C++多线程本质归结为三要素:原子性、重排控制与资源等待策略。线程切换依赖内核上下文保存与恢复,真正并行需多核支持。x86的TSO模型限制重排,但非天然顺序一致;std::atomic通过lock前缀或特殊指令实现原子操作,acquire/release在x86上免费,seq_cst则需全屏障。CAS循环基于lockcmpxchg实现无锁逻辑,自旋锁适用于极短临界区,互斥锁则通过futex休眠避免空转。x86掩盖内存序错误,ARM暴露问题,伪共享导致缓存抖动。最终,高级抽象依赖汇编原子指令、内存模型与操作系统调度协同工作,理解底层才能写出跨架构可靠的并发代码。
C++ 多线程的 std::mutex、std::atomic、CAS 和内存序,最终都归结为三件事:CPU 指令是否原子、编译器和 CPU 是否重排、抢不到资源时是忙等还是休眠。不谈汇编和内存模型,多线程就只是"好像能跑"。
一、线程切换在汇编层是什么
C++ 的 std::thread最终调用 pthread 或 Windows API,再进入内核调度。用户态看到的是 call foo,但线程切换时 CPU 实际执行的是:保存当前线程的 RIP、RSP、寄存器到内核栈或 TCB,加载另一个线程的上下文,回到用户态继续执行。线程不是"同时跑",而是多个执行流交替使用同一个核。真正的并行只在多核且多线程时才发生。
二、x86 的内存模型:TSO
x86-64 采用 TSO(Total Store Order),仅允许一种重排:Store 可以重排到 Load 之前。Load 与 Load、Load 与 Store、Store 与 Store 之间不允许重排。原因在于每个核都有 store buffer。当一个核执行 mov [x], 1后紧接 mov rax, [y],可能读到 y 的旧值,因为对 x 的写入还停留在 store buffer 中,尚未刷新到缓存。因此 x86 并非"天然顺序一致",只是比 ARM 约束更强。
三、std::atomic在 x86 上生成的指令
3.1 relaxed 计数
cnt.fetch_add(1, std::memory_order_relaxed)在 x86 上生成 lock xadd dword ptr [rcx], eax。lock前缀保证读旧值、加 1、写回这三步对其他核不可拆分。现代 CPU 并非锁总线,而是锁缓存行,在 MESI 协议下获取该缓存行的独占权后完成原子读改写。
3.2 acquire 与 release 在 x86 上几乎免费
data = 42; ready.store(true, std::memory_order_release)在 x86 上仅生成两条 mov指令,没有 mfence,也没有 lock。因为 x86 的 store 本身就具备 release 语义,load 本身就具备 acquire 语义,编译器只需保证不将前面的写重排到 store 之后即可。ARM 则不同,需要使用 stlr指令才能实现 release 语义。
3.3 seq_cst 才真正有代价
ready.store(true, std::memory_order_seq_cst)在 x86 上可能生成 xchg [ready], al(隐式 lock)或 mov加 mfence。mfence的作用是将 store buffer 中的内容刷出,保证后续 load 不会提前看到旧值。顺序一致性保证所有线程对操作顺序有一致的全局视图,代价是全屏障。
四、CAS 循环在汇编中的形态
无锁结构的核心是 compare_exchange_weak,在 x86 上对应 lock cmpxchgq。该指令在比较相等时写入新值,否则将内存当前值加载到 RAX 中返回。失败时循环重试,直到成功为止。ARM 采用不同的策略,使用 ldxr进行独占加载,再用 stxr尝试独占存储,失败则重新加载并重试。x86 是一条指令完成原子比较交换,ARM 则是加载、尝试存储、失败重试的模式。
五、std::mutex的快路径与慢路径
std::mutex在 Linux 上通过 pthread 实现。无竞争时,快路径仅使用 lock cmpxchg将互斥量从 0 改为 1,整个过程零系统调用。一旦有竞争,线程调用 futex系统调用进入内核,被挂起到等待队列并让出 CPU。解锁时先将值写回 0,再调用 futex唤醒等待者。只有确实存在等待线程时才会进入内核,否则完全在用户态完成。
六、自旋锁与互斥锁的区别
自旋锁使用 xchg原子交换指令抢锁,抢不到就在用户态循环并插入 pause指令让出流水线。它不休眠,适合临界区极短的场景,多核下等待几纳秒比上下文切换更划算。互斥锁抢不到则通过 futex休眠,上下文切换需要几千到几万周期,但 CPU 不会空转。两者的选择取决于临界区长度和等待时间的预期。
七、为什么 x86 上测试通过,ARM 上却崩溃
常见错误是在 store 时遗漏 memory_order_release,在 load 时遗漏 memory_order_acquire。在 x86 上,由于 TSO 模型的约束较强,store buffer 的延迟刷新在多数测试中碰巧不会触发问题。但在 ARM 的弱内存模型下,对数据的写入可能仍停留在写队列中,而消费者已经读到标志位为真,从而读到未初始化的数据,触发断言失败。x86 掩盖了 bug,ARM 将其暴露为线上事故。
八、伪共享
当两个 std::atomic变量位于同一缓存行时,不同核心分别写入这两个变量会导致缓存行反复失效,即便它们在逻辑上互不影响。汇编层面看不出任何问题,但性能会显著下降。解决方式是对齐到 64 字节边界,或使用 perf c2c工具检测 HITM 事件来定位。
九、底层映射
| C++ 抽象 | x86 指令 | 是否进内核 | 说明 |
|---|---|---|---|
| atomic load | mov | 否 | acquire 通常也是 mov |
| atomic store | mov | 否 | 编译器屏障即可 |
| fetch_add | lock xadd | 否 | 原子读改写 |
| compare_exchange | lock cmpxchg | 否 | 无锁核心 |
| seq_cst store | xchg / mfence | 否 | 全屏障 |
| mutex 无竞争 | lock cmpxchg | 否 | 快路径 |
| mutex 有竞争 | futex syscall | 是 | 休眠等待 |
| 线程切换 | 内核调度 | 是 | 上下文切换 |
十、结论
C++ 多线程的真相是:高级语言负责语义,汇编负责原子性,CPU 内存模型负责可见性和重排,操作系统负责调度和休眠。原子变量通过 lock、xchg、cmpxchg解决读改写不被打断的问题;内存序决定写入何时对其他核可见;互斥量在用户态用 CAS 抢锁,抢不到就通过 futex 休眠;无锁编程依赖 CAS 循环、内存序和内存回收策略。x86 的 TSO 模型容易掩盖错误,ARM 等弱内存模型架构则会将这些错误暴露为实际故障。