3.2 组件间通信与同步 「lock-free」,中文圈常直译成"无锁",这个行话在高频系统里出现的频率高到近乎口头禅,但它真正承诺的不是"没有锁"这个字面事实,而是一个更强的性质:系统整体的前进不依赖任何一个侥幸拿到资源的线程。一把设计不当的互斥锁,持有线程若被内核抢占几百微秒,热路径上所有等待者集体陪绑——这就是为什么架构章要专门用一节讲通信与同步:组件切分(3.1)只是画好了齿轮,这一节负责让齿轮之间零间隙啮合。读完本节,你应当能为自己系统里的每一条组件间数据流,说出它用的通信原语、为什么是它、代价是什么。 读完你应当能 说明共享内存通信相对消息传递、系统调用的延迟优势及其一致性代价; 手写或读懂一个单写单读环形队列,解释它为什么无锁且缓存友好;
「lock-free」,中文圈常直译成"无锁",这个行话在高频系统里出现的频率高到近乎口头禅,但它真正承诺的不是"没有锁"这个字面事实,而是一个更强的性质:系统整体的前进不依赖任何一个侥幸拿到资源的线程。一把设计不当的互斥锁,持有线程若被内核抢占几百微秒,热路径上所有等待者集体陪绑——这就是为什么架构章要专门用一节讲通信与同步:组件切分(3.1)只是画好了齿轮,这一节负责让齿轮之间零间隙啮合。读完本节,你应当能为自己系统里的每一条组件间数据流,说出它用的通信原语、为什么是它、代价是什么。
组件既然在同一台机器(3.1 的结论),它们之间最便宜的通信介质就是同一片物理内存。相对消息中间件,共享内存省掉了序列化、网络栈、内核拷贝这三层,一次通信的成本从微秒级降到百纳秒以内。但省掉的不只是钱,还有顺序与边界的自动保证——消息系统天然帮你排队,共享内存里这些事全要自己管:谁写谁读、写到什么程度算完整、读的人按什么顺序消费。本节的全部技术内容,归结起来就是把这三件事做对。
工程上通行两种共享内存形态。快照区:一块由单一写者更新、多个读者轮询的数据区(比如最新盘口),读者拿版本号判断新旧,简单、低延迟,缺点是不保留历史——错过了中间几拍就错过了。事件队列:环形缓冲区按序保存事件流,读者维护自己的读指针,一个不漏。热路径上两种形态常并存:盘口状态走快照区供多处即时读取,事件流走队列供策略与回放按序消费。
无锁队列家族里,单写单读(SPSC)环形缓冲是最简单也最常用的一种:一个写者推进写指针,一个读者推进读指针,两个指针都是原子变量,各自只有一方写。因为没有两个写者竞争,它不需要比较交换循环,写入与读取都是纯顺序操作——这也是它快的原因:没有重试,就没有不可预测的延迟。
class SpscRing: def __init__(self, capacity_pow2): self.buf = [None] * capacity_pow2 # 环形槽位,容量取二次幂 self.mask = capacity_pow2 - 1 self.head = 0 # 读者推进:下一个要读的槽 self.tail = 0 # 写者推进:下一个要写的槽 def push(self, item): # 仅写者线程调用 while self.tail - self.head == len(self.buf): pass # 满:背压,读者太慢 self.buf[self.tail & self.mask] = item self.tail += 1 # 原子递增即"发布" def pop(self): # 仅读者线程调用 if self.head == self.tail: return None # 空:无数据,调用方自旋等待 item = self.buf[self.head & self.mask] self.head += 1 return item
十个槽位的动画都没必要——这段代码的全部精髓在两处。一,tail += 1 就是发布动作:写者先把数据放槽里再递增指针,读者看到指针变化时数据必然完整,这个顺序在强内存序的主流处理器上天然成立,跨平台时需要显式内存屏障,属于第五章的话题。二,队列满时写者自旋等待(背压),这把流控压力显式暴露出来:热路径组件的消费能力必须匹配生产峰值,否则背压会沿数据流逆向传导到行情接收——那是灾难级故障。生产实现还要加缓存行对齐(把 head 与 tail 放进不同的六十四字节缓存行,避免伪共享),这个细节留给第五章展开。

组件读队列时若无数据,怎么办?三种模型。阻塞等待让出 CPU,唤醒要经过内核,微秒级代价,热路径免谈。事件通知(如事件文件描述符机制)本质还是内核参与,同样出局。剩下自旋轮询:读者空转检查指针,烧着 CPU 换取纳秒级响应——热路径的标准答案。它成立的前提是专用核绑定:每条热路径线程独占物理核,不与操作系统或其他进程共享,否则自旋线程被调度器抢走,一切白搭。核绑定的具体配置(隔离核、中断亲和)在第五章内核调优节展开。
暗坑一是顺序。多个组件并行处理后,事件在日志里的先后可能与真实先后不一致——甲组件先见到事件 A 后见到事件 B,乙组件可能相反。如果冷路径的监控与回放不做全局定序,排障时会陷入"日志自相矛盾"的泥潭。通行解法是让行情接入处发全局递增序号,所有组件的日志带上自己消费到的序号,事后按序号归并。暗坑二是时钟。组件 A 打的时间戳与组件 B 的时间戳若取自不同的时钟源,微秒级比较就是自欺。热路径的纪律是全机统一用单调时钟打点,跨机比较才引入时钟同步协议——那套精密时间同步体系,第四章再讲。两个暗坑的共同教训:并发系统里,"先后"从来不是免费的,要么显式设计,要么显式放弃。
背景:某系统把风控状态放在一个受互斥锁保护的全局结构里,策略线程每次报单前加锁更新头寸。压测显示平均耗时二十纳秒,一切正常。
操作:上线后用逐笔全量打点观察分布,发现每几万笔出现一次三百微秒级的尖刺,时间点分散。对照内核日志,尖刺与操作系统的定时器中断及调度迁移强相关——锁的持有者偶发被抢走,排队者陪绑等待。
结果:把风控状态改成单写者模型:策略线程是唯一写者,检查逻辑改为对本地副本做无锁读取;全局聚合交给冷路径异步对账。尖刺消失,均值几乎未变。
解读:均值骗人,分布诚实。锁的问题从来不是平均代价,而是代价的不可控——你无法命令内核"此刻别抢占我的临界区"。无锁化的真正收益是把最坏情况的边界收窄,这正是钟表匠思维:不走快没关系,绝不能停摆。
变式:如果业务确实需要多写者(比如多条策略线共享一份头寸),与其上锁,不如改架构:给每条策略线独立头寸账户,风控按账户聚合检查,写者重归单一。数据流设计绕开共享写,几乎总比在锁上做文章划算。