尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++11并发编程与内存序:从std::atomic到无锁队列

C++11并发编程与内存序:从std::atomic到无锁队列 1. 为什么第二篇笔记要死磕并发和内存序上一篇 C11 笔记我主要整理了移动语义、完美转发、lambda 这些偏语法糖层面的东西老实说那部分虽然绕但每天写代码都能摸到。这篇笔记我想换个方向把 C11 里真正改变“多线程编程世界观”的那一块整理出来std::thread、std::atomic以及绕不开的内存序。启动这个主题的直接原因是最近在社区里看到一个热门讨论有人问C11 的内存序是不是专门为原子操作准备的这个问题乍一看好像是送分题因为 API 层面上 memory_order 确实只出现在 std::atomic 相关函数的参数里。但如果你真的写过几个无锁数据结构或者被线上偶发的诡异并发 bug 折磨过就会知道事情不是这么简单。内存序本质上描述的是“内存操作的可见性与顺序约束”它管的不光是原子变量本身还包括原子操作周围那些普通变量的读写。这篇文章适合谁看我自己定位是写给那种已经开始用 C11 写多线程程序但对原子操作、内存序停留在“背过六种枚举值”阶段的同学。看完你至少能搞明白三件事std::atomic 到底帮你扛下了什么为什么把一个变量声明成 atomic 还不够以及什么时候该用 relaxed、什么时候该用 release/acquire。我会从最基础的线程创建讲起一路推到手动实现一个单生产者单消费者无锁队列中间把那些坑和原理揉碎了说清楚。2. 先把并发编程的底座补起来线程、锁与异步2.1 std::thread 不是简单地“开一个函数”C11 之前想在 C 里开线程你得用 pthread 或者 Windows API平台相关不说生命周期管理全靠自觉。有了 std::thread 之后至少语法层面统一了。#include thread #include iostream void worker(int id) { // 模拟干点活 std::cout thread id started\n; } int main() { std::thread t1(worker, 1); std::thread t2(worker, 2); // 关键必须 join 或 detach否则析构会直接 terminate t1.join(); t2.join(); }这段代码里有几个点值得新同学注意。第一线程对象的生命周期和线程执行体是两回事std::thread 对象创建后线程可能已经跑起来了但如果你忘了 join 或 detach对象析构时程序会直接崩溃而不是“等一等”或者“自动清理”。这个设计是 C 标准委员会故意的目的是强迫你明确线程的归属策略避免程序快退出了线程还在后台用已经被销毁的变量。第二std::thread 的参数是按值拷贝的如果你想传引用必须用 std::ref 包一层。我见过不少人在这里踩坑传了个引用进去以为在线程里修改的是外面的变量结果编译器报错或者改了份拷贝。这是 C 类型安全的表现也是新手最容易懵的地方。void change(int x) { x 10; } int main() { int a 1; // 直接传 a 会编译失败因为无法将 int 绑定到 int 的临时对象上 std::thread t(change, std::ref(a)); t.join(); // 此时 a 11 }第三线程函数的入口建议用 lambda 包裹一下这样捕获成员函数、智能指针都更方便不容易出现裸指针悬空的问题。成员函数线程最容易犯的错是捕获了 this但对象先于线程结束而析构线程访问到的是已释放内存。用 shared_ptr 管理对象生命周期再配合 weak_ptr 或者原子标志位做退出通知是比较稳妥的工程做法。2.2 mutex 加锁的真相保护的不是代码是数据很多人把锁理解为“让一段代码同一时间只能一个线程进入”这个说法没有错但容易引导新手写出大括号一包、里面干一堆无关紧要事情的代码。锁真正保护的其实是共享数据的“不变量”。比如一个计数器必须“读取-加一-写回”整个过程原子完成你才能说这个计数器是线程安全的。#include mutex std::mutex mtx; int counter 0; void increment() { std::lock_guardstd::mutex guard(mtx); counter; }这里 lock_guard 是 RAII 的典型构造时 lock析构时 unlock。哪怕 counter 中间抛了异常锁也会被正确释放不会死锁。C11 还提供了 std::unique_lock它比 lock_guard 更灵活支持手动 unlock/relock配合条件变量时基本都得用 unique_lock。曾经在项目里见过一种写法把锁放在函数开头然后整个函数体都是锁的范围里面有文件 IO、网络请求动辄几十毫秒。线程一多锁竞争激烈性能直接崩。锁的粒度控制其实是一个权衡锁太粗浪费并行度锁太细又容易引入 bug。我个人的经验是先保证正确性用粗锁把并发问题解决掉再用 profiler 看热点确定是锁竞争之后再精细化。别一上来就搞无锁或者读写锁复杂度会让你怀疑人生。2.3 条件变量生产者消费者模式的基石互斥锁解决的是“同一时间只有一个线程访问共享数据”但线程之间经常需要“你干完了告诉我我再继续”的协作关系。比如一个任务队列消费者线程不能傻傻地轮询队列是不是空的那会白烧 CPU。正确的姿势是用 std::condition_variable。#include condition_variable #include queue std::mutex mtx; std::condition_variable cv; std::queueint tasks; void producer() { for (int i 0; i 10; i) { { std::lock_guardstd::mutex lock(mtx); tasks.push(i); } cv.notify_one(); // 通知一个等待的消费者 } } void consumer() { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, []{ return !tasks.empty(); }); // 防止虚假唤醒 int task tasks.front(); tasks.pop(); lock.unlock(); // 处理任务时不需要继续持锁 // 处理任务 } }注意 wait 的第二个参数那个 lambda 是必须的。lambda 返回 false 就继续睡返回 true 才往下走。原因有两个spurious wakeup虚假唤醒是操作系统调度导致的现象线程可能在没有任何 notify 的情况下被唤醒这时候如果队列还是空的直接 pop 就崩了另一种情况是多个消费者被同时唤醒但只有一个任务抢到任务的线程把队列 pop 空另一个线程醒过来发现队列又空了。这个 lambda 本质上是在帮你二次确认“唤醒条件是否真的满足”。我见过不少生产级代码里偷懒不传这个谓词直接 cv.wait(lock)然后靠外围 if 判断。这种做法在单消费者场景下偶尔能跑但只要消费者一多偶发崩溃就来了而且极难复现。所以写条件变量的时候一定要把谓词加上这属于花五分钟保五小时的那种细节。2.4 std::async比手撕线程更省心的异步方案很多人写 C 并发第一反应就是 std::thread 加 join。但如果你只是想让一个耗时函数在后台跑然后拿结果用 std::async 会简单很多。#include future #include iostream int heavy_calc(int x) { // 假设这里很耗时 return x * x; } int main() { std::futureint f std::async(std::launch::async, heavy_calc, 5); // 主线程可以干别的事情 int result f.get(); // 等待结果 std::cout result \n; }std::async 的好处是它把“创建线程、传递参数、返回值、异常处理”这一套流程都帮你封装好了。子线程里如果抛了异常f.get() 会把这个异常重新抛回主线程这是手写 std::thread 很难做到的事情。std::thread 里子线程异常只能靠 catch 后自己处理异常如果跑出线程函数会直接调用 std::terminate 把整个程序干掉。传统面试题里问“用 thread 还是 async”我现在的回答是默认用 async除非你能说清楚你需要对线程生命周期做精细控制比如线程循环等待、需要手动唤醒、需要调整调度优先级。async 还有一个省心的点它第一个参数可以传 std::launch::async 或 std::launch::deferred后者表示延迟到 get() 时才在当前线程执行。如果什么都不传标准允许实现自行选择这会导致程序在不同平台上行为不一致所以务必显式指定 launch policy。3. 原子类型解决了什么问题以及它到底解决不了什么3.1 一次看似简单却足够致命的计数假设一个网络服务器需要统计收到了多少请求最简单粗暴的写法是long long counter 0; void on_request() { counter; // 这行真的线程安全吗 }如果你只在单线程里跑这行代码没任何问题。多线程下就不一样了counter 翻译成 CPU 指令至少是三步把 counter 从内存读到寄存器寄存器加一寄存器写回内存。三步之间线程 A 可能刚读完旧值线程 B 也读到了同一个旧值然后大家分别加一写回结果是两次请求只计数了一次。这个 bug 属于“丢了更新”频率高的时候很容易发现频率低的时候则悄悄丢等事后查日志才发现数据对不上。解决思路有两个方向。一种是加锁每次自增前拿 mutex简单粗暴。但如果这是一个极高并发的热路径比如每秒钟几百万次请求锁的开销就会成为瓶颈而且锁在多核竞争激烈时会有明显的性能悬崖。另一种思路就是用 C11 提供的原子类型#include atomic std::atomiclong long counter{0}; void on_request() { counter.fetch_add(1, std::memory_order_relaxed); }3.2 std::atomic 是自带原子性的封装而不是什么都不做的壳std::atomic 是一个类模板它对底层类型整数、指针、枚举等做了封装保证从 C 层面对它发起的操作是原子性的。“原子性”指的是一个操作要么完整执行、要么完全不执行其他线程不可能看到中间状态。CPU 层面这东西一般依赖 LOCK 前缀指令或者某些指令本身具有的原子语义比如 x86 上的 xchg在 ARM 上则可能依赖 LDXR/STXR 这样的互斥指令对。很多人对 std::atomic 有个误解以为它只是把变量包了一层性能差不了多少。实际上非原子变量读写通常编译成一条简单的 mov 指令而原子操作在做读改写时可能需要 lock cmpxchg 这种代价明显更高的指令。性能虽然比锁好但也不是零成本。所以别把所有共享变量都声明成 atomic那是拿大炮打蚊子还可能误伤别的逻辑。std::atomic 提供的函数远不止 load/store 和 fetch_add。常用的还有compare_exchange_strong / compare_exchange_weakCAS 操作无锁编程的核心原语。exchange原子地写入一个新值并返回旧值。fetch_sub / fetch_and / fetch_or对应减法和位运算。以比较交换为例它的语义是如果当前值等于期望值就把它更新为目标值并返回 true否则读取当前值到期望值里返回 false。注意 compare_exchange_weak 在平台条件不满足时即使当前值等于期望值也可能返回 false表现为“伪失败”这种场景需要循环重试。而 compare_exchange_strong 保证不会伪失败。x86 平台上两者编译出来的结果几乎一样但你要准备把代码移植到 ARM 等弱内存序平台就要小心处理 weak。3.3 为什么 volatile 救不了并发这玩意到底管什么“线程安全的自增怎么写”这个问题在 C11 之前的标准答案甚至可以是空集因为 C98/03 根本没有线程的概念。老一辈程序员听说 volatile 能防止编译器优化就把它用来做并发控制然后踩坑踩到怀疑人生。volatile 的真实作用是告诉编译器这个变量的值可能被“本代码之外的外部世界”修改所以每次使用都必须从内存重新读取不能因为编译器认为“这个循环里变量没变过”就直接优化成一次性读取或寄存器缓存。这适用于内存映射 IO 寄存器这种场景设备硬件会修改那块内存而编译器看不到这个修改行为。但 volatile 管不到 CPU 缓存和内存序问题。现代 CPU 是多核的每个核有自己的缓存线程 A 写了一个 volatile 变量这个写入可能还停留在 A 核的 store buffer 里线程 B 在另一个核上读读到的仍然是旧值。volatile 既不会生成内存屏障也不提供任何同步语义它没法保证“A 写在前面B 就能看见”。所以结论很清楚并发场景下共享变量的原子性和可见性交给 std::atomic跟硬件状态交互的变量才考虑 volatile。这两者不是替代关系。如果某个变量既要并发安全访问又对应到设备寄存器理论上你需要把它们组合起来用通过 atomic_ref 之类的方式去访问 volatile 修饰的地址。当然这是极端情况大部分业务代码不会碰到。4. 没有乱序和重排这两个概念内存序就是空中楼阁4.1 编译器和 CPU 都在偷偷“优化”你的读写顺序你想让线程 A 把数据准备好之后再设置一个 flag告诉线程 B “你可以取数据了”。代码写得很漂亮int data 0; std::atomicbool ready{false}; // 线程 A data 42; // 先写数据 ready.store(true); // 再设标志 // 线程 B while (!ready.load()); // 等标志 std::cout data; // 使用数据如果一切行为严格符合你写代码的顺序那这段程序没问题。但问题在于无论编译器还是 CPU为了性能都会对指令做重排。编译器可能认为 data 42 和 ready.store 之间没有数据依赖把两个操作换一下位置也“不影响单线程语义”CPU 也可能在硬件层面把 store 操作先执行了或者把 load 操作往前挪让流水线更忙。在单线程程序里这种重排不会破坏可观察行为因为线程自己读自己的能通过乱序执行缓冲来做等价恢复。多线程就麻烦了线程 A 的 store 顺序变了线程 B 观察到的情况就和你的直觉完全不一致。B 可能看到 ready 变成 true 了但 data 还没写。这里的核心痛点就是你不仅需要原子操作你还需要操作之间有一个可预期的顺序。4.2 从 store buffer 说起弱内存序平台上有哪些事会超乎直觉要理解为什么不同平台行为差异这么大得稍微看一眼 CPU 架构。x86 是一个强内存序模型它在硬件层面做了很多事情保证对于普通写操作store 不会越过更早的 storeload 也不会越过更早的 load写后读的顺序也基本能保持。所以你在 x86 上跑上一节的代码大概率是 OK 的这也导致很多在 x86 上开发的同学对内存序完全不敏感。ARM 和 PowerPC 就完全不同。它们的内部为了省电、提吞吐允许 store buffer 把写操作暂存然后再异步刷到缓存一致性协议里。如果线程 A 在 ARM 上执行 data 42 然后 ready.store(true)第二步可能先把 ready 刷出去了data 还在 store buffer 里蹲着。线程 B 发现自己读到了一个巨大的数字或者别的什么没初始化的值bug 就炸了。编译器也参与重排。现代编译器在你没加任何同步约束时会默认所有内存访问之间没有跨线程依赖从而大胆地做各种指令调度。有时候你以为代码是那样的编译出来汇编却是另一回事。4.3 C11 引入一套六档内存序它到底在描述什么为了把这个问题从“未定义行为”里捞出来C11 正式把内存模型写进了标准。它提供的核心工具就是 memory_order一共六个枚举值内存序含义典型开销与用途memory_order_relaxed只保证原子性不提供任何顺序约束计数器自增、统计信息memory_order_consume只对依赖该值的后续操作有排序约束标准里存在但实践中建议当 acquire 用memory_order_acquire保证后续读写操作不会被重排到该 load 之前读锁、拿到标志后去读共享数据memory_order_release保证之前的写操作不会被重排到该 store 之后写锁、设置标志前先更新共享数据memory_order_acq_rel同时具备 acquire 与 release 语义读改写操作如 CAS 循环memory_order_seq_cst全序一致所有线程看到同一个操作顺序默认值性能最差但最安全这个表读起来像背单词但也只能先背下来。关键是理解如下模型acquire 和 release 不是作用于“某个原子变量本身”的约束而是一种“屏障”作用用来划分它前后普通内存操作的可见性。release store 相当于一道屏障把前面所有写操作包括普通变量的写都推到屏障之前屏障之后的写不允许越界到屏障之前另一个线程做了 acquire load把后续的读操作挡在屏障后面之前没有完成的内存读写都先别碰。两边一配对release 之前对普通变量的写入就对 acquire 之后读这些变量的线程可见了。正式名称叫 happens-before 关系。std::atomic 的释放操作与获取操作之间建立起了这种关系于是普通变量 data 42 的写入就“happens-before”B 线程对 data 的读取。这就是 C11 内存模型最精华的地方它不只是让原子变量访问安全还顺带保护了凡是处于同步关系里的非原子数据访问让它们不再算 data race。5. 正面回答内存序是专门为原子操作准备的吗5.1 这个问题的标准答案不是但最常见的使用载体确实是原子操作回到开头那个热搜问题。我的看法是如果你只是在 API 层面看问题会觉得内存序只出现在 std::atomic 的函数签名里那当然“是给原子操作准备的”。但如果你把它当成一种内存同步模型来理解就会发现它真正的服务对象是“线程间共享数据时的可见性规则”原子变量只是承载这套规则的载体而已。让我用一个生活中常见的场景来说清楚记住 release/acquire 配对的核心不是原子变量自己需要“顺序”而是你需要建立一个跨线程的“消息通道”。通道的入口是你往一个原子变量里写值release出口是另一个线程从同一个原子变量里读值acquire。一旦你通过了这个通道前面做的普通变量修改就能安全地传过去。所以理解它的正确姿势是原子变量只负责传递消息和同步点普通变量才是真正被“保护”的“货”。你不可能用一块“满载货物”的普通变量去跨线程同步也不应该只关心原子变量自身读到了什么值而忽略了货物到底有没有到达。这套思想不只在 C 里成立Java 的 volatile、C# 的 volatile、Go 的 atomic实现的都是同一种东西的不同表现形式。这也就是为什么很多人写出“线程 A 给 atomic 变量加一线程 B 读这个变量判断是否退出”这种代码时总觉得哪里不对劲——因为你把一个用来建立同步关系的 release/acquire 消息变量当成了数据自增的计数器。计数器本身也许不会错乱但它没有传达任何“我发布了一份数据你可以安全读取了”的语义。它和普通变量之间没有 happens-before 桥梁普通数据依旧存在竞态风险。5.2 什么时候内存序确确实实只在管原子操作本身有一种场景下内存序的关注对象就真的只局限在原子变量上不需要考虑普通数据当所有线程都只把这个原子变量当成独立的计数器或开关不去关联别的状态时relaxed 就够用。典型例子比如埋点统计、内存分配器的线程本地计数、自旋锁里的 ticket 号。线程只需要保证加法和读法的原子性不需要关心计数和别的状态有什么先后顺序。这种情况下memory_order_relaxed 才是完全正确的选择——它开销最小语义也完全匹配需求。再举个例子无锁栈的 pop 操作里线程要 CAS 修改栈顶指针把栈顶指针从旧的 head 换成新的 head。这个场景是 acquire因为它需要保证新 head 指向的那个节点对象在它的构造函数里对节点的写操作能被当前线程看到你需要 acquire 语义护航。而 push 操作更新栈顶时用 release保证把节点数据都写完再把指针发布出去。所以同样是原子变量上的操作配哪种内存序取决于它和其他非原子数据之间有没有同步义务。5.3 区分两个词原子性保证的是完整性内存序保证的是可见性很多人在学习时把原子操作和内存序混为一谈。这俩其实是两维的东西。原子性保证一个操作不会分裂成两半被其他线程穿插。即使你用 memory_order_relaxedfetch_add 也不会自增到一半被其他线程打断最终值不会丢。顺序性/可见性保证一个操作在线程间是否按某个可预期的顺序被观察。它关心的是“我改了别人什么时候能看到”“看到的时候和这个改动作相关的其他改动是否也一起来了”。一个经典不严谨的比喻是原子性相当于你往信箱里塞了一封信整个过程不会被别人从中间把信抢出来内存序则相当于你通知朋友“我把信放进去了你现在可以来取”的沟通方式。如果通知方式不对你朋友看到“有信”的同时信可能还在半路上。如果你的邮箱里还放了别的东西你有多个数据要一起送过去那你就需要通过这个“取件通知”把那些东西的送达顺序也一并安排好。release 相当于把邮箱里所有先放好的东西都钉在“放信”动作之前acquire 相当于收件人必须先把邮箱里所有东西都收下来才会看到“有信”这个信号。看到没有邮箱里的普通物品才是大头信号本身只是载体。5.4 六档内存序里每一次都要死磕是否影响正确性给出一段自查清单写任何一个带内存序的原子操作前都可以照着想这个原子操作需不需要和其他非原子变量的读写建立先后关系如果需要选 acquire/release配对的另一个线程必须做相反的操作。如果选 seq_cst是不是因为你有多个原子变量它们之间需要存在一个全局一致的全序seq_cst 就是给这种场景兜底的。如果只关心计数自增不关心顺序才可以直接上 relaxed。不要为了“秀操作”把本该 relaxed 的地方改成 seq_cst也不要反过来为了“快一点点”把该用 acquire 的地方改成 relaxed正确性问题上没有性价比。从工具使用角度我的习惯是先用默认的 seq_cst 把逻辑跑对需要用工具验证瓶颈确实在原子变量上时再把个别操作降级为 release/acquire最后才考虑 relaxed。在序列一致性下都跑不对的并发代码降级之后只会错得更离谱。6. 实操实录跟着我用 C11 手写一个无锁 SPSC 队列6.1 需求定义与设计选型为什么 SPSC 是最友好的无锁入门纸上谈兵讲了一大堆现在来做一个能跑的东西验证一下一个单生产者单消费者SPSC的无锁有界队列。选 SPSC 是因为它只需要一个写线程和一个读线程不存在两个消费者同时竞争队列头和两个生产者同时竞争队列尾的复杂问题同步维度变得很纯粹非常适合用来展示 relaxed/acquire/release 到底怎么落地。设计思路沿用经典的 ring buffer。用一个数组保存元素生产者维护 write_index消费者维护 read_index。队列满的条件是 write_index - read_index capacity队列空的条件是两者相等。核心挑战在于生产者在写入数据之后再去更新 write_index消费者先读 write_index确认有数据之后才能去读对应位置的元素。这天然就是一个 release/acquire 配对的场景。6.2 完整实现所有细节都写在注释里#include atomic #include vector #include thread #include iostream #include chrono template typename T, size_t Capacity class SPSCQueue { public: SPSCQueue() : buffer_(Capacity), write_index_(0), read_index_(0) {} bool push(const T item) { // 只有生产者能调用 push size_t current_write write_index_.load(std::memory_order_relaxed); size_t current_read read_index_.load(std::memory_order_acquire); // 队列已满 if (current_write - current_read Capacity) { return false; } buffer_[current_write % Capacity] item; // 关键先把 item 写进 buffer再发布 write_index // release 保证上面的普通写操作不会泄漏到这次 store 之后 write_index_.store(current_write 1, std::memory_order_release); return true; } bool pop(T item) { // 只有消费者能调用 pop size_t current_read read_index_.load(std::memory_order_relaxed); size_t current_write write_index_.load(std::memory_order_acquire); // 队列为空 if (current_read current_write) { return false; } item buffer_[current_read % Capacity]; // 数据读完了再更新 read_index // 这里 relaxed 就够用了因为不需要把其他写操作发布出去 // 但如果消费者还依赖这个 read_index 去判断空满生产者用 acquire 加载 // 就能看到 read_index 的最新值 read_index_.store(current_read 1, std::memory_order_release); return true; } private: std::vectorT buffer_; std::atomicsize_t write_index_; std::atomicsize_t read_index_; };解释几个设计细节。生产者 push 里的 read_index 加载用 acquire。原因在于如果队列刚被消费者清空消费者可能在 pop 里 release 了新的 read_index生产者需要 acquire 到消费者的 release才能正确看到队列空出来的槽位。否则生产者可能始终认为队列是满的。消费者 pop 里的 write_index 加载用 acquire。生产者 push 完数据之后 release write_index消费者 acquire 到这一步就能保证 buffer_ 里被写入的数据是可见的。这是整个队列里最重要的同步点。write_index_ 和 read_index_ 自身用来判断空满时每次都做 acquire/release 或 relaxed 的配对加载是必要的。6.3 验证正确性与性能跑一个生产消费测试int main() { SPSCQueueint, 1024 q; std::atomiclong long sum{0}; constexpr int total 1000000; std::thread producer([] { for (int i 0; i total; i) { while (!q.push(i)) { // 队列满则自旋等待 std::this_thread::yield(); } } }); std::thread consumer([] { int value 0; for (int i 0; i total; i) { while (!q.pop(value)) { std::this_thread::yield(); } sum.fetch_add(value, std::memory_order_relaxed); } }); producer.join(); consumer.join(); std::cout sum sum.load() \n; // 期望输出sum 499999500000 }在我本机 x86 上跑这个程序输出稳定符合预期。sum 的值是 0 到 total-1 的累加说明消费者确实收到了生产者的每一个整数顺序也完全正确。这个结果不是理所当然的它依赖于 push 里的 release pop 里的 acquire 配对。如果这两处都改成 relaxed程序有很大的概率在线程数少的情况下也能跑出正确结果但一旦加大 total 或者在弱内存序平台上运行丢失或错乱就必然出现。很多人拿这个例子说“看relaxed 也能跑对啊”那是被 x86 的强内存序掩盖了问题。换到 ARM 开发板上同样的代码relaxed 版本翻车率会让他们意识到什么叫内存序。6.4 实战中可以进一步优化的细节上面的实现还有几个可以打磨的点。第一为了提升性能缓存行伪共享问题不容忽视。write_index_ 和 read_index_ 如果落在同一个缓存行上生产者每次写 write_index 都会导致消费者的 read_index 缓存失效反之亦然。在高频入队出队场景下这个影响相当明显。可以采用 alignas(64) 修饰索引字段把它们分散到不同的缓存行。std::atomicsize_t write_index_; char padding_[64]; // 或者直接用 alignas std::atomicsize_t read_index_;不过这里也提醒一句缓存行大小在不同架构上不一定是 64 字节最稳妥的做法是把两个原子变量放到一个结构体里用 alignas(std::hardware_destructive_interference_size) 来分隔C17 才引入这个常量。C11 就自己按 64 字节来。第二自旋等待的性能。push 失败说明队列满此时直接 yield 会让出 CPU但也会导致调度延迟。如果是低延迟场景可以考虑用 _mm_pause()x86或 __builtin_ia32_pause()让 CPU 在几个周期内不做什么重活。我这个测试用 yield 是为了线程友好真实无锁代码里通常得看场景取舍。第三队列缓冲区的元素默认构造可能比较昂贵。可以把 buffer_ 从 vector 改成 T* 并配合 aligned_storage 手动管理生命周期只在 push 时构造、pop 时析构。代码复杂度会上升很多属于“以后需要时再优化”的部分。7. 常见问题与排查技巧内存序相关的坑我踩过的都给你列出来7.1 为什么 memory_order_relaxed 自增一个计数器还会出现重复或丢失连续踩坑的往往是自旋锁或者计数器场景。我用一个简化版说明std::atomicint ticket{0}; // 线程 A ticket.fetch_add(1, std::memory_order_relaxed); // 线程 B 同时执行同样的操作relaxed 保证单个 fetch_add 本身是原子的不会出现两次 fetch_add 同时读到同一个旧值最后只加了一次的情况。这个保证是可靠的跟内存序没有关系。如果你发现自增一直丢更新先检查是不是忘了用 fetch_add而写成 ticket.load() 1 再 store——那当然不原子。再检查一下是不是把普通 int 直接 multi-thread 自增了而压根没有 atomic 类型。另一种情况是单生产者单消费者队列里消费者读 read_index 用的 relaxed但队列满的判断却依赖 write_index 的最新值这时可能读到稍微过期的 write_index判断出错。要修正的话判断空满的 load 必须用 acquire因为它需要和生产者的 release 配对。这就是我们常说的“内存序要配对”不是所有 load 都用 relaxed 也没事。7.2 为什么 lock-free 代码在 x86 上测得好好的一上 ARM 就蹦我在实际项目中遇到过这种情况。一个基于原子变量 CAS 的无锁栈在开发机 x86 上压测几天没问题丢到 ARM 嵌入式板子上跑几个小时随机崩且每次崩溃位置都不一样。原因就是 x86 是强内存序模型很多编译器会针对 x86 自动润滑比如把 acquire/release 在 x86 上编译成普通的 mov因为 x86 本身 store 不会重排到更早的 load 之前。但是 ARM 是弱内存序CPU 需要显式的 dmb 指令来保证顺序代码里如果漏了配对或者用了错误的内存序ARM 上就会真实暴露。排查这种问题第一是代码审查把所有 acquire/release 配对列出来逐个对比是否匹配。第二是上工具clang 的 ThreadSanitizer 虽然不能自动发现所有内存序问题但它能发现明显的数据竞争。如果是复杂场景还可以用 CDSChecker 这类专门验证无锁数据结构的工具去构建状态空间分析。第三是交叉编译到 ARM 模拟器里高频跑压力测试。注意模拟器上的 CPU 还未必模拟出真实的 store buffer 行为只是提高置信度。7.3 怎样正确看待编译器屏障与 CPU 屏障的区别内存屏障按层级可以分为编译器屏障和 CPU 屏障。编译器和 CPU 都可能做乱序所以一份代码想保证内存序需要两道关卡都守住。编译器层面可以用 atomic_signal_fence 或者直接插入内联汇编里的空 asm volatile( ::: memory)告诉编译器这里不能把汇编之前的读写操作调度到汇编之后。CPU 层面则需要真正的屏障指令比如 x86 的 mfence/lfence/sfenceARM 的 dmb。std::atomic 的内存序参数会被编译器翻译成合适的一组指令同时约束编译器和 CPU。你在写库代码时有时候能看到这样的写法inline void compiler_barrier() { asm volatile( ::: memory); }它的作用是只禁止编译器重排不限制 CPU 乱序。如果你编写的是无锁代码但又想手动控制更底层的行为这个工具可能有用。普通应用开发我不建议手动插屏障容易画蛇添足不如直接用 std::atomic 给出内存序把事情交给标准库的实现者去查指令手册。7.4 一套内存序自查清单写并发代码前先过一遍每次写完无锁代码建议对着清单问一遍能少踩很多坑这个原子变量是用来传输数据的还是只做计数如果是传输数据必须配上 acquire/release如果只做计数且不影响其他数据relaxed 可以考虑。我的 release store 对应的 acquire load 在哪个线程的哪个位置找不到对方的配对往往说明同步关系没建立起来。原子操作前后那些普通变量的访问是否在临界区内它们有没有可能被别的线程通过另一个通道访问同一个地址既由普通代码访问又由原子代码读取这可能直接构成 data race引发 UB。我是不是把一堆原子操作都塞进了一个循环里如果循环里既有依赖关系又在不同线程间有多个同步点你的程序是否允许这些同步点以不同顺序被观察到如果允许怎么证明排查过一个数据竞态线上多线程写入一个无锁 hash map某个节点被并发读写时值不对时好时坏。加了 TSan 跑了一遍报的竞态正是发生在“普通写 原子标记位”之间。修复方式很简单把节点的写完再对节点的 ready 标志做 release 写读取端先 acquire 读 ready再读节点内容。就这么一个小改动问题消失。这类案例一再说明atomic 只管自己的原子性它周围的普通代码能不能安全共享靠的是内存序。7.5 多说一句无锁不等于高性能最后分享一点个人体会。无锁代码最大的价值不是一定更快而是在某些场景下提供更好的扩展性和延迟特性。锁在高竞争时会让线程睡眠、唤醒开销很大无锁靠自旋重试延迟更可控。但无锁代码的编写和验证成本高出好几个量级一个看似精巧的 ABA 问题就能让你排查数天。所以工程上我给自己定的一条原则是默认用锁简单可靠性能不够再 profiling只有确认瓶颈在锁上并且场景确实适合无锁再动手设计无锁结构。写无锁时优先挑模型简单的 SPSC、MPSC 来做复杂的多写多读结构尽量用现成库比如各种无锁队列实现而不是自己造轮子。回到 C11 本身它给我们提供的这套线程库和原子类型虽然是 2011 年的产物但直到今天线程创建、互斥锁、条件变量、原子变量、内存序这一整套工具箱依然是 C 并发编程里最核心的骨架。把这些基础概念搞扎实比追着 C20/23 的新特性跑要重要得多。这篇笔记先写到这里下一篇如果还有机会继续聊并发我打算把无锁栈、读写锁和无锁队列在实际多核机器上的性能对比数据整理出来到时候见。
返回列表