多线程counter++操作的原子性与内存可见性疑问解答
多线程计数器递增的原子性与内存架构疑问
示例代码
#include <thread> #include <iostream> #include <chrono> #ifdef ATOMIC #include <atomic> std::atomic<int> #else int #endif counter(0); void thread() { for(;;) { #ifdef ATOMIC counter.fetch_add(1, std::memory_order_relaxed); #else counter++; #endif std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } int main() { std::jthread t1{thread}; std::jthread t2{thread}; while (true) { std::cout << counter #ifdef ATOMIC .load(std::memory_order_relaxed) #endif << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(1000)); } }
背景与疑问
通常认为counter++包含「取当前值、递增、存回」三步,在多线程环境中会因原子性问题不符合预期。但观察编译后的汇编代码,counter++被译为单条指令add DWORD PTR counter[rip], 1,由此产生以下技术疑问:
- 为何单条汇编指令的
counter++在多线程环境中仍不被视为原子操作? - 内存可见性与缓存如何影响该操作?若计数器不在缓存中,是否会导致显著等待或指令被中断重试?
问题解答
1. 单条汇编指令的counter++为何不被视为原子操作?
- 硬件原子性≠语言标准原子性:x86架构下,对齐的32位内存操作的
add指令确实是硬件原子的,但这只是CPU的硬件特性,C标准并不承认普通int的++操作具有原子性。C标准规定,普通非原子变量的并发读写属于未定义行为,编译器完全可以无视多线程场景对代码做优化。 - 编译器优化的不确定性:当开启编译优化(如
-O2)时,编译器可能会将普通变量counter缓存到寄存器中,比如在循环里多次递增寄存器中的值,最后再一次性写回内存。此时实际执行的指令就不再是单条add,原子性自然无法保证。 - 依赖硬件特性不可移植:即使在x86上能侥幸运行,换到其他架构(如ARM),单条内存
add指令可能本身就不是原子的,程序会直接出现数据竞争问题。
2. 内存可见性与缓存的影响
- 内存可见性问题:普通变量的读写没有内存屏障,线程对
counter的修改可能仅停留在本地CPU缓存中,其他线程无法及时看到最新值,导致读取到过期数据。而std::atomic会通过指定的内存序(如memory_order_relaxed)保证修改的可见性,确保其他线程能看到最新的计数器值。 - 缓存的影响:如果计数器不在当前CPU的缓存行中,CPU会通过MESI协议发起缓存同步请求,这个过程会有一定延迟,但不会导致指令中断重试。现代CPU会自动通过缓存锁定或总线锁定来保证原子操作的完成:比如x86的内存
add指令在执行时,会锁定对应的缓存行,确保整个操作从读取到写回的过程不会被其他CPU打断。如果是未对齐的操作或超过CPU原生宽度的操作,可能会触发总线锁定,延迟会更高,但依然能保证原子性。不过对于普通变量,即使硬件处理了缓存同步,编译器的优化依然可能破坏可见性,导致线程间的数据不一致。
内容的提问来源于stack exchange,提问作者nowox
相关产品推荐
相关产品推荐

