CHAPTER 25 / Linux 与并发系统
线程、互斥锁与死锁
两个线程各做一千次加法,为什么结果与安全性都不能只靠一次运行判断?
这一章要弄清楚
- 以 happens-before 解释共享数据安全
- 把业务不变量放在同一临界区
- 识别死锁环并选择一致的加锁方案
先备知识:真实资源与RAII:独占拥有 / 进程、系统调用与文件描述符
C++20 / macOS 与 Linux;本章的硬件模型只推演逻辑,不代表设备性能。
先写不变量,再选择锁
考虑两个账户 A=100、B=100。一次转账应保持总金额为 200,而且余额不能在检查之后被别人抢先改掉。这两条性质叫不变量:每次对外可见的操作完成时都应成立。给单独的读或写上锁还不够;“检查余额、扣减 A、增加 B”必须作为一个整体受到保护。互斥锁保护的是一组数据及其访问协议,并不是给一个变量贴上永远安全的标签。
线程(thread)是同一进程内的执行流。线程一般共享地址空间,各自拥有寄存器状态和调用栈。counter++ 在 C++ 语义中是读、计算、写组成的操作;两个线程未经同步同时修改普通整数,产生 data race,程序行为未定义。课堂上的“都读到零,最后只写出一”可以解释丢更新的直觉,却不是标准保证的唯一结果。不能用“我跑了一百万次都正确”证明没有竞争。
thread对象现在代表什么?(两段补充合计另估30分钟)
包含<thread>后,std::thread worker;建立一个空线程对象,不启动工作。给构造函数传入已学的可调用对象,例如std::thread worker(task);,成功构造便启动执行task的线程,不需要再调用start。构造线程与工作线程之后可以交错,不能预测谁先打印。原例中的std::array<std::thread,4>先得到四个空对象;t=std::thread(task)将新启动线程交给当前空对象t管理,临时对象不再拥有它。thread构造合同
joinable()返回这个对象是否仍关联一个需要join或detach处理的线程,不是在问工作是否还在跑。本章只用join,不使用detach:
| 本题中的状态 | joinable结果 | 接下来怎样处理 |
|---|---|---|
| 默认构造,尚未接管线程 | false | 守卫跳过,不调用join |
| 成功接管,尚未join,即使任务已结束 | true | join等待完成并解除关联 |
| join成功返回 | false | 守卫跳过,不重复join |
假设四个槽位只启动第一个,主线程观察到的状态是true、false、false、false;成功join第一个后变成四个false。空vector没有线程对象,清理循环执行零次。这里主线程独自管理这些句柄;joinable不是允许多个线程同时操作同一thread对象的锁。join与状态
回访stage08时:末尾构造与捕获快照
后续stage08用workers.emplace_back(lambda),把lambda作为构造参数,在vector末尾建立一个std::thread;成功追加后size加1,新线程已经启动。workers.reserve(3)只预留存储,size仍是0,也没有启动线程。与其把emplace_back理解成“存一段以后再执行的代码”,应按本元素的构造合同理解;这里元素是thread,构造会启动工作。vector末尾构造
[&,id,begin,end]的&是默认引用捕获,对函数体实际用到、需要捕获的外部局部名字按引用借用;后面三个名字是例外,各保存创建时的副本。它不是引用所有作用域变量。沿用10.4的捕获与寿命:stage08的input、partial被借用,id、begin、end各有副本。给定id=1、begin=2、end=4,创建后即使外层继续下一轮,这个闭包仍处理下标2、3并写partial[1];begin=end时内循环零次,局部和仍为0。这里只读给定区间,完整分区公式到第27章再组合。捕获默认与例外
启动失败为什么要清理已有线程,沿用本章末段;异常展开与借用对象寿命见16.4–16.5。尤其原stage08的main没有外层匹配catch,不能仅凭它定义了JoinAll,就把未捕获启动异常后的析构记成已执行事实;后续NET N3的受控观察会应用同一证据边界。本段是读法与状态推演,没有新增线程资源耗尽实验。
锁怎样建立顺序
同一 mutex 的解锁与后续成功加锁建立同步关系。进入临界区的线程不仅获得排他权,还能按 C++ 内存模型观察前一持有者在解锁前做的修改。std::lock_guard 用 RAII 管理锁,离开作用域或异常展开时自动释放。不要把 lock 与 unlock 分散在多个返回分支中,让未来维护者猜哪条路径漏了解锁。
第一个例子让四个线程分别增加一千次,每次修改都持有同一把锁。主线程在所有 join 返回后检查 4000。join 也建立完成关系:工作线程结束前的操作对成功等待它的线程可见。但 join 不能修复工作线程相互之间早已发生的 data race;必须在工作期间就同步共享写入。
死锁与争用的边界
如果线程甲先锁 A 再等 B,线程乙先锁 B 再等 A,就可能形成循环等待。解决方式之一是所有路径按稳定顺序加锁;另一方式是在 C++ 中用 std::scoped_lock 同时获取多把 mutex,其算法避免这种获取阶段死锁。它不自动解决递归调用、外部回调、条件等待或其他隐藏锁构成的更大环。
第二个例子同时向两个方向转账,用 scoped_lock 覆盖两个账户。自转账必须先短路,因为同一把非递归 mutex 不能作为两个独立锁反复获取。余额不足是一种业务结果,不是锁失败。示例采用有限金额和操作次数,避免有符号整数溢出与业务问题混在一起。
正确以后才压缩临界区
持锁做网络请求或调用未知回调会延长阻塞,甚至引入死锁。可以在锁内复制必要状态,解锁后做慢操作,再在锁内校验并提交;但这会引入“复制后状态已改变”的问题,必须用版本号或重试协议保护语义。简单地把检查挪到锁外往往破坏不变量。
测试应包括单线程、多线程、空任务、自转账、双向转账与反复运行。ThreadSanitizer 能发现本次执行覆盖到的许多竞争,但不会证明所有调度无竞争,也不是一般死锁证明器。性能上要分别量工作量和等待时间;锁很多不等于一定慢,锁少也不等于正确。面试时给出数据、锁、访问路径的对应关系,再讨论减少共享和局部累积,思路会更扎实。
创建失败也需要等待已有线程
启动第二个线程时,线程构造可能抛异常;已经启动的第一个线程即使工作做完,只要仍可 join,其 thread 对象直接析构就会终止程序。示例在启动任何线程前建立 JoinAll 守卫,异常退出也等待所有已启动线程,且守卫比被借用的 mutex 和数据先析构。每个下载程序用专门的注入异常模拟第二次创建前失败,外层捕获后检查第一份工作确实完成;这不是实际耗尽系统线程资源的实验。
受保护的两次加一
甲持有 mutex,乙尚不能访问 count。
count 变为 1,解锁发布临界区写入。
乙成功加锁后读到 1,写入 2。
阅读完整推演文字
- 甲进入
甲:已锁;count:0;乙:等待
甲持有 mutex,乙尚不能访问 count。
- 甲更新并解锁
甲:解锁;count:1;乙:将加锁
count 变为 1,解锁发布临界区写入。
- 乙更新
甲:完成;count:2;乙:持锁
乙成功加锁后读到 1,写入 2。
跟着例子,走完一遍
四个线程共享计数器
四个工作线程各执行 1000 次加一。
- 每次更新在 lock_guard 生命周期内完成。
- 主线程 join 所有工作线程。
- 用始终生效的检查验证最终结果。
#include <array>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <thread>
struct InjectedFailure {};
void increment(int& count,bool inject_failure=false) {
std::mutex mutex; std::array<std::thread,4> workers;
struct JoinAll {
std::array<std::thread,4>& threads;
~JoinAll() {for(auto& t:threads) if(t.joinable()) t.join();}
} join_all{workers};
std::size_t started=0;
for(auto& t:workers) {
// Teaching injection models failure before the second thread starts.
if(inject_failure && started==1) throw InjectedFailure{};
t=std::thread([&] {
for(int i=0;i<1000;++i) {std::lock_guard lock(mutex); ++count;}
});
++started;
}
for(auto& t:workers) t.join();
}
int main() {
int count=0; increment(count);
if(count!=4000) throw std::runtime_error("count");
int partial=0; bool caught=false;
try {increment(partial,true);} catch(const InjectedFailure&) {caught=true;}
if(!caught || partial!=1000) throw std::runtime_error("join on creation failure");
std::cout<<"count="<<count<<'\n';
}count=4000
所有冲突访问由同一 mutex 排序;join 确保检查发生在工作完成之后。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 21-a.cpp -o example && ./example预期标准输出:
count=4000
双向转账保持总额
A、B 各有 2000;两个线程分别向相反方向转 1000 次,每次 1。
- 自转账直接返回。
- scoped_lock 同时保护两个余额。
- 结束后验证 A、B 均为 2000。
#include <array>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <thread>
struct Account {std::mutex mutex; int amount=2000;};
struct InjectedFailure {};
bool transfer(Account& from,Account& to,int amount) {
if(amount<0) return false;
if(&from==&to) return true;
std::scoped_lock lock(from.mutex,to.mutex);
if(from.amount<amount) return false;
from.amount-=amount; to.amount+=amount; return true;
}
void transfer_both(Account& a,Account& b,bool inject_failure=false) {
std::array<std::thread,2> workers;
struct JoinAll {
std::array<std::thread,2>& threads;
~JoinAll() {for(auto& t:threads) if(t.joinable()) t.join();}
} join_all{workers};
workers[0]=std::thread([&]{for(int i=0;i<1000;++i) if(!transfer(a,b,1)) std::terminate();});
// Explicit injection, not a claim that the machine exhausted thread resources.
if(inject_failure) throw InjectedFailure{};
workers[1]=std::thread([&]{for(int i=0;i<1000;++i) if(!transfer(b,a,1)) std::terminate();});
for(auto& worker:workers) worker.join();
}
int main() {
Account a,b;
if(!transfer(a,a,1) || transfer(a,b,-1)) throw std::runtime_error("validation");
transfer_both(a,b);
if(a.amount!=2000 || b.amount!=2000) throw std::runtime_error("conservation");
Account partial_a,partial_b; bool caught=false;
try {transfer_both(partial_a,partial_b,true);} catch(const InjectedFailure&) {caught=true;}
if(!caught || partial_a.amount!=1000 || partial_b.amount!=3000)
throw std::runtime_error("join on creation failure");
std::cout<<"balances="<<a.amount<<','<<b.amount<<" total="<<a.amount+b.amount<<'\n';
}balances=2000,2000 total=4000
扣款和入账在同一临界区完成,两个方向使用相同的多锁协议。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 21-b.cpp -o example && ./example预期标准输出:
balances=2000,2000 total=4000
每个字段有锁,整体仍不正确
先锁 A 扣款,解锁后再锁 B 入账;另一观察者看到了总额减少的中间状态。
修正思路:在一个明确临界区中维护跨字段不变量;需要获取多把锁时统一顺序或使用 scoped_lock。
轮到你动手
先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。
练习 1
设计一个同时返回两个账户余额的 snapshot(),要求看到一致总额。
给我一点提示
- 读路径也要遵守锁协议。
- 避免先读 A 再分开读 B。
查看答案与推理
snapshot 同时持有两个账户 mutex,复制两个金额后返回副本。只给写者上锁而读取普通余额仍会发生 data race;分别读也可能获得两个不同时刻的组合。
练习 2
把计数器例子改成每个线程先局部计数,只在结束时加到共享总数。比较锁次数。
给我一点提示
- 局部变量不共享。
- 最后一次合并仍需要同步。
查看答案与推理
每个线程局部加一千次,再用一次 lock_guard 合并。锁获取从 4000 次降到 4 次,结果不变;是否更快还需基于真实工作量测量,不把锁次数直接当实测加速。
把理解说出来
先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。
data race 与一般 race condition 有什么区别?
参考回答 / English answer
data race 是未正确排序的冲突内存访问,至少一个写且至少一个非原子;C++ 中是未定义行为。业务竞态也可能出现在全部访问都加锁却把事务拆开的代码中。
A data race violates the language memory model. A logical race can still exist in individually synchronized operations that do not preserve a larger invariant.给所有线程最后 join,能修复 counter++ 竞争吗?
参考回答 / English answer
不能。join 只排序工作线程完成与主线程后续操作,不能排序工作线程之间的冲突写。
Join orders thread completion with the waiting thread. It does not synchronize conflicting updates between workers.读取余额时为什么也需要锁?
参考回答 / English answer
普通读取与并发写入同样是冲突访问。锁协议必须涵盖全部访问路径,包括日志和调试输出。
A read can race with a write. Every access path, including diagnostics, must follow the synchronization protocol.两把锁如何导致死锁?
参考回答 / English answer
甲持有 A 等 B、乙持有 B 等 A 形成环。统一获取顺序或使用适用的多锁工具,缩小嵌套锁范围。
Opposite acquisition orders can create a circular wait. A consistent order or a suitable multi-lock operation breaks that cycle.scoped_lock 是否保证整个程序无死锁?
参考回答 / English answer
只处理给定锁的获取;回调中的隐藏锁、递归、自锁、等待条件仍需整体分析。
Scoped_lock manages acquisition of the supplied locks. It cannot reason about hidden locks, callbacks, or the rest of the program.如何减少锁开销又不破坏正确性?
参考回答 / English answer
先减少共享写,局部累积后批量合并;明确最终一致性需求。以基线和争用测量验证收益。
I first reduce shared mutation, for example through local accumulation. Then I measure contention and verify that the new aggregation semantics are acceptable.继续查证
- C++ draft:Data races ↗
conflicting evaluations、happens before、data race
- OSTEP:Locks ↗
28.1 基本概念、28.3 评价锁
公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。