想象你开了一家餐厅,生意火爆到一个人完全忙不过来。于是你雇了一群厨师(线程)同时开工:有人切菜、有人掌勺、有人摆盘。但厨房里只有一个灶台(共享资源),大家都抢着用;一盘菜做好,传菜铃(条件变量)一响,服务员才知道去端;账房先生记账(共享数据),两个人同时写就会记错。想要让这群厨师有条不紊地协作,你需要一整套“后厨管理制度”——这就是 C++ 多线程编程。 现代 CPU 动辄 8 核 16 核,如果你的程序只会用 1 个核,就像餐厅只雇 1 个厨师,其他锅灶全部闲置。C++11 起,标准库提供了 std::thread、mutex、condition_variable、future、atomic 等一整套工具,让我们能像管理后厨一样把任务拆给多个核并行执行,真正“榨干”多核性能。本文用这个餐厅后厨的比喻贯穿始终,从招聘厨师(创建线程)到立规矩(加锁)、摇铃(条件变量)、发取餐号(异步任务),一步步学会让多核 CPU 忙起来。

岗位关联

对 AI 从业者而言,模型推理、训练数据流水线、参数服务处处需要并发编程。掌握 C++ 多线程,意味着你写的 AI 系统能真正吃满多核算力,而不是让昂贵的 GPU 干等单线程“慢慢磨”。

目录
  1. 可视化演示
  2. 线程创建管理:招聘厨师上岗
  3. 互斥锁家族:灶台准入令牌
  4. RAII锁封装:自动归位的刀具
  5. 条件变量:后厨传菜铃
  6. 异步任务:取餐号与承诺
  7. 原子操作:不粘锅
  8. 线程安全设计:后厨规章制度
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示栈的压入和弹出操作,后进入的元素先弹出(LIFO),支持自动播放和手动步进。

本节配有交互动效(进入视口后自动加载,约 3KB)。

栈 — 初始状态 空栈,等待元素压入 LIFO 栈是后进先出的线性数据结构

线程创建管理:招聘厨师上岗

后厨开张第一天,你只雇了一位厨师,切配、掌勺、煲汤全他一人干,出菜慢得顾客摔盘子。你决定加人——每招一位厨师,就是在 C++ 里创建一个线程。一句 std::thread chef(cook, "张师傅"); 就完成“签约上岗”,操作系统立刻调度一个新线程去执行 cook 函数。

但厨师上岗后,老板必须回答一个问题:是等他干完手里的活再打烊,还是让他自己干完自行下班?这就是 join() 与 detach() 的抉择。join() 像老板站在后厨门口等张师傅炒完最后一道菜才关店——主线程阻塞,直到子线程全部执行完毕。detach() 则像老板挥挥手说“你干完自己走就行”——子线程转入后台独立运行,与主线程彻底分离。一个常见的坑是:既没 join 也没 detach,std::thread 析构时会直接调用 std::terminate() 让整个程序当场崩溃,就像老板不闻不问,厨师撂挑子走人,留下一厨房烂摊子。

那多雇几个厨师一定更快吗?Amdahl 定律给出了冷水:

老板恍然大悟:真正该做的不是无限招人,而是把切菜、备料这些可并行环节拆给多个厨师,把端菜、调味这种串行环节留给自己亲自把关——线程数按核数开,瓶颈先优化串行部分。

S(N)=1(1−P)+PN\displaystyle S(N) = \frac{1}{(1 - P) + \frac{P}{N}}
加速比 S 随线程数 N 增大而提升,但永远受限于串行部分 1-P。就算雇 100 个厨师,如果 50% 的工序必须一个人完成,最快也只能快 2 倍。
  1. 加速比 S(N)用 N 个厨师并行后,总耗时缩短为原来的多少分之一
    S(N)=TserialTparallel\displaystyle S(N) = \frac{T_{\text{serial}}}{T_{\text{parallel}}}
  2. 可并行比例 P整个工序中能同时多人协作的部分占比,范围是 0 到 1
    0≤P≤10 \le P \le 1
  3. 串行瓶颈 1-P必须单干的工序占比,是加速比永远翻不过去的天花板
    lim⁡N→∞S(N)=11−P=1串行比例\displaystyle \lim_{N \to \infty} S(N) = \frac{1}{1 - P} = \frac{1}{\text{串行比例}}

互斥锁家族:灶台准入令牌

后厨热闹了,新问题来了:三个厨师抢同一个灶台,你翻勺我颠锅,菜糊成一团。解决办法是立规矩——灶台旁挂一块令牌(mutex),谁拿到令牌谁才能靠近灶台,用完必须归还。数学上,互斥锁保证了一个铁律:

C++11 的互斥锁家族针对不同后厨场景各有所长:recursive_mutex 允许同一个厨师反复拿同一把刀——同一线程重复加锁不死锁;timed_mutex 提供 try_lock_for 限时等待,等不到令牌就先干别的活,避免干耗;shared_mutex 是“菜单锁”——多位顾客可同时看菜单(共享读),但修改菜单必须独占(唯一写)。

回到后厨:老板根据场景选令牌——普通灶台用 mutex 就行,但菜单这种“读多写少”的资源,用 shared_mutex 才能让大家都看上菜单,而不是排成一排干等。

hold(M,t1)∧hold(M,t2)  ⟹  t1=t2\displaystyle \text{hold}(M, t_1) \land \text{hold}(M, t_2) \implies t_1 = t_2
任意时刻,锁 M 最多只能被一个线程持有。这就是“互斥”二字的数学含义:两个不同的线程不可能同时拿到同一把锁。
  1. hold(M, t)表示“线程 t 当前持有锁 M”这个状态的谓词
    hold(M,t)\text{hold}(M, t)
  2. 同真则同一如果 t1 和 t2 都“持有 M”,那它们只能是同一个线程
    hold(M,t1)∧hold(M,t2)  ⟹  t1=t2\text{hold}(M, t_1) \land \text{hold}(M, t_2) \implies t_1 = t_2
  3. 排队语义没拿到锁的线程阻塞在锁的等待队列里,谁先来谁先用
    t∈waitingQueue(M)  ⟹  ¬hold(M,t)t \in \text{waitingQueue}(M) \implies \neg\text{hold}(M, t)

RAII锁封装:自动归位的刀具

有了令牌规矩,新麻烦来了:厨师炒到一半油锅起火,扔下锅铲就跑(异常发生),令牌没人归还,其他厨师饿死也等不到灶台。lock_guard 就是一把“自动归位的刀具”——构造时自动加锁,析构时自动解锁,无论发生什么(哪怕抛异常),出作用域锁必定归还:

锁也会带来经典灾难——死锁。两个厨师各拿一把工具互不相让:

unique_lock 比 lock_guard 更灵活:支持手动 unlock() / lock(),像厨师可以“临时放下刀去接电话”,接完再回来拿——这正是配合条件变量所必需的能力。老板立下规矩:所有厨师必须先拿锅铲再拿菜刀,顺序统一,死局自然破解。

∃t1,t2:H(t1,la)∧W(t1,lb)∧H(t2,lb)∧W(t2,la)\displaystyle \exists t_1, t_2: H(t_1, l_a) \land W(t_1, l_b) \land H(t_2, l_b) \land W(t_2, l_a)
厨师 A 拿着锅铲等菜刀,厨师 B 拿着菜刀等锅铲,互相干瞪眼,谁也动不了。死锁的数学本质是“循环等待”。
  1. H(t, l)线程 t 已持有锁 l,相当于厨师手里攥着工具
    H(t,l)H(t, l)
  2. W(t, l)线程 t 正在等待锁 l,相当于厨师眼巴巴等别人手里的工具
    W(t,l)W(t, l)
  3. 循环等待成环t1 拿 a 等 b,t2 拿 b 等 a,四条件同时成立即成死锁
    H(t1,la)∧W(t1,lb)∧H(t2,lb)∧W(t2,la)H(t_1,l_a) \land W(t_1,l_b) \land H(t_2,l_b) \land W(t_2,l_a)
  4. 破局之道所有线程按同一全局顺序加锁,或用 std::lock 一次拿多把锁
    lock(l1)≺lock(l2)≺⋯≺lock(ln)\text{lock}(l_1) \prec \text{lock}(l_2) \prec \cdots \prec \text{lock}(l_n)
cpp4 行
{    std::lock_guard<std::mutex> guard(stove_lock);  // 进厨房:拿令牌    use_stove();                                    // 用灶台}   // 出作用域:令牌自动挂回墙上,绝不泄漏

条件变量:后厨传菜铃

厨师炒完菜,服务员怎么知道何时来端?轮询?服务员每秒跑来看一眼,浪费体力还可能记错菜。条件变量就是后厨的传菜铃——菜好了摇一下铃,服务员听到铃响才过来端:

wait 的本质可以等价为一个带着谓词检查的循环:

条件变量还有一个大坑——通知丢失:如果厨师在服务员开始等之前就摇铃,这个铃响就永远错过了。所以判断条件必须用 wait(lock, pred) 这种带谓词的版本,把“摇铃”和“条件成立”绑定成一个完整逻辑,漏铃也不怕。

wait(pred,lock)≡while(¬pred){cv.wait(lock);}\displaystyle \text{wait}(pred, lock) \equiv \text{while} (\neg pred) \{ cv.wait(lock); \}
唤醒后必须重新检查条件!因为存在“虚假唤醒”——铃没响线程却自己醒了,醒来看一眼出菜口没菜,就得继续睡。
  1. 谓词 pred一个返回真假的判断,如“出菜口有菜”
    pred:queue≠∅pred: \text{queue} \neq \emptyset
  2. while 循环检查每次被唤醒后先查条件,不满足就继续睡
    while(¬pred)\text{while} (\neg pred)
  3. 原子释放与重拿cv.wait(lock) 会先释放锁让厨师们干活,自己被唤醒后再重新抢回锁
    cv.wait(lock)⇒unlock(lock)→sleep→lock(lock)cv.wait(lock) \Rightarrow \text{unlock}(lock) \rightarrow \text{sleep} \rightarrow \text{lock}(lock)
cpp14 行
std::condition_variable cv;std::mutex m;std::queue<Dish> ready; // 服务员等菜:铃不响就睡觉std::unique_lock<std::mutex> lk(m);cv.wait(lk, []{ return !ready.empty(); }); // 厨师出菜后摇铃{    std::lock_guard<std::mutex> lk(m);    ready.push(dish);}cv.notify_one();

异步任务:取餐号与承诺

有时候老板不想干等一位厨师做完某道菜——他下完单就去管别的桌。这正是 std::async 的价值:给后厨“下个单”,拿到一张取餐号(future),菜做好凭号取:

promise 则是厨师侧的“承诺书”:厨师拿到 promise 对象,做好菜后调用 set_value 兑现承诺;future 是顾客手里的取餐号,调用 get() 时若餐没好就阻塞等待。两者的配合:

注意 std::async 的默认策略可能不创建新线程,而是推迟到 get() 时才同步执行。要想确保立刻异步,需要显式传 std::launch::async。此外 future.get() 只能调用一次——像取餐号撕掉后就不能再取第二次。

Ppro→  set_value(v)  Vshared→  get()  Tfut\displaystyle P_{\text{pro}} \xrightarrow{\;\text{set\_value}(v)\;} V_{\text{shared}} \xrightarrow{\;\text{get}()\;} T_{\text{fut}}
promise 把结果值写进共享状态,future 从共享状态把值取出来,中间完成了一次跨线程传值——像储物柜一样,一边放进去,一边取出来。
  1. promise 写入生产者线程调用 set_value(v),把结果送进共享状态
    Ppro.set_value(v)P_{\text{pro}}.\text{set\_value}(v)
  2. 共享状态传递V 是 promise 与 future 之间的“储物柜”
    VsharedV_{\text{shared}}
  3. future 读取消费者调用 get() 取出结果,未就绪则阻塞等待
    Tfut=Fut.get()T_{\text{fut}} = F_{\text{ut}}.\text{get}()
cpp3 行
std::future<int> ticket = std::async(cook_pasta, 3);  // 下单:煮3份意面do_other_work();                                       // 该干嘛干嘛int pasta = ticket.get();                              // 凭号取餐,没做好就等

原子操作:不粘锅

后厨最闹心的场面:两位厨师同时往账本上记“今日出菜数”,你写 +1,我写 +1,最后账本只多了 1——其中一笔被覆盖了。对付“单个变量的记账”,不需要上锁这么重的工具,一口“不粘锅” std::atomic 就够了:

原子操作的读改写(RMW)语义:

atomic 比 mutex 快得多,因为它通常编译成一条 CPU 原子指令(如 x86 的 lock xadd),不需要操作系统介入、没有线程上下文切换。它只适合保护单个变量;如果多个变量必须保持一致(如“余额”和“流水”同时更新),还是要用互斥锁。atomic_flag 是最简单的原子布尔量,可以用来手写自旋锁。

RMW(a):v←load(a);a←store(f(v))\displaystyle \text{RMW}(a):\quad v \leftarrow \text{load}(a);\quad a \leftarrow \text{store}(f(v))
load 和 store 之间不允许插入任何其他线程的写操作,整个“读-改-写”过程不可分割——就像不粘锅的涂层,食材根本粘不上去。
  1. load 读取从原子变量 a 读出当前值 v
    v=load(a)v = \text{load}(a)
  2. 运算 f对读到的值施加变换,比如自增
    f(v)=v+1f(v) = v + 1
  3. store 写回把新值写回变量 a
    a=store(f(v))a = \text{store}(f(v))
  4. 原子不可分割三步中间任何线程无法插入写操作,读到的永远是最新值
cpp2 行
std::atomic<int> dish_count{0};dish_count.fetch_add(1);   // 读-改-写一气呵成,谁也插不进手

线程安全设计:后厨规章制度

有了工具,还得有制度。数据竞争(data race)是后厨最大事故源:两位厨师同时切同一块肉、同时改同一页菜单,结果肉剁烂、菜单混乱,程序甚至直接崩溃。数据竞争的严格定义:

避免数据竞争的三大策略:不可变共享——把数据做成“提前切好的凉菜”,只读不写、人人可拿、永不损坏;thread_local——每个厨师的私人围裙,各用各的互不争抢;锁或原子——共享可变数据的唯一出路:要么锁住再动,要么原子化处理。后厨有了这套规章,再多的厨师也不会打架。

Race(a)  ⟺  ∃t1≠t2:  W(t1,a)∧(R(t2,a)∨W(t2,a))∧unsync(t1,t2)\displaystyle \text{Race}(a) \iff \exists t_1 \neq t_2:\; W(t_1, a) \land (R(t_2, a) \lor W(t_2, a)) \land \text{unsync}(t_1, t_2)
两个不同线程访问同一变量,其中至少一个在写,且两者间没有任何同步机制,就必然构成数据竞争——这在 C++ 里是未定义行为,编译器“合理”地做出任何疯狂的事都不奇怪。
  1. 写者出现线程 t1 对变量 a 执行写操作
    W(t1,a)W(t_1, a)
  2. 读写冲突线程 t2 对同一变量 a 执行读或写,即至少一读/写同时存在
    R(t2,a)∨W(t2,a)R(t_2, a) \lor W(t_2, a)
  3. 无同步屏障t1 和 t2 之间没有锁、原子操作、join 等任何 happens-before 关系
    unsync(t1,t2)\text{unsync}(t_1, t_2)
  4. 结论三者同时满足就产生数据竞争,运果不可预测

AI 应用场景

场景 1:LLM 推理引擎的并发请求调度

阶段:模型部署与推理优化

解决的问题:LLM 推理服务要同时响应多个用户的请求,传统单线程逐个处理,CPU 多核闲置、GPU 利用率低、首 token 延迟高。用“后厨管理”的思路:std::atomic 计数器分配请求 ID,condition_variable + 队列做请求的生产者-消费者缓冲,多个工作线程从队列取请求并行执行 prefill 和 decode。

具体案例:某 7B 模型推理服务,单线程处理约 50 token/s;改为 8 线程流水线(1 个加载线程 + 7 个计算线程)后,吞吐提升到 300+ token/s,首 token 延迟下降 60%。

场景 2:训练数据加载流水线

阶段:数据预处理与加载

解决的问题:深度学习训练时,GPU 等 CPU 加载数据是常见瓶颈。用互斥锁保护数据队列、条件变量在队列空时唤醒加载线程、队列满时让训练线程等待,实现生产者(磁盘读取 + 解码增强)与消费者(GPU 喂数据)的松耦合并行。

具体案例:CV 训练流水线中,4 个 worker 线程并行做图片解码与随机增强,通过 unique_lock + condition_variable 控制预取队列,GPU 利用率从 60% 提升到 95%。

场景 3:模型参数服务器的读写并发

阶段:分布式训练的参数同步

解决的问题:参数服务器上存着模型权重,大量 worker 要频繁读取,少量更新线程要写。用 shared_mutex 实现“读多写少”的并发策略:多个 worker 同时共享读参数,更新到达时独占写,既保证一致性又最大化吞吐。

具体案例:推荐系统 Embedding 参数服务器,读请求 QPS 达到百万级,写更新每 10 秒一批,shared_mutex 让读线程无需互相排队,整体吞吐提升约 4 倍。

Python 演示

python36 行
import threading # 共享的出菜计数器:对应后厨里那块"今日出菜数"白板counter = 0INCREMENTS = 10000          # 每位厨师要往白板上写的次数lock = threading.Lock() def chef_work(worker_id, use_lock):    """模拟一位厨师反复更新共享计数器"""    global counter    for _ in range(INCREMENTS):        if use_lock:            with lock:      # 类似 C++ lock_guard,离开 with 自动解锁                counter += 1        else:            counter += 1    # 无锁自增,可能发生数据竞争    print(f"厨师{worker_id}完工", flush=True) def run_demo(use_lock):    global counter    counter = 0    workers = [        threading.Thread(target=chef_work, args=(i, use_lock))        for i in range(4)    ]    for w in workers:        w.start()           # 相当于 std::thread 创建并启动    for w in workers:        w.join()            # 相当于 std::thread::join()    expected = 4 * INCREMENTS    mark = "正确" if counter == expected else "丢失计数,发生数据竞争"    print(f"加锁={use_lock}: 期望{expected}, 实际{counter} -> {mark}") if __name__ == "__main__":    run_demo(use_lock=False)   # 先看无锁的翻车现场    run_demo(use_lock=True)    # 再加锁对比
目的:用 Python 复现 C++ 多线程中的数据竞争问题——4 个线程对共享计数器各做 10000 次自增,对比无锁和有锁两种情况的最终结果。
  1. with lock:进入自动加锁、退出自动解锁,对应 C++ lock_guard 的 RAII 思想
  2. counter += 1无锁时这一行在底层也是“读-改-写”三步,多线程下会丢计数
  3. threading.Thread(target=chef_work, args=(i, use_lock))对应 std::thread chef(cook, i)
  4. w.start()线程创建后启动运行,类似 std::thread 构造即启动
  5. w.join()主线程等待所有 worker 完成,对应 std::thread::join()
  6. if __name__ == "__main__":程序入口,分别演示无锁和有锁两种模式

常见误区

误区我的程序慢,多开几百个线程肯定能榨干 CPU,反正核多的是。创建 500 个线程跑任务,结果比单线程还慢,一定是编译器太差了。

正解Amdahl 定律决定了加速上限——串行部分占比 1-P 是翻不过去的天花板。线程过多时,上下文切换、缓存争抢、锁竞争的开销会超过并行收益。正确做法是让线程数约等于 std::thread::hardware_concurrency()(物理核数),并优先优化串行瓶颈。

为何常见:“人多力量大”的直觉深入人心,忽略了调度开销和共享资源争抢的成本,误以为并行等于无限扩展。

误区只要每个共享变量外面都包一层锁,程序就绝对安全了。锁是万能药,包得越密越放心。

正解锁粒度过粗会把并发退化成串行——大家排队拿锁,和单线程没有本质区别;锁粒度过细又会引入死锁和复杂度。正确做法是:在不牺牲正确性的前提下,把锁作用域缩到最小,用 RAII 管理、按统一顺序加锁。

为何常见:分不清“正确性”和“性能”是两回事,以为处处设防就等于万无一失,忽略了锁本身的开销。

误区调用了 `detach()`,线程就会被删除,主线程结束它也跟着结束,内存自动回收。

正解detach() 只是解除主线程对子线程的管理关系,线程仍在后台独立运行。若 detach 的线程引用了已销毁的局部变量,或主线程提前退出,程序会崩溃或行为未定义。detach 只适合“生命周期完全自管”的后台任务。

为何常见:把“分离”误解成“删除”,忽略了线程一旦启动就拥有独立于主线程的生命周期。

误区`atomic` 又安全又快,把程序里所有共享变量都改成 `std::atomic` 就再也不用加锁了。

正解atomic 只保证单个变量的原子读改写。当多个变量需要作为一个整体保持一致(比如“余额”和“流水条数”必须同时变化),原子操作无法保证组合后的原子性,必须用互斥锁把一组操作包起来。

为何常见:只盯着“原子”二字,没有理解一致性是对一组操作而言的,不是对单个变量而言的。

误区`cv.wait(lk)` 一返回,说明数据一定准备好了,直接取用即可,不需要再检查。

正解存在“虚假唤醒”——操作系统层面线程可能无缘无故被唤醒;且多个线程被 notify_all 同时唤醒后,其他线程可能已把资源抢走。所以必须用 wait(lock, pred) 的谓词形式,或用 while (!pred) cv.wait(lock) 循环二次确认。

为何常见:把“被唤醒”当作“条件成立”的充分条件,忽略了并发环境下唤醒信号与条件状态之间的时间差。

面试题

Q1. 创建一个 std::thread 后,既不调用 join() 也不调用 detach(),会发生什么?

单选题 · easy

  • A.线程转入后台静默运行
  • B.程序终止并直接崩溃← 正确答案
  • C.线程资源自动释放
  • D.编译器直接报错拒绝

答案:B。std::thread 析构时会检查线程是否仍可 join,若还在运行且未 detach,C++ 标准规定调用 std::terminate() 终止程序。这就像老板不管不顾,厨师撂挑子留下一片狼藉。A 是 detach 的行为,C 是 join 后的结果,D 是编译期错误而非运行期。

Q2. 关于 join() 与 detach() 的行为,下列说法正确的是?

单选题 · easy

  • A.detach 后主线程无法再用 join 等待← 正确答案
  • B.join 会让子线程立刻停止执行
  • C.join 和 detach 可同时调用无影响
  • D.detach 会强制回收线程所有资源

答案:A。detach() 分离后子线程与主线程彻底解绑,线程对象不再代表可等待的线程,再调用 join() 会抛出异常。B 错,join 只是阻塞等待;C 错,两者互斥;D 错,detach 不回收任何资源,线程仍在后台运行。

Q3. 下列哪种互斥锁允许同一线程对同一把锁多次加锁而不发生死锁?

单选题 · mid

  • A.std::mutex 标准互斥锁
  • B.std::recursive_mutex 递归互斥锁← 正确答案
  • C.std::timed_mutex 限时互斥锁
  • D.std::shared_mutex 共享互斥锁

答案:B。recursive_mutex 内部维护持有者计数,同一线程可重复加锁,每加一次就要对应解锁一次。普通 mutex 同一线程第二次 lock 会死锁——像同一个厨师拿同一把刀还要再等别人送刀。timed 和 shared 都不具备递归语义。

Q4. lock_guard 与 unique_lock 的核心区别是什么?

单选题 · mid

  • A.unique_lock 支持手动解锁再上锁← 正确答案
  • B.lock_guard 支持复制给其他线程
  • C.unique_lock 构造时不会自动加锁
  • D.lock_guard 可以延迟到析构前解锁

答案:A。unique_lock 除了构造加锁、析构解锁外,还允许中途手动 unlock() 和 lock(),这是配合条件变量 wait 的必要条件。lock_guard 则只保证构造加锁、析构解锁,像自动归位的刀具,简单但不够灵活。

Q5. 条件变量的 wait 为什么必须配合 while 循环检查条件?

多选题 · mid

  • A.存在操作系统层面的虚假唤醒← 正确答案
  • B.被唤醒后条件仍可能不成立← 正确答案
  • C.通知可能在开始等待前就已发出← 正确答案
  • D.为了让 CPU 保持忙碌状态

答案:ABC。虚假唤醒让线程在无人 notify 时醒来;notify_all 唤醒多个线程后资源可能被抢光;通知先于等待发出会永久丢失。所以每次醒来都要用谓词确认。D 错,wait 的初衷是让 CPU 休眠省电,而不是空转。

Q6. 与互斥锁相比,std::atomic 的核心优势是什么?

单选题 · easy

  • A.无需加锁而开销极低← 正确答案
  • B.可以保护任意复杂的数据结构
  • C.能保证多个变量操作的原子性
  • D.能够消除所有数据竞争问题

答案:A。atomic 通常编译成单条 CPU 原子指令,没有 OS 介入和线程切换,开销远低于 mutex。但它只保护单个变量,组合多变量的一致性仍需锁,也无法覆盖复杂结构。B、C、D 都是对 atomic 能力的过度夸大。

Q7. 下列哪些做法可以有效避免死锁?

多选题 · mid

  • A.所有线程按统一的全局顺序加锁← 正确答案
  • B.使用 std::lock 一次性获取多把锁← 正确答案
  • C.尽量缩小持锁时间减少等待← 正确答案
  • D.创建更多线程分担加锁压力

答案:ABC。统一加锁顺序打破“循环等待”;std::lock 用 try 策略同时拿多把锁避免成环;缩小持锁时间降低等待窗口。D 错,线程越多竞争越激烈,反而更容易触发死锁。

Q8. shared_mutex 最适合下列哪种工作负载?

单选题 · hard

  • A.大量线程并发读、少量线程偶尔写← 正确答案
  • B.大量线程并发写、少量线程偶尔读
  • C.所有线程读写频率大致相等
  • D.所有线程都频繁读且频繁写

答案:A。shared_mutex 允许读锁共享、写锁独占,典型应用是“菜单”式资源——看菜单的顾客可以成群结队,改菜单必须一个人独占。若写多读少或读写均衡,共享锁的维护开销反而拖慢性能。

Q9. 两个线程在没有任何同步机制的情况下同时写同一个变量,会发生什么?

单选题 · hard

  • A.必然产生数据竞争,是未定义行为← 正确答案
  • B.操作系统会自动加锁保证正确
  • C.最终结果必定是其中某一次的写入
  • D.编译器会禁止这种代码通过编译

答案:A。两个线程写同一变量且无同步,满足数据竞争定义的三要素,C++ 标准将其判为未定义行为——结果可能不是任何一次写入的“合理”值,还可能引发更诡异的内存问题。系统不会自动保护,编译器也无法在运行时拦截。

Q10. 关于 std::async、future 与 promise 的关系,下列哪些说法正确?

多选题 · hard

  • A.future.get() 同一结果只能成功调用一次← 正确答案
  • B.async 可能不创建新线程而延迟执行任务← 正确答案
  • C.promise 负责在某个线程中填充结果值← 正确答案
  • D.future 可以复制多份给多个线程取结果

答案:ABC。get() 只允许调用一次,多次调用会抛异常——像取餐号撕了就没了;std::async 默认策略可能是 deferred,调用 get() 才同步执行;promise 正是生产者侧填值的工具。D 错,future 只能移动不能复制,只能有一个消费者。