一、引言
在多线程程序中,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节的描述:
关注公众号“Hankin-Liu的技术研究室”,回复“intel manual"可获得此INTEL官方手册下载链接。
其核心特征可以概括如下:
- 读不会乱序:
- 写不会越过更早的读:
- 写与写之间按顺序执行(除非是特殊的 streaming store 或字符串操作):
- 读可能越过旧的写(不同地址):即允许「Read after Write」乱序,但同地址不行:
- 跨核可见性保证:同一 CPU 核发出的写,在所有其他核上可见的顺序一致:
- 锁指令形成全局顺序(total order):
这意味着:Intel 架构在多核环境下天然具备较强的内存可见性顺序。
🧩 TSO 模型的关键:Store Buffer + Load Forwarding
Intel 的强一致性来自两个机制的权衡:
-
Store Buffer 写操作先进入缓冲区,异步刷入缓存。这样写线程不会被内存访问阻塞。
-
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))分析:
- 关注写线程汇编的398行,atomic store的release语义在intel架构下就是普通的mov指令。没有 mfence、sfence、lock 前缀等同步指令;
- 执行顺序是:
(1)先写共享数据;
(2)再写 ready 标志。 - 在 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))分析:
- 读线程汇编的422行,movzbl 0x40(%rax), %eax 是普通的 load;
- 没有任何 fence 或 lock;
- 语义上的“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++ 内存模型和原子操作汇编的分析,我们可以得出几个重要结论:
- 理解硬件机制有助优化性能 TSO 提供天然顺序保证,store buffer 决定跨线程可见性。
- 内存序选择影响开销与可见性 relaxed 开销小但可见性延迟,release/acquire 限制编译器重排,seq_cst 写操作引入屏障保证全局顺序。
- 优化关键 合理选择内存序、注意缓存行对齐和 false sharing,可显著降低延迟。
📬 欢迎关注公众号“Hankin-Liu的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货。