除夕夜,一家小饭店的后厨忙得团团转。大厨老张原本只有一位帮厨,洗菜、切菜、掌勺全靠一个人,勉强应付得来。可今年年夜饭订单翻了三倍,老张咬咬牙,又雇了两位厨师:小明和小刚。 两位厨师共用一张案板、一口大锅、一个盐罐。案板是共享内存,盐罐上的数字记录着“已经加了几次盐”。菜谱就是代码,每位厨师按自己的菜谱干活。看起来井然有序,可问题恰恰出在那个共享的盐罐上——小明和小刚同时伸手去抓盐罐,一个还没放回去,另一个又伸手了。盐罐上的数字彻底乱了,一锅汤要么咸得要命,要么淡而无味。 这个故事,就是并发编程的缩影。在C++里,多线程给了我们“同时干活”的能力,但共享数据一旦被多个线程同时读写,就会像两位厨师抢盐罐一样发生数据竞争。幸运的是,C++为我们准备了三种武器:原子操作(特制盐罐,取放一气呵成)、互斥锁(厨房钥匙,谁拿到谁进)、条件变量(厨房铃铛,菜好了喊一声)。本篇就用这个厨房故事,带你迈出C++多线程与原子操作的第一步。
多核时代,并发是榨取CPU性能的必由之路。理解线程、原子操作、内存序与同步原语,你就能写出既快又稳的系统级代码——这正是高性能服务端、游戏引擎、AI推理框架共同的技术底座。
目录
可视化演示
展示线程生命周期状态转换、CAS原子操作循环及竞态条件与互斥锁保护对比。
本节配有交互动效(进入视口后自动加载,约 3KB)。
线程生命周期 std::thread创建后进入就绪态 调度器分配CPU进入运行态 join等待线程结束后回收资源
线程创建:雇来两位帮厨
大厨老张决定雇两位帮厨。在C++的世界里,“雇人”不是填张表那么简单——你得用std::thread向操作系统正式下聘书:我要创建一条新的执行流。线程创建后,它和主线程并行跑,主线程不用等它也能继续干自己的活。
创建线程的语法简洁得出奇,一行代码就够:std::thread t(make_dish, "红烧肉")。这行代码执行后,系统立刻启动一个全新的执行路径,像店里多了一位手脚麻利的帮厨,马上开始按菜谱干活。
线程跑完之后怎么办?两个选择。t.join()是“原地等他回来交差”,主线程会阻塞,直到子线程结束。t.detach()则是“干完活不用报告,各回各家”。注意detach之后,主线程失去了与这个线程的关联句柄,再也无法等待它。
厨师终于上岗。但厨房只有一张案板、一个盐罐,麻烦也随之而来——他们开始抢盐罐了。
- std:thread:C++标准库的线程类名,像“招聘合同”的官方抬头,声明这是一条独立执行流。
- t(……)
t是线程对象,括号里第一个参数是任务函数(厨师要执行的菜谱),后面的参数是要传给它的材料。 - join()主线程调用
t.join(),站在厨房门口等这位帮厨干完活再走。
数据竞争:盐罐上的糊涂账
小明要往汤里加盐,小刚也要加。共享的盐罐上,标签写着“已加5次”。问题在于,“加盐”不是一个动作,而是三个:看一眼数字(读)、心算加一(改)、把新标签贴回去(写)。
多线程的世界里,这三个步骤之间随时可能被另一条线程插一脚。小明看到5,刚想写上6,小刚也凑过来,看到的还是5。小刚贴上6,小明也贴上6——两人各加了一次盐,标签却只多了1。这就是经典的“丢失更新”。
这种多个线程同时读写同一块内存、且至少一方在写的现象,就是数据竞争(data race)。在C++标准里,数据竞争属于未定义行为:程序可能崩溃、死循环,或者得出匪夷所思的结果。
盐罐要防抢,就得想个办法让“取、算、放”一气呵成——这正是原子操作要干的事。
- 右侧 count先从共享内存把当前值读到寄存器,好比看一眼盐罐标签。
- count + 1在寄存器里完成加法,好比心算“再加一次盐”。
- 左侧 count把新值写回共享内存,好比贴上写有6的新标签。
原子操作:多功能盐罐
C++准备的特制盐罐叫std::atomic<T>。普通int变量没有防抢能力,但std::atomic<int>的每个操作,底层都映射成CPU提供的原子指令:要么不做,要么瞬间做完,中间不可能被插一脚。
最常用的是fetch_add:原子地完成“读-加-写”。count.fetch_add(1)就是count = count + 1的原子版,不会丢失更新。它还支持load()读取和store()写入——分别是安全的“看标签”和“贴标签”。
更强大的是CAS(compare_exchange):只有当v等于expected时,才把v更新为desired,否则不更新并返回失败。它像“先核对标签是不是我记的数,是就改,不是就拉倒”,是构建无锁算法的基础。
原子操作守护了单个变量,但复杂的场景远不止保一个数——如果两件事必须同时完成,原子操作就不够用了。这时得动用厨房钥匙。
- count被声明为
std::atomic<int>的原子变量,就是那只特制盐罐。 - fetch_add(1)原子地完成读、加、写三步,中间不可打断。
- 比较 v == expected把罐子当前值和预期值对照,像核对标签是不是自己记的数。
- 交换 v ← desired相等才写入新值,不等就什么都不做,返回是否成功。
内存序:纸条的可见顺序
盐罐保住了,新问题浮出水面:纸条的可见性。厨师A明明把盐放好、写了张纸条贴在门口,厨师B却半天看不到。多核CPU里,每个核心有自己的缓存,写操作先落到缓存,什么时候刷进共享内存是另一回事。
内存序(memory_order)管的就是“这张纸条何时对其他核心可见”。宽松序(relaxed)只保证原子变量本身不撕裂,不保证顺序;获取-释放(acquire/release)保证:线程A在release写之前的所有操作,对随后acquire读到该变量的线程B全部可见;顺序一致(seq_cst)则给所有线程一个统一的“全局快照顺序”,最安全也最贵。
一个经典片段:线程A做x.store(1, release),线程B做y.load(acquire)。B一旦看到x的值,就知道A在发布之前写的所有数据都准备好了——像极了“张老板贴出'已消毒'告示,客人看到告示就知道可以进店”。
内存序是把双刃剑:调对了性能飞升,调错了bug诡异。新手请从顺序一致起步,熟练后再考虑宽松优化。
- store(1, release)线程A以release语义写入x,相当于贴出“已消毒”告示,之前的写操作不许乱序越过它。
- load(acquire)线程B以acquire语义读取x,一旦读到1,就能看见A之前的所有写操作。
- 成对出现release和acquire必须作用在同一个原子变量上,才能形成跨线程的同步。
互斥锁:厨房钥匙
有时候,我们要保护的是一段“复合动作”:比如先看账户余额再扣款,两步必须连着做完,不许别人插进来。原子操作管不了这么宽的场面,于是C++派出了互斥锁(mutex)——厨房钥匙。
规则很简单:进厨房前先lock()拿钥匙,干完活unlock()还钥匙,没拿到钥匙的线程只能在门口排队。被mutex保护的那段代码叫临界区(critical section),同一时刻只允许一个线程进入。
手动lock/unlock容易忘。std::lock_guard是智能钥匙扣,离开作用域自动归还;std::unique_lock更灵活,可以手动解锁,也是条件变量需要的搭档。死锁则像两个厨师各拿一把钥匙、都在等对方先进门——互相谦让造成的僵局。
互斥锁解决了“完整执行”,但它太重:一个厨师进厨房,其他人都得晾着。能不能让等待的人去睡一觉,被通知了再干活?条件变量登场。
- lock_guard lk(m)构造时自动调用
m.lock(),拿到厨房钥匙。 - 临界区从这行开始到所在花括号块结束,都是受保护的区域。
- 析构自动解锁离开作用域,
lk析构调用m.unlock(),钥匙自动归还。
条件变量:厨房铃铛
煲汤要等水烧开。厨师B不想空转锅铲,他想在旁边眯一会儿,水开了有人摇铃喊他。条件变量(condition_variable)就是那口铃铛。
使用模式:一个线程持锁后调用cv.wait(lk, pred)——它会原子地释放锁并进入睡眠。另一个线程在条件准备好后调用cv.notify_one()或cv.notify_all(),把沉睡者摇醒。醒来后线程重新拿锁,然后检查谓词是否成立。
虚假唤醒是著名的坑:线程可能没接到铃铛也自己醒了,或醒后发现条件其实没满足。所以wait的谓词必须用循环重新检查——这也是wait第二个参数存在的意义。C++标准明确允许虚假唤醒,所以必须while循环防御。
生产者-消费者模型就是靠铃铛完成的:生产者做好菜摇铃,消费者睡醒取菜。多线程协作的世界终于顺畅了。
- cv条件变量对象,厨房里的铃铛。
- wait(lk, pred)原子地释放锁lk并进入睡眠,直到被通知且pred返回true才重新锁上并继续。
- predicate一个返回bool的谓词,是“确认汤真的开了”的检查,防御虚假唤醒。
常见陷阱:灶台上的坑
灶台上最滑的地板有几块:死锁、悬垂引用、volatile误区。死锁我们已经见过——两个线程互相持锁等待,像两位厨师各拿一把钥匙,互不相让。破解之道:所有线程按相同顺序给多个锁排队,或用std::lock同时锁多个。
悬垂引用往往藏在detach里。一个被分离的线程还在执行,但它引用的局部变量早已被主线程销毁,就像被解雇的帮厨还回去用已经回收的旧案板——未定义行为。永远不要detach一个引用了栈变量的线程。
volatile更是经典误区:它只告诉编译器“别优化我”,却不保证CPU缓存一致性和原子性,用它防并发等于刻舟求剑。
知道了坑的边界,就能画出安全地图。最后看看真正的高手怎么用多线程。
- R1 → R2线程1拿着资源R1,等待R2。
- R2 → R1线程2拿着R2,等待R1,形成环状等待。
- 死锁两人都不撒手,谁也无法推进。
并发实践:多核厨房的功力
多线程的最终目标是榨干多核CPU。但线程是昂贵的资源,每创建一个线程,操作系统都要分配独立的栈空间。一百个厨师挤在一个厨房里,除了比手肘长,效率反而更差。
线程池就是“固定雇佣量”的方案:一次性创建N个线程,它们常态待命,任务来了按需分配,避免了反复“雇人/解雇”的开销。无锁编程则是用原子操作+CAS实现高并发数据结构,在极热点数据上免去锁竞争。
衡量并发收益的金标准是Amdahl定律:假设程序可并行部分比例为p,有n个核,理想加速比是1 / ((1-p) + p/n)。哪怕核数无穷大,加速比上限也只有1 / (1-p)。再精巧的多线程设计,也胜不过串行瓶颈。
故事来到尾声:小明和小刚用原子操作管好盐罐,用互斥锁守住厨房门,用条件变量协调上菜顺序……一桌年夜饭在井然有序的并发中准时出锅。你,也迈出了并发编程的第一步。
- p程序里可并行部分的比例,取值0到1。
- 1 - p必须串行执行的比例,谁也躲不开。
- p / n可并行部分被n个核平分后的耗时。
- S(n)n个核下的理论加速比,串行比例决定天花板。
AI 应用场景
推理引擎的并发批处理
阶段:模型推理与部署优化
解决的问题:AI服务收到大量推理请求,传统串行处理吞吐低、响应慢。多线程能并行处理多个请求,但共享模型参数和结果队列会引发数据竞争。
具体案例:某推荐系统在线推理服务,用C++线程池接收用户请求,每个worker线程独立执行推理,用条件变量管理任务队列,用std::atomic计数器记录已完成请求数。改造后吞吐量提升数倍,且无数据竞争。
训练数据流水线
阶段:数据预处理与加载
解决的问题:GPU训练时经常“等饭吃”——CPU单线程预处理图片、做增强太慢,数据喂不饱GPU。
具体案例:一个生产线程负责读图和做数据增强,多个消费线程通过锁和条件变量从有界队列取数据,用std::atomic维护队列大小,避免重复消费。数据吞吐成功匹配GPU训练速度,训练迭代时间缩短约40%。
参数服务器的乐观并发更新
阶段:分布式训练
解决的问题:多个训练worker同时更新共享模型参数,直接写会互相覆盖,加重锁又会让吞吐骤降。
具体案例:参数服务器用compare_exchange实现乐观并发:读参数、算梯度、CAS写入,冲突则重试。由于极少冲突,无锁路径让更新吞吐翻了近一倍。
Python 演示
import threadingimport time class BankAccount: def __init__(self, balance=2000): self.balance = balance self._lock = threading.Lock() def withdraw(self, amount): tmp = self.balance time.sleep(0.0005) tmp -= amount self.balance = tmp def safe_withdraw(self, amount): with self._lock: tmp = self.balance time.sleep(0.0005) tmp -= amount self.balance = tmp def run_unsafe(num_workers): acc = BankAccount(2000) threads = [threading.Thread(target=acc.withdraw, args=(50,)) for _ in range(num_workers)] for t in threads: t.start() for t in threads: t.join() return acc.balance def run_safe(num_workers): acc = BankAccount(2000) threads = [threading.Thread(target=acc.safe_withdraw, args=(50,)) for _ in range(num_workers)] for t in threads: t.start() for t in threads: t.join() return acc.balance if __name__ == "__main__": n = 10 expect = 2000 - n * 50 got = run_unsafe(n) print(f"无锁: 期望余额 {expect}, 实际 {got}") got_safe = run_safe(n) print(f"有锁: 期望余额 {expect}, 实际 {got_safe}")
threading模拟C++多线程的“读-改-写”数据竞争,再演示互斥锁如何修复。tmp = self.balance先把余额读到本地,模拟C++里count从共享内存读进寄存器。time.sleep(0.0005)人为放大时间窗,让线程在此刻被切换,制造竞争。self.balance = tmp写回共享变量,如果多个线程同时写,就会互相覆盖。with self._lock:拿到互斥锁进入临界区,等价于C++的lock_guard。threading.Thread(target=acc.withdraw, args=(50,))像std::thread一样创建线程,指明执行函数和参数。t.start()启动线程。t.join()主线程等待所有子线程结束。balance_no_lock = run_unsafe(n)对比无锁与有锁的最终余额差异。
常见误区
误区“我在所有共享变量操作外面都加了mutex,数据安全肯定没问题了吧?”
正解锁诚实,但锁是局部的。如果代码里任何一处访问同一个共享变量时忘了加锁,数据竞争依然存在;锁的顺序不当还会死锁。好比厨房钥匙只锁了储藏室大门,可窗户还敞着,小偷照样进来。
为何常见:直觉上“加了锁=安全”,但锁只保护它包裹的临界区,保护不了其他入口。
误区“volatile告诉编译器不要优化,那多线程共享变量用它就安全了吧?”
正解volatile只防止编译器把变量缓存进寄存器,既不保证原子性,也不保证多核缓存一致性。CPU可能乱序执行,各核缓存各不相同。C++里多线程正确的可见性保障来自atomic和mutex,而非volatile。
为何常见:volatile名字听着就像“易变”,很容易和并发共享变量产生联想,加上编译器优化和CPU乱序执行的知识门槛高,误解就这样扎根了。
误区“atomic底层有特殊指令,肯定比mutex锁慢,所以锁是更好的选择?”
正解恰恰相反:无竞争时atomic的CAS只比普通加减慢一点点,而mutex的lock/unlock涉及系统调用或复杂的用户态队列,开销大得多。只有竞争激烈时atomic可能自旋消耗CPU。多数场景下,atomic是比锁更轻的选择。
为何常见:直觉上“额外的保证=额外的开销”,却忽略了mutex自己那套重量级机制。
误区“detach后就不需要管线程了,它自己会释放资源。”
正解detach只是让线程不阻塞主进程,线程本身仍会继续运行。如果它捕获了局部变量的引用或指针,而主线程已经销毁了这些对象,就会形成悬垂引用,属于未定义行为。正确做法是:用join保证生命周期,或让线程使用自己独立的数据。
为何常见:detach语义看起来像“甩手不管”,恰好掩盖了生命周期这个最需要管的点。
误区“条件变量的线程被notify叫醒,那条件一定成立,直接继续执行吧。”
正解虚假唤醒可能让线程在条件不满足时醒来;即使真的被notify,也可能有多个等待线程同时被唤醒抢资源。必须用while循环(或wait的谓词重载)重新检查条件,不满足就继续睡。
为何常见:notify给人一种“事件发生”的错觉,但wait的返回原因未必是notify,也可能是系统级别的偶发唤醒。
面试题
Q1. 以下哪个方法会让主线程阻塞等待子线程执行完毕?
- A.join等待子线程← 正确答案
- B.detach分离线程
- C.swap与另线程交换
- D.sleep让线程休眠
答案:A。join会阻塞主线程直到子线程结束,相当于老张站在厨房门口等厨师交差。detach是放养不等待,swap只交换线程句柄,sleep只是让当前线程睡觉。
Q2. 下列哪个类型能保证对共享变量的操作是原子的?
- A.原始int变量
- B.volatile int
- C.std::atomic类型← 正确答案
- D.静态int变量
答案:C。只有std::atomic的底层指令保证读取-修改-写入不被中断。volatile只防编译器优化,不防CPU乱序和缓存不一致,原始int和静态int都没有原子保证。
Q3. 在C++标准中,数据竞争(data race)属于哪一类行为?
- A.编译器能查出
- B.未定义行为← 正确答案
- C.运行时警告
- D.确定结果错误
答案:B。数据竞争在C++标准里是未定义行为,不像Java会做内存模型约束。好比两个厨师抢盐罐,结果是咸是淡只能听天由命,可能是任意结果,编译器也不负责提前报警。
Q4. std::atomic<int> counter; counter.fetch_add(1) 的含义是?
- A.counter++ 非原子
- B.原子自增操作← 正确答案
- C.自增但可被打断
- D.需锁保护才行
答案:B。fetch_add在硬件层面把“读-加-写”做成一瞬间完成,等价于原子版的counter = counter + 1。它不可被打断,也不需要互斥锁保护,是特制盐罐的取放动作。
Q5. 下列关于C++内存序由弱到强的排序,正确的是哪项?
- A.宽松、释放、顺序一致← 正确答案
- B.顺序一致、释放、宽松
- C.宽松、顺序一致、释放
- D.释放、宽松、顺序一致
答案:A。宽松序(relaxed)只保证原子性;释放/获取(release/acquire)增加因果同步;顺序一致(seq_cst)给全局统一顺序,约束最强。好比告示从“贴了不一定有人看见”到“全店同步广播”。
Q6. 关于memory_order_release与memory_order_acquire,下列说法正确的有?
- A.写前操作对读方成为可见← 正确答案
- B.不能跨多个变量传播
- C.保证全局顺序一致
- D.必须配对同一原子变量← 正确答案
答案:AD。release写之前的操作,会被同一原子变量上acquire读到的线程看到,A正确;同步关系可以沿多个原子变量传递,B错误;只有seq_cst保证全局顺序一致,C错误;release和acquire必须作用在同一原子变量上形成配对,D正确。
Q7. 条件变量的wait需要配合unique_lock,主要原因是?
- A.提升运行速度
- B.原子释放锁并睡眠← 正确答案
- C.防止死锁出现
- D.避免内存泄漏
答案:B。wait必须原子地完成“释放锁+入睡”,否则会出现“已经睡下但锁还没放”的窗口期。unique_lock支持手动解锁,让这份原子性成为可能。好比厨师睡着前必须先把厨房钥匙交出去。
Q8. 关于条件变量的虚假唤醒,正确的说法有?
- A.没有收到通知也可能醒来← 正确答案
- B.wait重载谓词可以防御← 正确答案
- C.notify_all才会触发唤醒
- D.while循环重查条件即可← 正确答案
答案:ABD。C++允许线程在没有notify时自发醒来,A正确;wait的谓词重载本质就是“醒来后重查条件”,B正确;notify_one同样会唤醒一个线程,C错误;用while循环重查条件是最标准的防御姿势,D正确。
Q9. 线程池最大的好处是?
- A.增加额外CPU核心
- B.避免线程反复创建销毁← 正确答案
- C.减少整体代码行数
- D.保证线程永不冲突
答案:B。线程的创建和销毁都有系统级开销,线程池让一组线程常驻待命,任务来了随取随用,省掉了反复“雇人/解雇”的成本。它不增加CPU核数,也不自动解决数据竞争。
Q10. 关于Amdahl定律,下列正确的有?
- A.加速比上限由串行部分决定← 正确答案
- B.核数无限加速比也有限← 正确答案
- C.串行比例越大加速比越高
- D.降低串行比例能提升扩展性← 正确答案
答案:ABD。Amdahl定律指出加速比上限是1/(1-p),串行比例越小扩展性越好,A、D正确;即使核数无穷大,上限仍是1/(1-p),B正确;串行比例越大只会让上限越低,C错误。好比厨房里帮厨再多,洗菜若必须一个人干,总时间就被洗菜卡死。