过去的十年间,硬件取得了巨大的进步。从重新定义消费级 GPU 工作方式的统一内存架构,到可以在笔记本电脑上运行数十亿参数 AI 模型的神经引擎,硬件能力的提升令人瞩目。然而,软件性能却仍然不尽如人意:简单的无服务器函数需要数秒的冷启动时间,仅仅是将 CSV 文件转换为数据库行的 ETL 管道就需要数小时的处理时间。这种软件与硬件能力之间的巨大差距,正是 “机械亲和”(Mechanical Sympathy)这一理念要解决的问题。

机械亲和的起源与内涵

机械亲和这一概念源自一位高频交易工程师 Martin Thompson。他在 2011 年注意到软件性能问题的根源在于缺乏对硬件的 “亲和性”,并从一级方程式赛车冠军 Jackie Stewart 的话语中借用了这一术语:“你不需要成为一名工程师才能成为赛车手,但你需要机械亲和。” 虽然我们通常不是在驾驶赛车,但这个概念对软件从业者同样适用。通过对运行软件的硬件 “倾注同理心”,我们可以构建出性能优异到令人惊讶的系统。著名的 LMAX 架构正是机械亲和原则的典型实践,它在单条 Java 线程上实现了每秒数百万事件的处理能力。

机械亲和的核心理念始于理解 CPU 如何存储、访问和共享内存。现代 CPU,无论是英特尔的产品还是苹果的芯片,都将内存组织成一种层级结构,每一层具有不同的访问延迟。CPU 的每个核心拥有自己的高速寄存器和缓冲区,用于存储局部变量和正在执行的指令;每个核心还拥有自己的 L1 缓存,比寄存器和缓冲区大一些,但速度稍慢;L2 缓存比 L1 更大,在 L1 和 L3 之间起到缓冲作用;多个核心共享 L3 缓存,这是最大的缓存层,但速度远低于 L1 和 L2;最后,所有核心共享对主内存(RAM)的访问,这是 CPU 访问速度最慢的层次。由于 CPU 缓冲区非常小,程序经常需要访问较慢的缓存层或主内存。为了隐藏这些访问的成本,CPU 会进行 “预测”:最近访问的内存很可能很快再次被访问;最近访问内存附近的内容很可能很快被访问;内存访问很可能会遵循相同的模式。这些预测在实践中意味着线性访问优于同一内存页内的访问,而后者又远远优于跨页的随机访问。

缓存行与伪共享

在 L1、L2 和 L3 缓存中,内存通常以称为 “缓存行”(Cache Line)的块形式存储。缓存行的长度通常是 2 的幂次方,常见为 64 字节。CPU 总是以缓存行的整数倍来读取或写入内存,这就引出了一个微妙的问题:如果两个 CPU 写入同一缓存行中的两个不同变量,会发生什么?

这就是 “伪共享”(False Sharing):两个 CPU 争夺同一缓存行中两个不同变量的访问权,迫使 CPU 通过共享的 L3 缓存轮流访问这些变量。为了防止伪共享,许多低延迟应用程序会使用 “填充” 数据来填充缓存行,使每行实际上只包含一个变量。有无填充的差异是惊人的:没有填充时,缓存行伪共享会导致延迟随线程数量近似线性增长;有填充时,延迟几乎不随线程数量变化。重要的是,伪共享只出现在变量被写入时。当变量只被读取时,每个 CPU 可以将缓存行复制到其本地缓存或缓冲区,而不必担心与其他 CPU 的缓存行状态同步。因此,原子变量是伪共享最常见的受害者之一。如果你在多线程应用程序中追求最后的性能提升,需要检查是否有任何被多个线程写入的数据结构,以及该数据结构是否可能成为伪共享的牺牲品。

单写者原则与自然批处理

构建多线程系统时,伪共享并不是唯一的问题。还存在安全性和正确性问题(如竞态条件)、线程超过 CPU 核心数量时的上下文切换成本,以及互斥锁(锁)的巨大开销。这些观察引出了我最常使用的机械亲和原则:单写者原则(The Single Writer Principle)。

从概念上讲,这个原则很简单:如果应用程序要写入某些数据(如内存变量)或资源(如 TCP 套接字),所有这些写入都应该由单个线程执行。考虑一个最小化的 HTTP 服务示例,它接收文本并生成该文本的向量嵌入。这些嵌入将通过服务内的文本嵌入 AI 模型生成。在上面的朴素架构中,我们使用互斥锁来解决这个问题。然而,如果多个请求同时到达服务,它们将为互斥锁排队,并迅速陷入队首阻塞。我们可以通过重构来应用单写者原则,从而消除这些问题。首先,我们可以将对模型的访问包装在一个专用的 Actor 线程中。不再是请求线程竞争互斥锁,而是向 Actor 发送异步消息。由于 Actor 是唯一的写者,它可以将独立请求分组为单个批量推理调用,然后将结果异步发送回各个请求线程。

使用单写者原则,我们消除了简单 AI 服务中的互斥锁,并添加了对批量推理调用的支持。那么 Actor 应该如何创建这些批次呢?如果我们等待达到预定义的批次大小,请求可能会无限期阻塞,直到有足够多的请求到来。如果我们在固定间隔创建批次,请求将在每次批次之间受到有界的延迟阻塞。有一种更好的方法:自然批处理(Natural Batching)。使用自然批处理,Actor 一旦队列中有可用请求就开始创建批次,并在达到最大批次大小或队列为空时完成批次。与基于超时的批处理策略相比,自然批处理将最佳情况延迟减半,最坏情况延迟也减半。如果单个写者处理一批写操作(或读操作),请贪婪地构建每个批次:一旦数据可用就开始批次,当数据队列为空或批次满时就结束。

实践建议与权衡

这些原则不仅适用于单个应用程序,而且可以扩展到整个系统。可预测的顺序数据访问适用于大数据湖,就像适用于内存数组一样。单写者原则可以提升 IO 密集型应用程序的性能,或为 CQRS 架构提供强大的基础。当我们编写具有机械亲和性的软件时,性能自然会随之提升,无论规模大小。

但在你离开之前,请务必优先考虑可观测性,然后再进行优化。你无法改进你无法衡量的东西。在应用任何这些原则之前,请定义你的 SLI、SLO 和 SLA,以便知道在哪里集中注意力以及何时停止。优先考虑可观测性,然后再优化,在应用这些原则之前,先测量性能并理解你的目标。

资料来源:本文主要参考 Martin Fowler 在 martinfowler.com 上发表的「Principles of Mechanical Sympathy」一文。