一、引言
在多線程程序中,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的技術研究室”,收徒傳道。持續分享信創、軟件性能測試、調優、編程技巧、軟件調試技巧相關內容,輸出有價值、有沉澱的技術乾貨。