无锁
结论
不完全是。std::atomic 是语言层面的抽象,底层怎么实现取决于类型大小、CPU 架构和具体操作。以现代 x86-64 为例:
- 普通的 load 和 store 什么都不锁,就是一条普通的
mov。 - 读-改-写操作(
fetch_add、exchange、compare_exchange)用的是带lock前缀的指令。在现代 CPU 上,lock锁的是缓存行,不是总线。 - 总线锁只在两种异常情况下出现:操作数跨了缓存行,或者内存不可缓存。正常使用
std::atomic时不会遇到。 - 放不进一条指令的大类型用的是真正的软件锁。
x86-64 上各操作生成什么指令
| 操作 | 指令 | 锁了什么 |
|---|---|---|
load(任意内存序) |
mov |
不锁。x86 保证对齐的读写本身就是原子的 |
store(relaxed / release) |
mov |
不锁 |
store(seq_cst,默认) |
xchg,或 mov + mfence |
xchg 隐含 lock;mfence 是全屏障 |
fetch_add / ++ |
lock xadd(结果没被用到时是 lock add) |
锁缓存行 |
exchange |
xchg |
锁缓存行 |
compare_exchange |
lock cmpxchg |
锁缓存行 |
seq_cst 的 store 用哪种写法因编译器而异,据我所知 GCC 用 mov + mfence,Clang 用 xchg。这张表是凭记忆写的,未实测。
在 x86 上,acquire 和 release 语义不需要额外指令,因为 x86 的内存模型(TSO)本身已经保证了这些顺序,编译器只需要不重排。
lock 前缀:从锁总线演变为锁缓存行
-
早期(486 及以前):
lock前缀让 CPU 拉低总线上的 LOCK# 信号,整条总线被锁住,其他所有核都无法访问内存。 -
P6(Pentium Pro)起:如果操作数在可缓存的内存里,并且没有跨缓存行,CPU 不再锁总线,改用缓存锁。过程分两步:
- 先通过 MESI 缓存一致性协议,把这条缓存行拿到本核,状态为独占(E)或已修改(M)。
- 在完成读-改-写的这几个周期里,不响应其他核对这条缓存行的请求。
- 其他核只是访问这一行时要等待,访问别的内存完全不受影响。
这一点见 Intel SDM 卷 3A 中"LOCK 操作对处理器内部缓存的影响"一节。
-
仍会锁总线的情况:
- split lock:操作数跨了两条缓存行。代价极高,会拖慢整台机器。Linux 5.7 起有 split lock 检测(启动参数
split_lock_detect),可以打警告,也可以直接给进程发SIGBUS。 - 不可缓存(UC)内存上的 locked 指令。
lock-free 的
std::atomic<T>会把自己按sizeof(T)对齐,不会跨缓存行,所以正常使用不会触发 split lock。 - split lock:操作数跨了两条缓存行。代价极高,会拖慢整台机器。Linux 5.7 起有 split lock 检测(启动参数
代价在哪里
lock 指令的开销主要来自两方面:
- 它同时是一道全内存屏障。 执行前必须把 store buffer 清空,即使没有竞争,也要十几到几十个周期。
- 有竞争时,缓存行在核之间来回传递(cache line bouncing)。每次操作都要把这条缓存行从别的核抢过来。
process-thread.md 5.5 节在 dev 虚拟机上的实测数据:单线程原子加 1.6 ns,两个线程争用同一个变量时 5.5 ns 一次。伪共享的代价也出在这里:两个线程改的是不同的变量,但变量落在同一条缓存行上,同样会把这条行来回抢。
其他情况
- ARM(AArch64):没有"锁总线"这个概念。
- ARMv8.0 用 LL/SC:
ldxr加载时由独占监视器标记这个地址,stxr写回时如果标记已被别的核打破就失败,外面套一个循环重试。 - ARMv8.1 增加了 LSE 原子指令(
ldadd、cas、swp),可以在互连或共享缓存那一级直接完成运算,高争用时比 LL/SC 快得多。 - GCC 10 起在 AArch64 Linux 上默认开启
-moutline-atomics,运行时检测 CPU,自动选用 LSE 或 LL/SC。
- ARMv8.0 用 LL/SC:
- 不是 lock-free 的类型:比如
std::atomic<一个 64 字节的结构体>,is_lock_free()返回false。GCC 会把这类操作转交给 libatomic,libatomic 按地址哈希到一张锁表上,用的是真正的锁。 - 16 字节类型:x86-64 有
cmpxchg16b指令,但 GCC 仍把 16 字节原子操作交给 libatomic,而且is_lock_free()返回false。原因之一是早年用cmpxchg16b来实现 load,遇到只读内存会出错。 std::atomic_flag:标准唯一保证一定 lock-free 的类型。
面试时的一句话回答
x86 上 std::atomic 的普通读写不加锁;读-改-写操作用 lock 前缀指令,现代 CPU 借助 MESI 协议锁住的是单条缓存行,不是总线。总线锁只在跨缓存行或内存不可缓存时才出现。原子操作的真正代价是它自带的全屏障和争用时缓存行在核间的来回传递。
这些内容都来自手册和源码知识,没有实测。如果需要,我可以在 dev 上用 g++ -O2 -S 看各操作实际生成的指令,确认 GCC 15 对 seq_cst store 用的是 mov+mfence 还是 xchg,以及 is_lock_free() 对 16 字节类型的返回值。
暂无评论,欢迎留下第一条评论。