从汇编看内存序:C++ 内存模型在 Intel 架构下到底做了什么

69 阅读11分钟

一、引言

在多线程程序中,C++ 内存模型定义了跨线程访问共享变量时的行为保证。
它规定了不同操作之间的 可见性 与 顺序性,通过诸如 memory_order_relaxed、memory_order_release、memory_order_acquire、memory_order_seq_cst 等语义,让开发者能够在性能与正确性之间做出权衡。
然而,C++ 的内存模型只是一个 抽象规范。真正执行这些语义的,是底层的硬件内存模型。
不同架构的 CPU(如 Intel x86 与 ARM)有着不同的内存一致性保证:

  • ARM的内存模型较弱,需要频繁使用内存屏障;
  • Intel 的内存模型天生较强,很多语义在硬件上就已经保证。

这篇文章就从汇编层面出发,看看:C++ 的各种内存序语义,在 Intel 处理器下 到底做了什么?

二、Intel 的天然强内存序(TSO)

TSO(Total Store Order) 模型。

Intel 的处理器自 P6 以来采用了所谓的 TSO 模型。
根据《Intel® 64 and IA-32 Architectures Software Developer’s Manual》10.2.2节的描述:
memory ordering 1 memory ordering 2 关注公众号“Hankin-Liu的技术研究室”,回复“intel manual"可获得此INTEL官方手册下载链接。

其核心特征可以概括如下:

  • 读不会乱序:
  • 写不会越过更早的读:
  • 写与写之间按顺序执行(除非是特殊的 streaming store 或字符串操作):
  • 读可能越过旧的写(不同地址):即允许「Read after Write」乱序,但同地址不行:
  • 跨核可见性保证:同一 CPU 核发出的写,在所有其他核上可见的顺序一致:
  • 锁指令形成全局顺序(total order):

这意味着:Intel 架构在多核环境下天然具备较强的内存可见性顺序。

🧩 TSO 模型的关键:Store Buffer + Load Forwarding

Intel 的强一致性来自两个机制的权衡:

  1. Store Buffer 写操作先进入缓冲区,异步刷入缓存。这样写线程不会被内存访问阻塞。

  2. Store-to-Load Forwarding 当程序紧接着读同一个地址,CPU 会直接从 store buffer 里读,避免看到旧值。

这种机制让读写看似“按程序顺序执行”,但实际上 CPU 在后台乱序执行。 Intel 的设计理念是:尽可能维持编程语义上的顺序,而不是强制所有指令完全按顺序执行。

三、从汇编看 C++ 内存序的真实区别

memory_order_relaxed

程序示例:

#include <atomic>
#include <iostream>
std::atomic<int> ready{};

__attribute__ ((noinline))
void set(int val)
{
    ready.store(val, std::memory_order_relaxed);
}

__attribute__ ((noinline))
void print()
{
    int val = ready.load(std::memory_order_relaxed);
    std::cout << val << std::endl;
}
int main(int argc, char** argv)
{
    int data = std::stoi(argv[1]);
    set(data);
    print();
    return 0;
}

反汇编

set函数

410 00000000004013f0 <_Z3seti>:
411       = __m & __memory_order_mask;
412     __glibcxx_assert(__b != memory_order_acquire);
413     __glibcxx_assert(__b != memory_order_acq_rel);
414     __glibcxx_assert(__b != memory_order_consume);
415 
416     __atomic_store_n(&_M_i, __i, int(__m));
417   4013f0:   89 3d de 2d 00 00       mov    %edi,0x2dde(%rip)        # 4041d4 <ready>
418 }
419   4013f6:   c3                      retq
420   4013f7:   66 0f 1f 84 00 00 00    nopw   0x0(%rax,%rax,1)
421   4013fe:   00 00

关注set函数汇编的417行,atomic的store在intel架构下就是一个普通的mov指令。
print函数

423 0000000000401400 <_Z5printv>:
424 {
425   401400:   41 54                   push   %r12
426     std::cout << val << std::endl;
427   401402:   bf c0 40 40 00          mov    $0x4040c0,%edi
428 {
429   401407:   55                      push   %rbp
430   401408:   48 83 ec 08             sub    $0x8,%rsp
431     memory_order __b __attribute__ ((__unused__))
432       = __m & __memory_order_mask;
433     __glibcxx_assert(__b != memory_order_release);
434     __glibcxx_assert(__b != memory_order_acq_rel);
435 
436     return __atomic_load_n(&_M_i, int(__m));
437   40140c:   8b 35 c2 2d 00 00       mov    0x2dc2(%rip),%esi        # 4041d4 <ready>
438     std::cout << val << std::endl;
439   401412:   e8 09 fd ff ff          callq  401120 <_ZNSolsEi@plt>
440   401417:   48 89 c5                mov    %rax,%rbp

关注print函数汇编的437行,atomic的load在intel架构下也是一个普通的mov指令。

结论

在intel架构下,atomic原子变量的relaxed语义的读写操作与普通变量的开销基本一致,没有额外的硬件负担。

memory_order_release、memory_order_acquire

程序示例:

#include <atomic>
#include <iostream>
#include <thread>

struct alignas(64) data
{
    uint64_t shared_data{0};
    char pad[64 - sizeof(shared_data)]{};
    std::atomic<bool> a_ready{false};
    char pad1[64 - sizeof(a_ready)]{};
};

data* d_ptr{nullptr};

int main() {
    d_ptr = new data();
    std::thread writer([] {
        d_ptr->shared_data = 5;
        d_ptr->a_ready.store(true, std::memory_order_release);
    });

    std::thread reader([] {
        while (!d_ptr->a_ready.load(std::memory_order_acquire)) {}
        std::cout << "data is " << d_ptr->shared_data << std::endl;
    });

    writer.join();
    reader.join();
    delete d_ptr;
    return 0;
}

反汇编

388 0000000000401430 <_ZNSt6thread11_State_implINS_8_InvokerISt5tupleIJZ4mainEUlvE_EEEEE6_M_runEv>:
389         d_ptr->shared_data = 5;
390   401430:   48 8b 05 a1 2d 00 00    mov    0x2da1(%rip),%rax        # 4041d8 <d_ptr>
391   401437:   48 c7 00 05 00 00 00    movq   $0x5,(%rax)
392       = __m & __memory_order_mask;
393     __glibcxx_assert(__b != memory_order_acquire);
394     __glibcxx_assert(__b != memory_order_acq_rel);
395     __glibcxx_assert(__b != memory_order_consume);
396 
397     __atomic_store_n(&_M_i, __i, int(__m));
398   40143e:   c6 40 40 01             movb   $0x1,0x40(%rax)
399       { }
400 
401     void
402     _M_run() { _M_func(); }
403   401442:   c3                      retq   
404   401443:   90                      nop
405   401444:   66 66 2e 0f 1f 84 00    data16 nopw %cs:0x0(%rax,%rax,1)
406   40144b:   00 00 00 00 
407   40144f:   90                      nop

写线程(store(release))分析:

  1. 关注写线程汇编的398行,atomic store的release语义在intel架构下就是普通的mov指令。没有 mfence、sfence、lock 前缀等同步指令;
  2. 执行顺序是:
    (1)先写共享数据;
    (2)再写 ready 标志。
  3. 在 Intel 的 TSO 模型(Total Store Order) 下,硬件天然保证:
    (1)写操作不会乱序(store-store 不重排),第二条 movb 不会在第一条之前可见;
    (2)因此,“release” 语义自动成立,即「在这次 store 之前的写操作(shared_data = 5)」一定在这次 store(a_ready)之前对其他线程可见。
409 0000000000401450 <_ZNSt6thread11_State_implINS_8_InvokerISt5tupleIJZ4mainEUlvE0_EEEEE6_M_runEv>:
410   401450:   41 54                   push   %r12
411   401452:   55                      push   %rbp
412   401453:   48 83 ec 08             sub    $0x8,%rsp
413   401457:   66 0f 1f 84 00 00 00    nopw   0x0(%rax,%rax,1)
414   40145e:   00 00 
415     memory_order __b __attribute__ ((__unused__))
416       = __m & __memory_order_mask;
417     __glibcxx_assert(__b != memory_order_release);
418     __glibcxx_assert(__b != memory_order_acq_rel);
419 
420     return __atomic_load_n(&_M_i, int(__m));
421   401460:   48 8b 05 71 2d 00 00    mov    0x2d71(%rip),%rax        # 4041d8 <d_ptr>
422   401467:   0f b6 40 40             movzbl 0x40(%rax),%eax
423         while (!d_ptr->a_ready.load(std::memory_order_acquire)) {}
424   40146b:   84 c0                   test   %al,%al
425   40146d:   74 f1                   je     401460 <_ZNSt6thread11_State_implINS_8_InvokerISt5tupleIJZ4mainEUlvE0_EEEEE6_M_runEv+0x10>

读线程(load(acquire))分析:

  1. 读线程汇编的422行,movzbl 0x40(%rax), %eax 是普通的 load;
  2. 没有任何 fence 或 lock;
  3. 语义上的“acquire”保证在于:
    (1)编译器不会把这次 load(对 a_ready)重排到后面的读(shared_data)之后;
    (2)而在 Intel 的硬件层:虽然 TSO 允许 store-load 乱序,但不会发生 load-load 乱序。也就是说,本线程后续的读操作不会被重排到这次 acquire 之前,而跨线程的可见性仍由缓存一致性协议保证。

结论

在intel架构下,atomic原子变量的release/acquire语义的读写操作与普通变量的开销十分接近,只是对编译器乱序进行了限制,几乎没有额外的硬件负担。

memory_order_seq_cst

程序示例:

#include <atomic>
#include <iostream>
#include <thread>

struct alignas(64) SharedData {
    std::atomic<int> a{0};
    std::atomic<int> b{0};
};

SharedData data;

void thread1() {
    data.a.store(1, std::memory_order_seq_cst); // write a
    int b_val = data.b.load(std::memory_order_seq_cst); // read b
    std::cout << "Thread1 read b = " << b_val << std::endl;
}

void thread2() {
    data.b.store(2, std::memory_order_seq_cst); // write b
    int a_val = data.a.load(std::memory_order_seq_cst); // read a
    std::cout << "Thread2 read a = " << a_val << std::endl;
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    std::cout << "Final values: a = " << data.a.load() 
              << ", b = " << data.b.load() << std::endl;
}

反汇编

349 00000000004013a0 <_Z7thread2v>:
350 void thread2() {
351   4013a0:   41 54                   push   %r12
352     __atomic_store_n(&_M_i, __i, int(__m));
353   4013a2:   b8 02 00 00 00          mov    $0x2,%eax
354   4013a7:   55                      push   %rbp
355   4013a8:   48 83 ec 08             sub    $0x8,%rsp
356   4013ac:   87 05 52 2e 00 00       xchg   %eax,0x2e52(%rip)        # 404204 <data+0x4>
357     __ostream_insert(__out, __s,
358   4013b2:   ba 11 00 00 00          mov    $0x11,%edx
359   4013b7:   be 10 20 40 00          mov    $0x402010,%esi
360   4013bc:   bf c0 40 40 00          mov    $0x4040c0,%edi
361     return __atomic_load_n(&_M_i, int(__m));
362   4013c1:   8b 2d 39 2e 00 00       mov    0x2e39(%rip),%ebp        # 404200 <data>
363   4013c7:   e8 f4 fc ff ff          callq  4010c0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
364     std::cout << "Thread2 read a = " << a_val << std::endl;
365   4013cc:   89 ee                   mov    %ebp,%esi
366   4013ce:   bf c0 40 40 00          mov    $0x4040c0,%edi
367   4013d3:   e8 48 fd ff ff          callq  401120 <_ZNSolsEi@plt>
368   4013d8:   48 89 c5                mov    %rax,%rbp
411 0000000000401450 <_Z7thread1v>:
412 void thread1() {
413   401450:   41 54                   push   %r12
414     __atomic_store_n(&_M_i, __i, int(__m));
415   401452:   b8 01 00 00 00          mov    $0x1,%eax
416   401457:   55                      push   %rbp
417   401458:   48 83 ec 08             sub    $0x8,%rsp
418   40145c:   87 05 9e 2d 00 00       xchg   %eax,0x2d9e(%rip)        # 404200 <data>
419     __ostream_insert(__out, __s,
420   401462:   ba 11 00 00 00          mov    $0x11,%edx
421   401467:   be 22 20 40 00          mov    $0x402022,%esi
422   40146c:   bf c0 40 40 00          mov    $0x4040c0,%edi
423     return __atomic_load_n(&_M_i, int(__m));
424   401471:   8b 2d 8d 2d 00 00       mov    0x2d8d(%rip),%ebp        # 404204 <data+0x4>
425   401477:   e8 44 fc ff ff          callq  4010c0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
426     std::cout << "Thread1 read b = " << b_val << std::endl;
427   40147c:   89 ee                   mov    %ebp,%esi
428   40147e:   bf c0 40 40 00          mov    $0x4040c0,%edi
429   401483:   e8 98 fc ff ff          callq  401120 <_ZNSolsEi@plt>
430   401488:   48 89 c5                mov    %rax,%rbp

分析
从反汇编可以看到,std::atomic 使用 memory_order_seq_cst 时,编译器在 store 指令处生成了带 lock 前缀的原子操作(xchg指令,汇编356、418行),而在 load 指令处仍然是普通的 mov(363、424行)。
这是因为在 Intel 的 TSO(Total Store Order)内存模型 下,处理器天然保证:

  • Store → Store 不乱序
  • Load → Load 不乱序
  • Load → Store 不乱序

唯一可能乱序的是 Store → Load,即写入尚滞留在本核的 store buffer 时,后续的读取可以越过尚未刷新出去的写操作执行。这种乱序不会影响单线程一致性,但会导致多线程间可见性问题。
为防止这种 “写后读乱序”,seq_cst 模型要求全序内存语义,因此编译器通过插入 lock 前缀的原子指令(如 xchg)隐式加入了一个 full fence:

  • 它会强制刷新本核的 store buffer;
  • 并阻止后续的读操作提前执行。

这样就保证了 当前线程的所有写在全局上先于后续的所有读、写可见,实现了符合 C++ memory_order_seq_cst 要求的全序一致性。
而在读取侧,由于 TSO 模型天然禁止 Load→Load 与 Load→Store 乱序,因此 load 操作无需额外的 fence,只要保证读取时能看到其他核心已经刷新出去的写即可。
综上,x86 架构上 seq_cst 的实现核心在于:

  • 仅在 store 侧 插入全栅栏(通常通过 LOCK 指令隐式实现,也可能直接使用 mfence),以防止 Store→Load 乱序;其余的顺序约束则由 TSO 硬件自然保证。

结论

在 Intel 架构下,atomic 的 seq_cst 写操作会插入全栅栏(可能是隐式的lock 指令方式),存在一定硬件开销;而读操作与普通变量的开销十分接近,几乎没有额外的硬件负担,只是对编译器重排做了限制。

四、性能角度分析

在多线程程序中,std::atomic 提供的内存序(Memory Order)不仅决定了数据在不同线程间的可见性,也直接影响指令执行的性能与编译器优化空间。以下从性能角度分析INTEL架构下各个内存序对当前线程的影响和可见性保障程度。

本线程影响

内存序对当前线程性能影响是否对编译器重排有限制跨线程可见性保障是否引入硬件屏障
relaxed几乎无性能损耗,可完全被编译器优化否:不禁止编译器重排不保证顺序,仅保证原子性
acquire读操作轻量是:禁止后续读操作越过该 load保证看到对应 release 写之前的所有写入否(x86 TSO 保证)
release写操作轻量是:禁止前面写操作延后越过该 store保证当前线程写在被 acquire 读到前全部可见否(x86 TSO 保证)
acq_rel读写组合是:禁止前写后写 & 后读前读重排双向保证可见性否(x86 TSO 保证)
seq_cst最重,会强制全序语义是:禁止所有读写跨越全局单一顺序,最强可见性是(x86 会生成 lock 指令)

可见性影响

内存序是否立即刷新 Store Buffer对其他线程可见性对当前线程开销说明
relaxed可能延迟看到旧值极低(和普通变量差不多)仅保证当前线程顺序,不保证跨线程同步
release写可能延迟其他线程可见保证写在 release 之前的操作顺序可见,但写本身仍在 store buffer
acquire读可能读到旧值保证 acquire 之后的读不被重排,但不强制刷新 store buffer
acq_rel写和读都可能延迟可见写和读结合 release/acquire 的特性
seq_cst写立即对其他线程可见较高(插入 fence 或 lock 指令)保证全局一致顺序,硬件强制刷新 store buffer,延迟略高

说明

  • Intel TSO 保证当前线程内的写-读顺序不乱序,但跨线程可见性依赖 store buffer 刷新。
  • seq_cst 是为了在多线程间实现严格全序,确保写立即可见。
  • release/acquire 在 Intel 下,由于 TSO,本质上只需编译器屏障即可,无额外硬件操作。

五、总结

通过对 Intel 架构下 C++ 内存模型和原子操作汇编的分析,我们可以得出几个重要结论:

  1. 理解硬件机制有助优化性能 TSO 提供天然顺序保证,store buffer 决定跨线程可见性。
  2. 内存序选择影响开销与可见性 relaxed 开销小但可见性延迟,release/acquire 限制编译器重排,seq_cst 写操作引入屏障保证全局顺序。
  3. 优化关键 合理选择内存序、注意缓存行对齐和 false sharing,可显著降低延迟。

📬 欢迎关注公众号“Hankin-Liu的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货。