跳到主要内容
博客

低延迟C++工程实践:无锁队列、内存池、伪共享与编译优化的纳秒账本

时间: 2026-10-24

低延迟C++工程实践:无锁队列、内存池、伪共享与编译优化的纳秒账本

行情数据离开网卡、进入 CPU 之后,战场就移交给了软件。

低延迟 C++ 和一般意义上的“高性能 C++”不是一回事。高性能追求吞吐量——单位时间处理更多任务;低延迟追求的是单次路径的确定性和极值——这一笔委托从进来到出去,每一纳秒花在哪里都要能说出账。优化方法也随之不同:不看平均数,看尾部;不算总算力,算单条路径上每一笔开销。

这篇文章把低延迟 C++ 工程里最经典的四笔账——无锁队列、内存池、伪共享、编译优化——逐一算清楚。开始之前,先立一把尺子。

先立尺子:纳秒账本的刻度

没有数量级直觉,优化就是盲人摸象。工程上常用的一组刻度大致是:

  • L1 缓存命中:约 1 纳秒
  • L2 缓存命中:约 4 纳秒
  • 主存访问(缓存未命中):约 60~100 纳秒
  • 一次原子读改写操作(contested 情况下):数十到上百纳秒
  • 一次系统调用:数百纳秒
  • 一次线程上下文切换:数微秒

记住这把尺子,后面每一笔账的意义就直观了:一次缓存未命中够做几十次 L1 访问,一次上下文切换够内存访问几十次。低延迟工程的本质,就是把热路径上的“贵操作”一个个换成“便宜操作”。

无锁队列:线程间交接的标准件

交易系统的线程模型大多是流水线:网络线程收行情,交给策略线程,策略线程再交给报单线程。线程之间的每一次交接都经过一个队列,这个队列就在热路径正中央。

为什么必须无锁

一把互斥锁的代价不只是加锁本身,而是失败时的代价——锁竞争一旦触发线程挂起和上下文切换,开销直接从几十纳秒跳到数微秒,延迟分布的尾巴就是这么来的。无锁结构用原子操作替代锁,把最坏情况从“微秒级挂起”压回“纳秒级重试”。

SPSC 是甜点

单生产者单消费者队列是无锁队列里性价比最高的形态:一个环形缓冲区加两个游标(head/tail),生产者写 tail、消费者写 head,各改各的,互不干扰。内存序用 acquire/release 就够——消费者读 tail 用 acquire,保证看到游标之前的数据都可见;生产者写 tail 用 release,保证数据先于游标可见。用默认的顺序一致(seq_cst)也能对,但每一拍都在为多付的同步成本买单。

两个细节别漏

一是消费者批量出队:一次原子读把一批元素拿走,摊薄单元素的同步成本。二是写多少申请多少:生产者按批次推进游标,而不是每写一个元素推一次。队列越深不是越好——容量够吸收突发抖动即可,过深的队列只会拉长缓存行的污染范围。

内存池:把“不确定”从分配器里拿掉

标准分配器的问题不是慢,是不可预测。malloc 内部有锁、有空闲链表管理、有向操作系统要页的可能,一次分配的耗时从几十纳秒到微秒级浮动——这种浮动本身就是低延迟系统不能接受的东西。

内存池的思路是预支和定额

启动时一次性申请一大块内存,切成固定长度的块,用空闲链表管理。分配就是摘链头、释放就是挂回去,两个操作都是常数时间且无锁,耗时完全可预测。订单、行情快照、策略信号这类生命周期短、大小固定的对象,是内存池的标准住户。

页错误是隐藏的地雷

申请来的内存第一次被访问时才真正分配物理页,触发 page fault——一次就是微秒级。所以内存池申请之后要立刻逐页“摸一遍”(预触发),把 page fault 全部消化在启动阶段。同样的道理,大页内存(2MB 或 1GB 的 huge page)能大幅减少 TLB 未命中,热数据驻留的数组值得放进去。

一个更激进的推论:热路径上不分配

内存池解决的是“分配可控”,最高境界是热路径根本不分配——对象复用、环形缓冲复用,让内存操作从热路径上彻底消失。

伪共享:最隐蔽的性能小偷

伪共享(false sharing)是多线程程序里最阴的坑,因为代码完全正确,只是慢。

根源在硬件:CPU 缓存按 64 字节的缓存行为单位工作,缓存一致性协议也以行为单位。两个线程分别读写两个逻辑上无关的变量,只要这两个变量恰好落在同一个缓存行里,每次写入都会导致对方的缓存行失效——两个核心开始疯狂互相“踢”对方的缓存,性能可以掉数倍。

最经典的事故现场就是无锁队列的 head 和 tail:两个游标各 8 字节,天然挤在同一个缓存行里。生产者每推一次 tail,消费者的 head 缓存行失效;消费者每推一次 head,生产者那边跟着失效。队列逻辑越正确,互相踢得越勤快。

修法简单粗暴:对齐填充

用 alignas(64) 让每个被不同线程写的变量独占一个缓存行,浪费几十字节内存,换缓存行的相安无事。同样的原则适用于线程局部的计数器数组、每线程状态位图——凡是“多线程各写各的”的结构,都要逐个检查有没有挤在同一行里。

编译优化:有用,但排在测量后面

编译器能做的事情比多数人想象的多:-O2/-O3 的内联和向量化、LTO(链接时优化)打破编译单元边界、PGO(按真实运行 profile 指导优化)对分支预测布局的改进,每一项都可能带来可观收益。分支提示(likely/unlikely)在错误处理密集的代码里也有价值——把异常分支挪出热路径的指令缓存。

但低延迟圈有句老话:先测量,再优化。编译器优化对“已经写对的热路径”是放大器,对“选错的数据结构”无能为力。无锁队列选错、内存乱分配、伪共享没排查,编译器再努力也救不回来。工具链的顺序应该是:先用 perf 找到热路径和 stall 点,用时间戳(RDTSC 或时钟源)在代码里埋点逐段计时,确认瓶颈之后,再谈优化手段。

编译选项之外,运行环境同样计入账本:CPU 频率锁定、中断隔离(isolcpus)、线程绑核、NUMA 节点就近分配——这些不属于 C++ 语法,但每一项都可能比代码级优化影响更大。

把账记完:低延迟是一种纪律

四笔账算完,可以总结出低延迟 C++ 的方法论:

  • 热路径上消灭贵操作:锁、动态分配、系统调用、缓存未命中,一个个排查替换
  • 数据结构按访问模式设计:谁写谁读、并发度多少,决定队列和容器的形态
  • 对齐到硬件:缓存行是真实的物理存在,内存布局要尊重它
  • 测量驱动一切:平均值骗人,看 P99 和 P99.9;感觉骗人,看计数器

这种工程纪律在交易基础设施里是肌肉记忆。盛立科技 的 REM 柜台、EFH 行情以 FPGA 硬件链路著称,但主机侧软件的工程素养同样是系统表现的一部分——硬件把链路延迟压到纳秒级,软件侧任何一笔失控的开销都会把硬件省下来的时间还回去。面向证券、期货市场的解决方案里,REM 柜台专门做了无冷态效应优化来压住性能抖动,本质上就是这套“把每一纳秒记账”的纪律在系统层面的体现。

写在最后

低延迟工程没有银弹,只有账本。无锁队列、内存池、缓存行对齐、编译优化,每一项的收益单看都是几十几百纳秒,加起来就是微秒级的代差——在低延迟交易里,这就是胜负手。

盛立科技在这条路上走了近二十年,早年与华为的合作项目曾入围科技部重点研发专项,2026 年又先后与智臾科技(DolphinDB)达成战略合作、获香港交易所两项年度大奖。纳秒级的世界里,慢功夫才是真功夫。

常见问题

Q1:什么是伪共享(False Sharing)?

两个线程读写逻辑上无关、但物理上落在同一个 64 字节缓存行里的变量时,缓存一致性协议会导致双方缓存行反复失效,性能可下降数倍。解法是 alignas(64) 对齐填充,让每个线程独占缓存行。

Q2:无锁队列为什么比加锁队列快?

锁竞争失败会触发线程挂起和上下文切换,开销从几十纳秒跳到数微秒,造成尾部延迟失控。无锁队列用原子操作替代锁,最坏情况只是纳秒级重试。单生产者单消费者(SPSC)环形队列是延迟最优的形态。

Q3:内存池解决了什么问题?

标准分配器的分配耗时不确定(几十纳秒到微秒浮动),内存池通过启动时预分配定长块、空闲链表管理,把分配和释放变成常数时间的无锁操作。配合预触发页错误和大页内存,可彻底消除热路径上的分配抖动。

Q4:低延迟 C++ 优化应该从哪里开始?

先测量后优化:用 perf 定位热路径和停顿点,用 RDTSC 埋点逐段计时,重点看 P99 而非平均延迟。常见收益顺序是:数据结构选型 > 消除锁和动态分配 > 缓存行对齐 > 编译器优化与运行环境调优。

Q5:编译器优化选项能带来多少收益?

-O2/-O3、LTO、PGO 各有可观收益,但只对已经选对了数据结构和算法的路径起放大作用。选错的无锁结构或未排查的伪共享,编译器无法挽救。分支提示对错误处理密集的代码有效。