领域:低延迟规则
测量应说明是热运行还是冷运行,并与生产环境匹配 [R:测量与报告场景匹配]
面向Rust和Java受众。
源自JMH及其示例(Aleksey Shipilev),其存在本身就证明了JVM上手写的微基准测试默认是错误的。
说明测量是在哪种场景下运行的,并检查它是否与生产环境的运行场景一致。 这里存在两个错误,但只有其中一个臭名昭著。
在生产环境是热运行时测量冷运行。 首次执行时,代码可能是解释执行或从冷指令缓存开始,分支预测器未经训练,页表未填充,并且在托管运行时上没有编译后的代码。该数值可能比稳态差一到两个数量级,并且它描述的是预热过程而不是实际工作。在JVM上,升级是分阶段的——解释器,然后是C1,接着是C2,对长时间运行的循环进行栈上替换——这需要数千次迭代,这就是为什么手写的计时循环默认是错误的,以及为什么需要测试框架。
在生产环境是冷运行时测量热运行,这半部分通常不受关注,因为它看起来像是严谨。一条每分钟运行一次的路径永远不会是热的。市场开盘的突发流量会进入冷的代码。错误处理程序在最糟糕的时刻当天首次运行。对于这些情况中的每一种,热运行数值都是用户永远不会看到的数字,而仔细丢弃的预热正是生产行为。在这种情况下丢弃它不是卫生习惯;它是在删除测量结果。
所以规则是范围匹配而不是过程:说明测量了哪种场景,说明生产环境处于哪种场景,并证明它们相同。如果生产环境确实同时存在两种情况——例如稳态流伴随着冷突发——那就应该是两个测量值和两个报告的数字,而不是一个被平均到毫无意义的数字。
这是[R:测量声明而非子集]在时间维度上的应用:预热样本是一个子集,丢弃它们是范围决策,而凭直觉做出的范围决策会制造出一个因“被测量过”而具有权威性的错误数字。
故障模式检查:生产路径在关键时刻是热的还是冷的,而我测量的是哪一种? 如果基准测试丢弃了前N次迭代,请说明生产环境中有什么对应这些迭代。如果什么都没有,那么被丢弃的部分就是答案。
两个相邻但独立的规则。[R:无协调遗漏]涉及仪器无法在相关事件期间采样的问题;而本规则涉及仪器在错误场景下采样的问题。[R:验证抽象被编译优化掉了]涵盖了优化器删除了基准测试正在计时的实际工作的情况——实际上是对“无事”的热测量。
无界队列将过载转化为延迟而非拒绝 [R:无界队列无过载行为]
面向Rust和Java受众。
源自利特尔定律(J. D. C. Little, 1961):L = λW,因此对于一个永不拒绝的队列,一旦到达率超过服务率,等待时间将无限增长。
决定当消费落后于生产时队列做什么。增长不是一种决策。
队列深度就是延迟——这是利特尔定律,而非启发式方法:假设到达率为λ,平均队列长度为L,则平均等待时间为L/λ,因此无界的L意味着无界的等待。因此,一个没有容量限制的队列就没有延迟限制,并且在其上游任何地方声明的延迟要求都无法得到保证。
这种故障具有典型的特征,导致诊断滞后。生产者从不阻塞,因此上游不会报告问题;吞吐量看似正确,因为每个项目最终都会被处理;唯一的症状是延迟单调递增。当问题可见时,积压的工作已经无人需要——每个被消费的项目都已经过时,因此容量正被用于提供对已经过去时刻的问题的答案,这就是为什么即使负载恢复正常,系统也无法恢复。它必须首先排空积压,而排空速度与导致积压的速度相同。
因此,为队列设置边界,并选择在边界时的行为。有四种合理的选择,且选择是一个领域决策,而非技术决策:
- 阻塞生产者。 真正的背压——最慢的消费者为所有人设定速率,压力传播给能够真正消除它的人。在生产者不能被停止的情况下是错误的,这正属于
[R:发布窗口内无阻塞]的范畴。 - 丢弃最旧的。 适用于市场数据、传感器读数、遥测数据:过时的tick具有负价值,最新的tick是唯一有价值的。
- 丢弃最新的。 适用于积压是公平队列且后来者未得到任何承诺的情况。
- 拒绝并告知。 适用于订单录入以及任何调用方必须知道其命运的场所。静默丢弃的订单比被拒绝的订单更糟糕,其差距等于调用方对调用所给予的信任宽度。
不选择就等于选择了第五种没人会故意选择的行为:吸收一切,同等程度地降低每个项目的延迟,最终耗尽内存而失败——或者,在托管运行时上,由于积压本身导致垃圾回收暂停,这进一步减慢了消费者速度,这几乎是生产环境中负反馈循环最接近的体现。
在边界确实存在于其他地方的情况下,无界结构仍然是正确的选择——例如,生产者被上游限流的队列,或者消费者的速度被证明快于任何可能的到达率的队列。在代码中,在队列旁边说明是哪种情况。一个附有明确理由的无界队列是一个决策;一个没有理由的无界队列只是披着同样外衣的“没有决策”。
故障模式检查:如果消费落后生产十秒钟,这个队列会做什么,谁会知道? 如果答案是“它会增长”和“没人知道”,那么这里就没有过载行为,只有一个等待发生的过载症状。
结构仅在证明时假设的并发场景下才正确 [R:结构保持其证明时的并发场景]
面向Rust和Java受众。
源自Dmitry Vyukov,Intrusive MPSC Node-Based Queue,1024cores——其中并发场景在算法名称中声明,但在代码中无处可寻。
从结构的正确性论证中解读出并发场景,然后说明代码中有什么防止了违反该场景。“文档说单消费者”不是一种机制。
MPSC意味着多生产者、单消费者,这里的“单”是一个前提条件而非描述。Vyukov的mpscq_pop读取self->tail,从中遍历,然后写回——这是一个普通的加载和普通的存储,没有同步,因为假定恰好一个线程在执行它们。两个消费者在此交错,两者都可能返回同一个节点,或者其中一个可能完全丢失一个节点。这两种结果都不会产生任何异常。没有断言,没有锁争用,没有错误路径:队列返回看似合理的值,程序继续运行,但包含重复或丢失的项目。
这种静默是最大的问题,并且负载会放大它。两个消费者运行一百次操作可能永远不会在关键的窗口内交错;测试套件通过,阶段测试通过,交错却随着生产流量到来。一个只在你无法复现的条件下出现的缺陷,从外部来看,与完全其他某个地方的缺陷无法区分。
因此,在语言允许的情况下,将并发场景放入类型中。在构造时只分发一个消费者句柄;让它既不能Clone也不能Sync;在pop上使用&mut self,以便借用检查器强制执行独占性。那么“两个消费者”就不是一个需要发现的bug,而是一个无法编译的程序——这是[R:使非法状态不可表示],以及[R:协议的类型状态],当并发场景在结构生命周期内发生变化时。这是整个领域中该纪律的最高杠杆实例,因为另一种控制手段是头文件注释中的一句话,而它要防止的故障是不可归因的。
当语言无法承载时——Java没有这样的句柄——控制手段必须是实际运行的东西:在首次使用时记录拥有线程,并在后续每次调用时在调试构建中断言,或者通过一个单个对象路由所有消费,该对象的构造是声明不变量的唯一位置。注释不是控制([R:保证需要读取者])。
此类问题比队列更广泛,其他成员以相同方式失效。两个生产者的SPSC环形缓冲区。一个被记录为不可重入却从信号处理程序或回调中进入的结构。一个HashMap被两个线程访问,因为包含它的字段在三次重构后变成了共享的。一个在变异发生时持有的迭代器。在每种情况下,并发场景都是正确性论证的一个假设,而人工制品中没有任何东西记录它。
故障模式检查:这个结构正确性论证假设的是什么并发场景,如果我立即违反它,什么会失败? 如果诚实的答案是“名字是这么说的”或“我们很小心”,那么这个结构就是无保护的,下一个接触它的人不会知道。
从未被触及的内存还不是内存 [R:已分配不等于已驻留]
面向Rust和Java受众。
源自Ulrich Drepper,《每个程序员都应该了解的内存知识》(2007),关于按需分页和首次触发的成本。
预分配不是准备。成功的分配购买了地址空间;页面在首次触发时到达,而触发才是成本所在。
malloc和mmap返回虚拟地址。物理页面是惰性提供的:对每个页面的首次写入会陷入内核,内核找到一个帧,将其清零——因为前一个租户的数据不能泄露——然后进行映射。因此,一个在启动时分配、在热路径上首次写入的1GB缓冲区,大约包含25万次缺页,每页一次,分布在第一次遍历它的过程中。该路径上的每个无分配断言都得到满足,但每个断言都在测量错误的东西:没有调用分配器。
一旦实现,驻留也不是永久的。在内存压力下或交换时,页面可以被回收,因此一个长时间运行的进程,如果在启动时触及其缓冲区然后闲置,可能会在一个已有一周没有缺页的路径上再次触发缺页。这就是为什么补救措施有两部分,以及为什么只做第一部分是一个常见且昂贵的错误。
因此,对于任何带有截止时间的路径上的内容:在启动时,在任何实际工作到来之前,写入该路径将触及的每一页——触发必须是写入,因为读取一个未触及的页面可能由共享的零页提供,证明不了什么。然后锁定它:mlockall(MCL_CURRENT | MCL_FUTURE),并且机器上不进行交换。在JVM上,-XX:+AlwaysPreTouch为堆完成了第一部分,并且值得以较慢的启动为代价,正因如此成本被移出了有界路径。
大页面在两个方向上都改变了算术,值得做出自己的决定,而不是使用默认值。更少、更大的页面意味着更少的缺页和更低的TLB压力;每次缺页更大,并且透明大页面带来了一条碎片整理路径,可能在最糟糕的时刻使线程停顿数毫秒。在启动时保留的显式大页面避免了这一点;将THP设置为always并寄希望于运气,正是这个领域不断需要指出的形状。
从将拥有内存的线程触发,而不是从执行设置的线程。 在多插槽机器上,首次写入也决定了放置:页面被分配在触发它的线程所在的NUMA节点上,因此满足此规则的显而易见的方式——初始化线程中的一个循环遍历每个缓冲区——将每个页面放在该线程的节点上,而固定在另一个插槽上的工作者随后通过互连读取所有页面,延迟大约是本地延迟的两倍。现在路径是无缺页的,但完全是远程的:以更快的首条消息为代价换来了更慢的稳态。首先固定拥有线程,然后让每个线程触发它将使用的内存。
故障模式检查:这条路径将触及的每一页是否已被将要读取它的线程写入,并且什么阻止它被回收? 三个问题,因为只回答第一个会留下一个在空闲之前很快的系统,而只回答前两个会留下一个在单插槽上很快的系统。
[R:热路径无分配]是姊妹规则,两者互不包含——那个是关于进入分配器,而这个关于它已经返回的内存。[R:在对应层回答需求]解释了为什么所有补救措施都是内存策略、锁或启动参数:没有容器或crate的选择能达到这个层次。[R:发布窗口内无阻塞]是它咬人最厉害的地方,因为写入者两次存储之间发生的缺页会将窗口保持其整个持续时间。
在需求所在的层回答需求 [R:在对应层回答需求]
面向Rust和Java受众。
在选择任何东西之前,说明需求位于哪一层,并在该层满足它。
关于机器的需求——不要扰动相邻的隔离核心,不要在发送路径上触发缺页,不要让实时线程争用的锁——不能通过选择更快的crate、信誉更好的传输或异步API来回答。那些是库级别的决策。它们可能是正确的,甚至可能有所帮助,但它们不保证系统级别的约束,并且将它们视为能保证,会把一个开放问题转化为一个背后什么也没有的已关闭问题。
错误不是错误的选择;而是错误的账目记录。一个标记为已满足的约束将不再被检查。一个开放的约束仍然...