低延迟系统开发#2

内存序与缓存行

为什么一个写不会马上被别的核看见(store buffer、invalidate queue),x86 为什么只允许 StoreLoad 乱序,release/acquire 怎么只在需要的地方补上顺序、在 x86 和 ARM 上各花多少钱;伪共享为什么和读到旧值是两回事。

约 12 分钟
本页目录

线程 A 先写 y = 1、再写 x = 1,线程 B 看到 x == 1 以后去读 y,一定能读到 1 吗?另一个问题:两个线程各自累加自己的计数器,不共享任何变量,代码也没有 bug,为什么两个计数器挨着放在同一个结构体里时,循环会慢好几倍?

两个问题背后是同一套硬件:每个核私有的缓存、64 字节的缓存行、让各核缓存保持一致的协议(MESI)。可它们是两类问题。第一个问的是正确性:读到的值对不对,答案非对即错;第二个问的是性能:值都读对了,花了多少个周期。 前者用 C++ 内存模型(std::atomic、memory_order)解决,后者用缓存行布局解决,混在一起就会拿错工具。第 1 节讲正确性,第 2 节讲伪共享。单个核内部的依赖链,是“值都对、照样慢”的另一种情况,放在 #9 循环优化 第 3 节。


1. 正确性:一个写为什么不会马上被别的核看见

1.1 store buffer:写先记下来,指令就算做完了

线程执行 x = 1 时,这个写不是直接进缓存的。它先进每个核私有的一个小队列,叫 store buffer,进去以后这条指令就算执行完了,线程接着执行下一条。

这样做是因为真正把写放进缓存很慢:如果别的核的缓存里也有这一行,就得先让它们把副本作废、等它们回确认(ACK),本核拿到这一行的独占权,写才能落进去,一来一回要几十纳秒。要是每个写都停下来等,流水线就一直在空转。有了 store buffer,等独占权这件事在后台进行,只有队列满了,流水线才会停下来。

store buffer 夹在流水线和 L1D 之间:等 ACK 的是队头这条写核 0(写方)执行单元(流水线)写一进队尾就算做完,接着执行下一条store buffer(本核私有,先进先出)ready = 1data = 42后进队头L1D队头拿到这一行的独占权(M),才写进来核 1(有 data 的副本)invalidate 请求ACKinvalidate queue先回 ACK,之后才作废L1Ddata 的旧副本 → I流水线不等 ACK;store buffer 满了,流水线才停。x86 上先进先出:data 进了 L1D,ready 才轮到,所以别的核不会先看到 ready。

图:store buffer 在执行单元和 L1D 之间排成一队。写进队尾,指令就退休;队头那条写要先让别的核作废副本、收齐 ACK,拿到这一行才进 L1D。所以要等 ACK 的是这条写,不是流水线;只有队满了,流水线才停下来。

代价是:一个写从“指令做完了”到“别的核看得见”,中间隔着一段时间,通常几十纳秒。 这段时间里,写还在 A 自己的 store buffer 里,别的核去读 x,读到的还是旧值。

线程 A 自己感觉不到这段时间。A 写了 x 以后马上读 x,CPU 会直接从 store buffer 里把刚写的值转给这次读(store-to-load forwarding),所以 A 看自己的写永远是按程序顺序来的。这段时间只有别的核才看得到,它不是 bug,也不是编译器造成的,是硬件为了不让流水线停下来必然付的代价。后面讲的乱序,都是从这里来的。

转发有一个条件:这次读要的字节,必须完整地落在 store buffer 里的某一条写之内。 读 4 字节、前面写过 8 字节且覆盖了这 4 字节,可以转发;写了 4 字节、紧接着从同一地址读 8 字节,一条写满足不了,转发失败,这次读要等写真正进了缓存才能完成,多等十几个周期。转发不会把几条写拼起来用。

1.2 invalidate queue:收到作废请求,先回 ACK 再处理

读的一方也有一个类似的缓冲。核 B 的缓存里有 x 这一行,核 A 发来作废请求时,B 不马上处理,而是把请求放进自己的 invalidate queue,立刻回 ACK,让 A 不用等。B 在处理这条请求之前去读 x,读到的还是自己缓存里的旧值。

两个缓冲合在一起,就是跨核读写会乱序的物理原因:

缓冲在哪一侧推迟了什么会造成的乱序
store buffer写方写对别的核可见后面的读越过前面的写(StoreLoad)
invalidate queue读方察觉到别人已经写了后面的读比前面的读还旧(LoadLoad)

x86 硬件保证读和读不乱序,所以在 x86 上,invalidate queue 造不成可见的乱序;ARM 这类弱序 CPU 不保证,两种都会出现。

写的一方有 store buffer,读的一方有 invalidate queue核 A(写方)x = 1;1写进 store bufferstore bufferx = 1指令到这里就算执行完了L1Dx = 0,收齐 ACK 才写成 1A 自己读 x,从 store buffer直接拿到 1;别的核还看不到。核 B(读方)invalidate queue作废 x还没处理L1Dx = 0(旧值,还没被作废)r = x;读到 042invalidate x3先回 ACK① A 的写进 store buffer,指令退休。② A 发出作废 x 的请求。③ B 把请求放进队列,马上回 ACK。④ B 读 x:请求还在队列里没处理,L1D 里的 x 没被作废,读到旧值 0。两个缓冲都是为了不让流水线等一次跨核往返;它们造成的乱序见上表。

图:A 的写进了自己的 store buffer,指令就退休了;B 收到作废请求,先放进 invalidate queue、马上回 ACK。B 在处理这条请求之前读 x,读到的还是旧值。两个缓冲存在的理由相同:别让流水线为一次跨核往返停下来。

把这两个缓冲放回整块芯片上,一次写要走过的路径如下,按步骤点下去看。invalidate 请求经片上互连,只发给缓存里有这一行的核,谁有由 L3 旁的 snoop filter 记着;新值不会被推过去,读方要用时自己来取。最后一步,读方已经看到了新的 ready,读 data 却拿到旧值,这就是读和读的乱序。

在新窗口打开 · 静态全图

1.3 四种乱序:x86 只允许 StoreLoad

对两个不同地址的读写,别的核可能看到四种乱序:

乱序含义x86 允许吗?
StoreStore两个写被别人看到的顺序反了不允许
LoadLoad后面的读拿到的值比前面的读还旧不允许
LoadStore后面的写在前面的读完成之前就可见了不允许
StoreLoad后面的读在前面的写可见之前就执行了允许

x86 的内存模型叫 TSO(Total Store Order):前三种由硬件保证,普通的 mov 就带着这个顺序,不用加屏障。唯一允许的 StoreLoad,就是 1.1 那段时间换了个名字:写还在 store buffer 里,后面对另一个地址的读已经执行了。

经典的例子是两个线程各自“先写自己的标志,再读对方的标志”:

// 开始时 x = y = 0
// 线程 1          // 线程 2
x = 1;             y = 1;
r1 = y;            r2 = x;

直觉上至少有一个线程能读到 1,可在 x86 上 r1 == 0 && r2 == 0 是可能的:两个写都还在各自的 store buffer 里,两个读已经读完了。在 x86 上需要专门防的只有这一种乱序,其他三种硬件已经禁止了。

1.4 先排除编译器:它也会重排

怀疑 CPU 乱序之前,先看编译器有没有动过代码。这个循环看上去每一轮都在读内存:

while (ready == false) {}

可是 ready 不是 std::atomic 也不是 volatile,编译器就可以认为这个线程之外没人会改它,只读一次,把循环改成:

if (!ready) { while (true) {} }   // 死循环,再也不读内存

这和 CPU 的内存模型无关,是编译器的合法优化。volatile 能阻止它,强制每一轮都真的读一次,但也只能做到这一点:volatile 不等于同步,它不规定和别的内存操作之间的顺序,管不了 1.1 那段时间。修好这个循环要用 std::atomic<bool>,也就是下一节。

1.5 release / acquire:只在需要的地方补上顺序

std::atomic 的读写可以带一个 memory_order 参数,说明这次操作要保证什么顺序:

内存序保证什么
relaxed只保证这一个变量的读写是原子的,不管和别的变量之间的顺序
release(写)这次写之前的所有读写,都不能被挪到它后面
acquire(读)这次读之后的所有读写,都不能被挪到它前面
acq_rel(读改写)同时是 acquire 和 release
seq_cst在 acquire/release 之上,所有 seq_cst 操作还有一个全局一致的先后顺序;不写参数时的默认值
consume已不推荐使用,编译器实际按 acquire 处理

最常用的是一对 release 和 acquire,用法像“发布、订阅”:一个 acquire 读在同一个原子变量上读到了一个 release 写的值,写方在 release 之前写的所有东西,读方在 acquire 之后都看得到。 别的变量不用是原子的:

struct SPSCFlag {
  std::atomic<bool> ready{false};
  int payload = 0;   // 普通 int,不需要是原子的
};

// 生产者线程
void publish(SPSCFlag &f, int value) noexcept {
  f.payload = value;                                 // 1. 普通写
  f.ready.store(true, std::memory_order_release);    // 2. release:1 已经写完了
}

// 消费者线程
auto try_consume(SPSCFlag &f) noexcept -> std::optional<int> {
  if (f.ready.load(std::memory_order_acquire)) {     // 3. acquire:和 2 配对
    return f.payload;                                // 4. 保证看到第 1 步的值
  }
  return std::nullopt;
}

payload 能安全地传过去,靠的只是 ready 上这一对 release/acquire。交易系统里的单生产者单消费者(SPSC)环形队列都是这个形状:一个原子下标负责同步,其余都是普通变量。

回到 1.2 动画里的那个例子,给写 ready 加上 release、读 ready 加上 acquire,再走一遍:硬件还是那两个缓冲,多出来的只有第 2 步和第 6 步这两个顺序约束,一个管写方,一个管读方。

在新窗口打开

1.6 价钱:x86 上几乎免费,ARM 上要专门的指令

同样的内存序,在不同 CPU 上编出来的指令不同:

操作x86-64ARMv8
acquire 读普通 movldar
release 写普通 movstlr
seq_cst 读普通 movldar
seq_cst 写xchg(或 mov 加 mfence)stlr
fetch_add 这类读改写带 lock 前缀的指令ldadd 一类原子指令,或 ldaxr/stlxr 循环

x86 上 acquire 和 release 编出来就是普通的 mov:它们要禁止的三种乱序,TSO 本来就禁止了。x86 上真正贵的是 seq_cst 的写和所有读改写操作,它们要把 store buffer 清空,等前面的写都可见了才能往下走,也就是要禁止 StoreLoad。所以 std::atomic 不写参数、用默认的 seq_cst 时,只需要发布、订阅的代码就多付了一笔。

ARM 的硬件四种乱序都可能做,所以 acquire/release 要用专门的 ldar、stlr(ARMv7 上是 dmb 屏障)。在 x86 上碰巧能跑的代码,比如用 volatile 代替 atomic 来传数据,到了 ARM 上就会读到旧值。C++ 内存模型按语义规定,而不是照搬 x86 的行为,就是为了让同一份代码在两种 CPU 上都对。


2. 伪共享:代码完全正确,照样慢

这一节的问题都可以出现在完全正确的代码上:每次读都读到了对的值,内存序也都用对了,按第 1 节的标准没有任何 bug。它是纯粹的性能问题,加原子操作、加屏障都修不好。

2.1 一致性协议管的是 64 字节的缓存行,不是变量

MESI 作废的单位是缓存行,x86 上一行是 64 字节。两个线程各写各的 int,两个 int 却恰好落在同一行里(比如结构体里相邻的两个字段),A 每写一次自己的变量,B 缓存里的整行就被作废;B 写自己的变量时,又要把这一行的独占权从 A 那里拿回来。这一行就在两个核之间来回搬,每次几十纳秒。这叫伪共享:两个变量在逻辑上毫无关系,“共享”只是内存布局造成的。

例子:两个线程各加各的计数器。

struct Counters {
  std::atomic<uint64_t> a{0};   // 只有线程 1 写
  std::atomic<uint64_t> b{0};   // 只有线程 2 写;和 a 挨着,落在同一条缓存行里
};
Counters c;

// 线程 1,绑在核 0
for (int i = 0; i < 100'000'000; ++i)
  c.a.store(c.a.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);

// 线程 2,绑在核 1
for (int i = 0; i < 100'000'000; ++i)
  c.b.store(c.b.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);

这里用 std::atomic 加 relaxed,只是为了让编译器每一轮都真的读写内存(1.4),x86 上编出来是普通的 mov,没有 lock 前缀,也没有屏障。每个变量只有一个线程写,代码完全正确。慢在这一条缓存行的独占权上:

  1. 核 0 读、写 a,要拿到这一行的独占权(M)。
  2. 核 1 读、写 b,是同一行,于是发请求把整行要过去,核 0 那一份变成 I。
  3. 核 0 下一轮读 a,本地已经没有这一行,只能等它从核 1 搬回来。

本来是 L1 命中、1 ns 左右的一次自增,变成了一次几十纳秒的跨核搬运,而且每一轮都可能来一次。把 a、b 分到两条缓存行上(2.3),同样的循环通常快几倍到十几倍。只跑一个线程时两种布局一样快,这也是伪共享难查的原因:单线程的测试测不出来,要多核同时写才冒出来,用 perf c2c 能看到具体是哪一行(B6 伪共享)。

2.2 伪共享和读到旧值是两回事

两者都涉及缓存行被作废,很容易混在一起:

读到旧值(1.2)伪共享
属于哪类问题正确性:读到的值对不对性能:读到要花多少周期
表现在一小段时间里读到一次旧值每次写都要把这一行的独占权抢过来,一直持续
修法memory_order,release/acquire改布局,alignas(64)
没有任何原子操作也会发生吗不会,得有线程间真正共享的数据会,两个互不相干的普通 int 就够了

最后一行最能说明区别:两个计数器各自只被一个线程写,没有原子操作,也没有数据竞争,只要在同一行里照样来回搬。一致性协议不知道变量之间有没有关系,它只知道两个核都要同一条 64 字节缓存行的独占权。

2.3 修法:一个热变量独占一条缓存行

2.1 的例子,只要让两个字段各占一条缓存行:

struct Counters {
  alignas(64) std::atomic<uint64_t> a{0};
  alignas(64) std::atomic<uint64_t> b{0};   // b 从下一条缓存行开始
};

每个线程一个计数器的数组也一样,给元素类型加 alignas(64):

struct alignas(64) PaddedCounter {
  std::atomic<uint64_t> value{0};
  // alignas(64) 让 sizeof 也补到 64,相邻元素不会落进同一行
};

PaddedCounter counters[num_threads];   // 每个计数器独占一整条缓存行

alignas(64) 也可以写成 std::hardware_destructive_interference_size。有些 Intel 核的预取器会把相邻的两条缓存行成对取进来,所以也有代码按 128 字节隔开。

填充只能修伪共享,修不了真共享:几个线程确实在改同一个计数器时,这一行本来就得来回搬,这是算法决定的。缓解办法是少碰这条共享的行:每个线程先在自己的副本上累积,攒够一批或者隔一段时间,再合并到共享变量上一次,用一点延迟换大幅减少的跨核流量。

2.4 另一个“共享”:L3 被别的进程挤占

L1、L2 是每个核私有的,L3 是同一颗 CPU 上所有核共享的,这是设计如此,不会引起一致性协议的来回搬运。L3 上的问题是容量:别的核上一个无关的进程只要扫过足够多的数据,就能把你的工作集从 L3 里挤出去。Intel CAT(Cache Allocation Technology)解决这个问题:用 pqos 把 L3 按路分开,给你的进程留一块别人用不了的份额。

三个都和“共享”沾边的问题,修法各不相同,也互相替代不了:

问题性质修法
读到旧值顺序memory_order(第 1 节)
两个核抢同一条缓存行布局alignas(64)(2.3)
L3 被无关进程挤占容量Intel CAT

应用

第 2 节的伪共享,在实际优化里对应“B6 伪共享”这张卡片:在 perf 里看到什么、怎么改、怎么验证。

B6 伪共享

  • 信号:c2c 某行 Tot Hitm > 5%,同一行里不同偏移被不同线程写;1 核时不显眼,多核才冒出来;以同一 socket 内的 HITM 为主。
  • 原因:缓存行(通常 64 字节)是一致性的最小单位。A 线程写字段 X,B 线程那份整行也失效,要重新从 L3 或内存读。
  • 修法分三步:
    • 先多垫 padding,把打架的字段推到不同的行。这一步只为证明诊断对,先不管省空间;
    • 用 c2c 复测,该行 HITM 降到 1% 以下、吞吐上来才算对。降不下来说明诊断错了:要么是真共享,要么还有别的字段卷进来;
    • 再按访问模式重排:每个线程常写的字段各占一行,多线程只读的字段可以共用一行,很少碰的字段填空位。
  • 更彻底的做法:把线程私有的字段挪进每线程一份的结构体。
  • 验证:c2c 前后对比;用 _Static_assert 守住结构体大小。
  • 原理:#2 第 2 节 伪共享;两个核各写自己的变量时,这条行的状态怎么来回变,见 MESI 专题第 6 节;让对象独占整条行的 alignas(64) 见 #6 1.3。