ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多核CPU原子操作与无锁队列:从总线锁到std::atomic底层原理

多核CPU原子操作与无锁队列:从总线锁到std::atomic底层原理 1. 从一次 “数据错乱” 开始为什么 i 在多核 CPU 上不靠谱我在并发编程这个坑里泡了快十年印象最深的一次是刚转岗做后台服务时线上有个统计接口的计数老是不对。代码特别简单就是每个请求进来count然后定期把 count 写回数据库。当时想当然地觉得这有啥问题单线程里加一百遍肯定是一百结果上线后统计值偶尔少几个多的时候能差出几十。排查了很久才意识到问题出在count本身——它压根就不是一个“瞬间完成”的操作。多核 CPU 的场景下count在底层被拆成了至少三步从内存把 count 加载到寄存器、在寄存器里加 1、再把结果写回内存。这三步中间另一个核心可能已经修改了内存里的 count等当前核心写回时那个修改就被覆盖了。这种“先读后写”的竞态条件就是并发数据错乱最常见的来源。所以这次我打算把原子操作这件事彻底掰开揉碎讲清楚。标题里问的是“多核 CPU 如何实现瞬间不可分割”我理解它背后真正的问题是当我们用std::atomic、用无锁队列时底层到底发生了什么为什么加了atomic之后多线程的就能保证不出错以及无锁到底是不是真的“无锁”这篇文章会从硬件层面的总线锁、缓存锁讲起再落到 C/C 的原子操作 API 和内存序最后用一个我实际写过的无锁环形队列做例子把从原理到代码的整条链路串起来。适合正在用 C 写多线程程序、遇到过数据竞争或者单纯想知道“原子操作凭什么能瞬间不可分割”的开发者。哪怕你刚接触并发编程只要能写基础的 C 代码这篇文章也能让你把这块硬骨头啃下去。我尽量用大白话把底层机制讲透像“缓存一致性协议”“store buffer”“内存屏障”这些词看起来吓人其实理解了它们各自扮演的角色之后你会发现原子操作的设计逻辑非常顺——它不是玄学是一套层层递进的硬件软件协同方案。2. 先看底层实现总线锁和缓存锁两条硬件路线2.1 为什么一条xchg指令就能“锁住”内存在 x86 平台上原子操作最终会落到几条特定的指令上常见的包括xchg、带lock前缀的add、inc、cmpxchg。其实硬件层面的思路非常直接要么把通往内存的“桥”暂时锁住要么在缓存层面做手脚。先说总线锁。早期 CPU 的原子操作就是这么干的——在执行带lock前缀的指令时CPU 会拉低一个叫做 LOCK# 的引脚让总线仲裁器在这条指令执行期间不允许其他核心访问内存。这相当于把通往内存的唯一桥梁临时焊死mua谁也别想同时过桥。这个方案简单粗暴也确实有效但问题也很明显一个核心做原子操作其他核心都得等着哪怕它们操作的是完全不相关的内存地址。这种全局性的“堵车”对性能影响太大了。所以后来引入了缓存锁。如果原子操作要访问的数据已经在该核心的 L1 缓存里那就不需要锁总线了只要保证这条缓存行在原子操作执行期间不被其他核心修改就行。缓存锁不是靠 LOCK# 引脚而是依赖缓存一致性协议MESI来实现核心先拿到这条缓存行的独占权Exclusive 或 Modified 状态然后执行修改操作其他核心通过嗅探snooping机制发现自己缓存里的同一条缓存行失效。整个操作结束时才把新值写回这个流程在硬件上被设计成不可中断的。用生活里的事类比总线锁就是在考试时把整个教室的隔板都拉起来谁也别想抄谁的。缓存锁则是改成只盯住你一个人别人只要不碰你那一份资料该干嘛干嘛。显然后者对并行效率友好得多。2.2 老生常谈的问题那缓存行是怎么保证“独占”的这就要聊 MESI 协议了它定义了四种状态Modified已修改、Exclusive独占、Shared共享、Invalid失效。在一颗多核 CPU 里每个核心通过总线嗅探其他核心的读写请求维护自己缓存行的状态。比如核心 A 打算在一个共享缓存行上做原子操作它得先发一个 RFORead For Ownership请求把它从 Shared/Invalid 升级成 Exclusive/Modified在这之前其他核心里的同地址缓存行必须全部变成 Invalid。缓存锁的粒度是缓存行不是单个变量。这涉及一个很重要的工程问题——伪共享False Sharing。如果两个线程要修改的变量不幸落在同一条 64 字节的缓存行里哪怕它们完全没有逻辑上的关联缓存一致性协议也会让它们互相“打架”A 改了地址 x整条缓存行失效B 再改地址 y又得重新同步。原子操作在这种场景下性能损耗会特别夸张。所以无锁队列这类高性能代码里几乎都会刻意做缓存行填充cache line padding或者干脆把每个变量对齐到 64 字节边界。注意缓存锁并非在所有场景下都可用。如果数据跨了两条缓存行比如你要原子地操作一个 128 字节的大结构体缓存锁就无能为力了只能退回总线锁。所以像std::atomicT这样的设施对 T 的大小是有要求的通常保证不会超过一个缓存行。2.3 存疑的“瞬间”原子操作不是一点代价都没有原子操作虽然不会被“打断”但它不是零成本的。如果大量核心同时对同一缓存行发起原子操作那这些操作会被缓存一致性协议强制串行化一个接一个地完成。也就是说原子性保证的是“不可分割”而不是“并行”。在极端竞争下原子操作可能比非原子操作慢好几倍。理解了这点你就知道无锁数据结构为什么通常只适合在竞争不太激烈或者操作本身很轻的场景下使用——竞争一旦激烈无锁代码的性能反而可能比加锁更差。这跟很多人第一直觉恰恰相反。3. 从指令到 C/C 语言层std::atomic背后做了什么3.1 C11 的 std::atomic 到底封装了什么在 C 语言里早期做原子操作得靠编译器扩展或者内联汇编。GCC 提供过__sync_fetch_and_add、__atomic_add_fetch这类内置函数MSVC 也有InterlockedIncrement。这些函数本质上就是把你想要的原子操作映射到前面说的lock xadd这类指令上。到了 C11标准库直接引入了std::atomicT模板把原子类型作为语言标准的一部分固定下来好处是跨平台、跨编译器行为一致可读性也高出不少。从使用者的视角看std::atomicint用法其实跟普通int差别不大load()读值store()写值fetch_add()做原子加并返回旧值exchange()做原子交换。但编译器在背后做了几件事对象放在对齐好的内存地址上访问路径被替换成原子指令禁止对它做任何可能破坏原子性的优化。这里面最容易踩的坑是把std::atomic变量错误地按普通变量去读std::atomicint counter{0}; void increment_bad() { counter counter 1; // 错误读出旧值、加一、再赋值相当于普通读写 } void increment_ok() { counter.fetch_add(1); // 正确单一原子指令完成 }第二种写法才是真正的原子自增。第一种写法里counter 1的读取和后续的赋值是分开的编译器可能把它优化成一次mov 一次mov中间完全可能被其他线程插入修改。3.2 一个反直觉的优化问题编译器可能会偷偷“帮倒忙”除了指令层面的问题还有一个很容易被忽略的环节编译器优化。写多线程代码时有一种常见场景是线程 A 在循环里等待一个标志位变化bool flag false; // 线程 A while (!flag) { // 忙等 } // 线程 B flag true;如果flag是普通bool编译器在 O2 优化下可能把while (!flag)直接优化成“加载一次 flag然后死循环”因为它不知道flag会被另一个线程修改。这是 C 内存模型的合法优化因为单线程视角下flag在自己的循环体里确实没被改过。所以正确写法是把flag声明为std::atomicbool这样编译器就明白它可能被外部修改每次循环都会重新加载。这个例子完美说明了语言关键字和硬件的配合关系volatile并不能解决这个问题。volatile只是告诉编译器“不要对这个变量的访问做优化”但它既不保证原子性也不保证内存序。我在很多老代码里见过volatile int当并发标志用的能跑通纯属运气好千万别学。实战心得在 C 里做并发编程只要变量被两个及以上线程共享且其中一个会写入就老老实实用std::atomic或者加锁。volatile只适合跟 MMIO内存映射 I/O打交道不适合做线程同步。3.3 你以为的“原子操作”其实分很多种内存序是什么意思真正把很多 C 开发者绕晕的是从 C11 开始引入的 memory_order 参数。这个参数的可选值包括memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。默认参数是memory_order_seq_cst也就是说如果你不指定所有原子操作都会按最严格的顺序一致性来执行。要理解这些参数得先理解一个事实现代 CPU 在执行指令时并不一定按照程序顺序进行。CPU 内部有乱序执行引擎编译器的指令调度也可能调整顺序。这些优化在单线程里没有影响但在多线程里就会导致“我代码里明明先写了 A 再写了 B另一个线程看到的却是 B 先发生、A 后发生”。我在网上见过一种特别经典的错误示范用一个生产者线程写数据再用一个消费者线程读然后靠一个std::atomicbool通知“数据写好了”。代码写了data value; ready.store(true);如果 memory_order 设置不当消费者在ready变成 true 之后读取data可能会读到一个旧值。因为在 CPU 的 store buffer 里data value的写入可能还没刷到 L1 缓存而ready.store(true)的写入先被消费方看到了。解决办法是给ready的写操作加上memory_order_release给读操作加上memory_order_acquire。release 和 acquire 这对组合保证“release 之前的所有写入在 acquire 之后的读取里都是可见的”。这两个词翻译成大白话就是“我发布release一个状态你要拿到acquire这个状态之后才准读我发布前写的数据。”如果不 care 顺序只想保证“这个变量本身不被撕裂”那就可以用memory_order_relaxed它只保证原子性不保证与其他变量的顺序关系。最典型的用法是计数器——你只关心数值对不对不关心它在整个程序事件里排在哪个位置。用 relaxed 能让 CPU 和编译器少做很多约束性能通常更好。4. 多核缓存一致性原子操作如何跨核心“看见”变化4.1 每一颗核心都有自己的“小账本”缓存层级先放一个直观图景。一颗现代 x86 CPU 有多个物理核心每个核心有自己独立的 L1 和 L2 缓存然后所有核心共享一个 L3 缓存。缓存的基本单位是缓存行cache line通常是 64 字节。当线程 A 在核心 0 上读取变量x它会先把x所在的内存从主存搬到核心 0 的 L1 缓存里之后读写都在缓存里做。如果线程 B 在核心 1 上也读了x它会在自己的 L1 缓存里再留一份。此时两个核心的 L1 里各有一份x大家都以为自己手里的是最新值。问题就出现在这里核心 0 改了它的x核心 1 不知道它手里的x还是旧值。如果核心 1 也在这个基础上改了x最后谁写回主存谁就“赢”了另一个人的修改就丢了。为了避免这种情况CPU 用缓存一致性协议保证当一份共享数据被某个核心修改后其他核心手里那份必须立即使失效。这个失效动作不是即时的它需要时间。在 x86 上核心 0 执行lock xadd时会先把写请求放进自己的 store buffer然后向总线广播“我要独占这条缓存行”。等所有其他核心都确认失效了写才真正完成。这个过程虽然很短但如果在几百纳秒里另一个核心也发了同样的请求总线仲裁器会分配顺序保证只有一个胜出另一个等前面那个完成后重新执行。4.2 可见性与顺序性不是一回事别混淆这是并发编程里最容易混淆的两个概念可见性visibility和顺序性ordering。可见性指的是“另一个核心能不能看到我这次写入”顺序性指的是“它看到的多个写入之间的相对顺序对不对”。一个简单的例子数据 value 1; 标志 flag true;线程 A 执行value 2; // 普通写 flag true; // 原子写release线程 B 执行while (!flag.load(acquire)); // 先看到 flag true assert(value 2);如果这段代码不加 acquire/releaseB 完全可能看到flag true却读到value 1。因为 CPU 可能先把flag true的写提交了value 2还在 store buffer 里没刷出去。如果你在flag上用了release那就等于告诉硬件“在本次 release 之前的所有普通写都必须对本次 release 之后 acquire 到该标志的观察者可见。” 硬件会插入必要的内存屏障指令把 store buffer 里的脏数据先刷到缓存。这就是我在文章开头强调的那个“为什么”的核心原子性解决的是“撕裂写torn write”的问题顺序性解决的是“约束 CPU 和编译器乱序”的问题。二者是相对独立的维度。std::atomic默认把两个都保证了但你也因此付出了性能代价。4.3 内存屏障一条让 CPU“老实排队”的指令在 x86 上内存屏障指令主要有mfence、lfence、sfence。C 的 memory_order 参数最终会被编译器映射成这些指令。比如memory_order_seq_cst的 store 在 x86 上通常不需要额外指令因为 x86 是 TSOTotal Store Order模型store 不会被重排但 load 之后为了阻止后续 load 提前越过去可能需要lfence或直接全屏障。ARM 和 PowerPC 的内存模型更弱同样的 memory_order 可能需要更多的屏障指令。所以同样的 C 原子代码在 x86 上跑得飞快在 ARM 上却慢得多。这也是为什么移动端开发写无锁代码时特别痛苦——你不仅要考虑逻辑还要考虑平台内存模型的强弱差异。不过这里有个实用建议在绝大多数业务代码里老老实实用默认的seq_cst就够用了不用特意去抠relaxed、acquire/release的微优化。只有当你确实用 profiler 测出原子操作是瓶颈并且对平台内存模型有足够理解时再去优化内存序。为了那几百纳秒的性能把代码弄得没人能维护未必划算。5. 无锁队列实战从理论到一行行代码5.1 为什么需要无锁队列它“锁”在了哪里无锁队列最常见的应用场景是生产者-消费者模型。传统做法是给队列加一个互斥锁每次入队出队都 lock/unlock 一次。锁的开销不只是系统调用或用户态切换它更关键的问题是锁的竞争会把线程阻塞操作系统需要上下文切换保存寄存器、切换内核线程、恢复用户态一次切换轻松几微秒。在高吞吐的日志系统、网络收发层、音视频渲染管线里这种开销往往难以接受。无锁队列的思路是用原子操作直接操作队列头尾指针让入队、出队的过程不需要加锁也就没有阻塞和上下文切换。但这不代表无锁队列没有“锁”——它锁的是缓存行通过缓存一致性实现这个锁由硬件隐式处理不用操作系统参与。正因为它不走内核所以延迟低得多但设计难度也高得多。我写过的最顺手的无锁队列是环形缓冲区ring buffer版本。它比链表版本简单很多不需要 ABA 问题的处理也避免了动态内存分配。5.2 一个可运行的 SPSC 无锁环形队列下面这个版本是单生产者单消费者SPSC模型它利用环形缓冲区天然只有一个写端和一个读端这一特性把原子变量的个数压到最少template typename T, size_t N class SPSCRingBuffer { static_assert((N (N - 1)) 0, N must be power of 2); alignas(64) std::atomicsize_t head_{0}; alignas(64) std::atomicsize_t tail_{0}; T buffer_[N]; public: // 生产者调用向队列尾部写入一个元素 bool push(const T value) { size_t tail tail_.load(std::memory_order_relaxed); size_t next (tail 1) (N - 1); // 位运算取模 // 队列满的条件tail 再走一步就追上 head if (next head_.load(std::memory_order_acquire)) { return false; // 队列已满 } // 注意这里是关键。先写数据再更新 tail buffer_[tail] value; tail_.store(next, std::memory_order_release); return true; } // 消费者调用从队列头部取出一个元素 bool pop(T value) { size_t head head_.load(std::memory_order_relaxed); if (head tail_.load(std::memory_order_acquire)) { return false; // 队列为空 } // 先读数据再更新 head value buffer_[head]; head_.store((head 1) (N - 1), std::memory_order_release); return true; } };这几个细节我逐个解释一下。alignas(64)是为了隔离 head 和 tail避免它们落在同一条缓存行里防伪共享。N必须是 2 的幂这样才能用位运算 (N - 1)代替% N。push 里先relaxed加载 tail因为本线程就是唯一的写者不需要看其他线程的 modified 顺序再用acquire加载 head是保证我能看到生产者最新发布的数据。写buffer_[tail]是普通写因为它通过 release 标志的释放语义被消费者在 acquire 之后看到。tail 的 store 用 releaseconsumer 那边就能安全地读到新数据。这个代码是我调过很多版之后的版本看起来简洁但每一行都有讲究。比如如果我贪图方便把 push 里的head_.load也写成 relaxed那在多核环境里生产者可能读到一个过期很久的 head以为是满的明明有空间却报满。生产者和消费者之间依赖 release/acquire 建立 happens-before 关系这个关系在数据竞争的正确性判断中是决定性的。5.3 扩展到 MPSC / MPMCABA 问题与重试机制上面的单生产者版本不需要 CAS 循环。但多生产者多消费者版本情况就要复杂得多。多个生产者同时要写 tail 时必须用compare_exchange_strong来抢 tail 的更新权std::atomicsize_t tail_{0}; bool try_push(const T value) { size_t old_tail tail_.load(std::memory_order_relaxed); size_t next (old_tail 1) (N - 1); // 全新的 tail 等于 next if (next head_.load(std::memory_order_acquire)) { return false; } // CAS只有当前 tail 还是 old_tail才把它改成 next if (!tail_.compare_exchange_strong(old_tail, next, std::memory_order_release, std::memory_order_relaxed)) { return false; // 另一个生产者抢先了本次 cas 失败 } buffer_[old_tail] value; return true; }CAS 失败后要么重试继续用最新 tail 重新计算要么直接返回失败。无锁队列的设计哲学是“不阻塞但允许失败”——调用者自己决定是重试还是放弃。链表实现里会遇到 ABA 问题即 CAS 比较的值被其他线程改了一圈又改回来导致 CAS 误判成功。环形缓冲区因为没有节点删除再插入的问题ABA 天然免疫这是它的一大优势。避坑要点无锁队列里的 buffer 写入顺序和执行 CAS 的顺序必须想清楚。上面的代码是先 CAS 再写 buffer这会导致一个问题缓冲区里的数据还没写tail 已经更新了消费者可能读到未初始化的数据。所以真正成熟的版本一定是把 buffer 数组做成std::atomicT数组每个槽位配一个序列号这样才能保证“先写数据、再更新 tail”而不会出现消费者提前读数据的情况。SPSC 版本之所以能先写再更新是因为只有一个生产者不存在“另一个生产者抢到 CAS 但不写数据”的中间态。5.4 真实的性能表现与适用场景判断我用 SPSC 环形队列做过一个日志落盘场景的测试单线程生产者写、单线程消费者刷盘队列深度 65536每项 128 字节。对比加锁的std::queue无锁版本吞吐提升大约 1.8 倍到 3 倍。延迟方面p99 从微秒级降到百纳秒级抖动明显减少。但无锁也不是万能药。如果生产者的生产速率超过消费者的消费速率队列迟早填满push 频繁失败所有线程都在忙着重试CPU 空转性能反而不如阻塞式队列加条件变量。所以无锁队列真正适合的是生产者偶尔快、消费者需要稳定低延迟、两者速率比较匹配、且整体队列深度预留足够的场景。6. 常见掉坑场景与排查速查表6.1 DCLP 双检锁的那点破事双重检查锁定Double-Checked Locking Pattern曾经是单例模式里的经典问题。不熟悉这个模式的读者假装它不存在但很多老代码里确实有。大体写法是这样的static Singleton* instance nullptr; static std::mutex mutex; Singleton* getInstance() { if (!instance) { // 第一次检查 std::lock_guard lock(mutex); if (!instance) { // 第二次检查 instance new Singleton(); // 问题集中在这一行 } } return instance; }问题在于new Singleton()包含三步分配内存、构造对象、把地址赋给instance。编译器或 CPU 可能把后面两步重排让外部线程看到一个非空的instance指针然后访问到尚未构造完成的对象。C11 之前的规范里这种写法是未定义行为C11 之后正确做法是static std::atomicSingleton* instance{nullptr}; static std::mutex mutex; Singleton* getInstance() { Singleton* p instance.load(std::memory_order_acquire); if (!p) { std::lock_guard lock(mutex); p instance.load(std::memory_order_relaxed); if (!p) { p new Singleton(); instance.store(p, std::memory_order_release); } } return p; }简单说用 atomic 的 acquire/release 替代普通指针读写保证“对象构造完成”这件事对后续观察者可见。6.2 警惕“看似原子”的复合操作我见过有人这么写计数器std::atomicint count{0}; void add(int n) { int cur count.load(); count.store(cur n); }这也是错的。load和store是分开的原子操作它们之间完全可能插入另一个线程的修改导致计数丢失。正确写法要么是count.fetch_add(n)要么自己用 CAS 循环void add(int n) { int cur count.load(); while (!count.compare_exchange_weak(cur, cur n)) { // cur 被更新为最新值继续尝试 } }compare_exchange_weak在 x86 上通常映射成一条lock cmpxchg指令失败时自动返回最新 cur。这类复合操作在实现无锁数据结构时到处都是是最基础设施值得反复练到熟悉。6.3 速查表常见并发问题对照问题现象表面原因根因方向排查顺序计数偶尔偏少count 非原子读写分离/竞态检查是否用了 fetch_add死循环不退出普通 bool 标志位被优化编译器不认为它能变改成 atomic 并配合 acquire/release数据读到旧值事件标志可见但数据不可见store buffer 乱序检查标志位 memory_order缓存行打架导致性能暴跌多变量挤在同一缓存行伪共享用 alignas(64) 分隔单件构造后外部线程访问空指针new 构造顺序重排写序未发布单例改用 atomic store(release)排查的时候建议先用 ThreadSanitizer 或者 AddressSanitizer 跑一遍它们能很快定位到更具体的数据竞争位置。像 TSan 会把“两个线程访问同一地址并发读写”的调用栈打出来省掉很多猜测的时间。但在多核高性能场景下TSan 的大开销会掩盖真实的性能问题所以排查正确性用它测性能还是得靠真实的 benchmark。7. 最后一点经验谈原子操作这个东西看起来是 CPU 硬件的事写代码时只是一行fetch_add的事但真正理解了它底层的锁机制、缓存一致性和内存屏障之后你对并发编程的整个认知会上一个台阶。你不再需要靠“试试看”来验证对不对而是能从指令和缓存层面预判一个并发方案是否安全。我个人建议如果刚开始接触并发编程从std::atomic默认的seq_cst写起搞清楚原子变量 vs 锁的取舍再把无锁队列从 SPSC 练到 MPMC。过程中一定要自己用 TSAN 跑测试不要只凭肉眼推断正确性——并发 bug 是最阴险的一类问题它可能几十次运行都不出现一上线就崩。另外补充两个小技巧做原子操作性能测试时尽量把测试数据对齐到缓存行上线上排查并发问题时可以把进程绑定到特定核心跑配合perf看缓存未命中率往往能瞬间定位到伪共享。这两个方法帮我省了不少事建议你也试试。
返回列表