锁、条件变量与并发不变量 / Locks, Condition Variables, and Invariants
1. 锁保护的是不变量
class Account {
mutable std::mutex mutex_;
int balance_ = 0; // guarded by mutex_
public:
int balance() const {
std::lock_guard lock(mutex_);
return balance_;
}
};不要只说“这一行加锁”。应列出哪些字段共同满足何种关系,所有读写都经过同一同步域。返回内部裸引用会在解锁后逃逸,破坏封装。
2. RAII 锁包装
| 类型 | 特点 |
|---|---|
lock_guard | 简单作用域持锁 |
unique_lock | 可延迟、解锁、移动,条件变量需要 |
scoped_lock | C++17 同时锁多个 mutex |
shared_lock | shared_mutex 的共享所有权 |
优先最简单的类型。adopt_lock 表示调用方已经持锁,误用会解锁未持有的 mutex;defer_lock 只构造所有权对象,不立即加锁。
3. Happens-before
某线程对 mutex unlock() synchronizes-with 之后成功获得同一 mutex 的 lock()。因此临界区内普通数据无需再声明为 atomic。
Thread A: lock -> 写共享状态 -> unlock
|
+-- synchronizes-with
Thread B: lock -> 读共享状态 -> unlock4. 多锁与死锁
void transfer(Account& a, Account& b, int amount) {
if (&a == &b) return;
std::scoped_lock lock(a.mutex_, b.mutex_);
// 同时维护两个账户不变量
}死锁需要循环等待。防止策略:固定全局锁顺序、std::lock/scoped_lock、减少嵌套、持锁时不调用未知回调或阻塞 I/O。超时只帮助恢复/观测,不能证明无死锁。
Lock A -> Lock B -> Lock C
^ |
+-----------------+ 出现环即有死锁可能递归 mutex 能让同线程重复加锁,但常掩盖职责不清和不变量分层问题;应先重构内部“已持锁”私有函数。
5. 临界区边界
典型模式是锁内提取任务,锁外执行:
Task task;
{
std::lock_guard lock(mutex);
task = std::move(queue.front());
queue.pop();
}
task(); // 用户代码不在锁内运行这样减少争用并避免用户回调重入/锁反转。复制快照后解锁也常比长时间持有读锁更稳健。
6. shared_mutex
共享锁允许多个读者,独占锁保护写者。它只在读比例高、临界区足够重且实现公平性适合时可能获益;小临界区中额外状态和缓存争用可能比普通 mutex 更差。
标准不统一保证读写公平,可能有写者或读者饥饿。升级共享锁到独占锁不是原子操作,解锁再加写锁期间必须重新验证条件。
7. 条件变量的三件套
std::unique_lock lock(mutex);
cv.wait(lock, [&] { return closed || !queue.empty(); });- mutex 保护共享状态;
- predicate 表达何时可继续;
- notification 只是提示“状态可能变化”。
wait 原子地释放 mutex 并阻塞,唤醒后重新加锁。带谓词重载等价于循环检查,从而处理虚假唤醒、通知先于等待,以及其他消费者抢走任务。
检查 predicate(false) --原子解锁并睡眠--> notify
^ |
+---- 唤醒、重新加锁、再检查 -------+8. 通知时机
常见写法在锁内修改状态,解锁后 notify,减少被唤醒线程立即阻塞在同一锁上的机会:
{
std::lock_guard lock(mutex);
queue.push(std::move(value));
}
cv.notify_one();持锁通知也可以正确,某些复杂生命周期协议甚至需要它。正确性来自状态始终受 mutex 保护和等待者使用 predicate,而不是固定的 notify 位置口诀。
9. 关闭协议
void close() {
{
std::lock_guard lock(mutex_);
closed_ = true;
}
cv_.notify_all();
}等待谓词必须包含关闭状态,否则空队列上的消费者会永久睡眠。析构前应先禁止新操作、设置关闭、唤醒等待者、join 线程,最后销毁 cv/mutex/queue。
10. 超时等待
优先使用 wait_until 的固定 deadline。循环调用 wait_for(duration) 遇虚假唤醒会重新累计完整 duration,导致总等待无上限。超时返回后仍需检查 predicate,因为超时和状态变化可能竞争。
11. call_once 与 semaphore
std::call_once 配合 once_flag 做线程安全一次初始化;若函数抛异常,本次不算完成,后续调用可重试。函数内静态变量自 C++11 起也有线程安全初始化。
C++20 counting_semaphore 适合资源许可计数;latch 单次倒计数,barrier 可重复阶段同步。它们不能自动保护任意共享对象。
12. 面试速答
问:为什么 condition_variable 必须用 while/predicate?
因为可能虚假唤醒、通知无记忆,且多个等待者竞争后条件可能再次为 false。
问:notify_one 应在锁内还是锁外?
两者都可正确;通常锁内改状态、锁外通知以减少争用,但复杂生命周期应按协议证明。
问:shared_mutex 一定更快吗?
不一定,它有更复杂的状态和公平性成本;需按读写比例、临界区和尾延迟测量。
13. 自测题与答案
- 为什么返回受锁保护容器的引用危险?
- 条件变量是否保存通知次数?
- 为什么持锁调用用户 callback 容易死锁?
- 超时等待为什么推荐绝对 deadline?
<details> <summary>参考答案</summary>
- 函数返回后锁已释放,调用方可在无同步下访问或长期保存引用。
- 不保存,业务状态必须由 mutex 保护的变量记录。
- callback 可能重入当前对象、获取反向顺序的锁或阻塞。
- 虚假唤醒循环若每次重置相对时长,总等待可能不断延长。
</details>
14. mutex 保护的是状态转换,不是某一行变量
struct Queue {
std::mutex m;
std::deque<Job> jobs;
bool closed = false;
};不变量可能是“closed 后不可提交,等待者最终被唤醒”。jobs 与 closed 必须在同一同步协议中观察和修改,而不是每个字段各有一把锁。
lock
read/modify jobs + closed as one state
unlock15. 解锁与加锁建立可见关系
线程 A 在 mutex 解锁前的写入,happens-before 线程 B 随后成功锁定同一 mutex 后的读取。这同时提供排他与发布,不需要再把锁内字段改 atomic。
16. 临界区外做昂贵工作
lock -> 取出/提交最小共享状态 -> unlock -> I/O/回调/重计算在锁内调用未知用户回调会造成重入、死锁和不可控延迟。若必须保持事务一致性,可先复制快照或采用显式两阶段协议,而不是无条件缩短到破坏不变量。
17. 死锁的四个条件与工程破局
多个资源形成循环等待:
T1 owns A -> waits B
T2 owns B -> waits A统一锁顺序、std::scoped_lock 同时锁多把、避免持锁等待外部工作、拆除资源环。try_lock 只在有回退/重试策略时有意义,盲目循环会活锁。
18. condition_variable 必须等待谓词
std::unique_lock lock(m);
cv.wait(lock, [&] { return closed || !jobs.empty(); });lock -> check predicate false -> atomically unlock+sleep
producer lock -> change state -> unlock/notify
wake/spurious wake -> relock -> check predicate again通知不是事件计数,状态才是事实。先通知后等待也不会丢失,只要谓词状态持久化并在同一 mutex 下访问。
19. 关闭要唤醒所有等待者
Running --close under lock--> closed=true
|
notify_all
|
workers wake: jobs empty && closed -> exit只设置标志不通知,会让无新任务的线程永久睡眠;销毁 cv/mutex 前必须 join 所有等待线程。
20. 超时使用 deadline
循环使用相对 wait_for 遇到伪唤醒会重置完整时长,累计超过预算。先计算单调时钟 deadline,再 wait_until,每次醒来检查谓词与剩余时间。
21. shared_mutex 不保证更快
读多写少且临界区足够大时可能获益;实现的公平性、写者饥饿、额外状态和缓存争用都可能让普通 mutex 更好。升级锁不是标准原子操作,释放 shared 再取 exclusive 期间状态可能变化。
22. 连续追问与自测
问:为什么 wait 需要 unique_lock? 答:wait 要原子地释放 mutex 并睡眠,唤醒后再重新持锁;unique_lock 支持这种可解锁状态。
问:notify 应在锁内还是锁外? 答:状态修改必须在锁内;通知可按协议在解锁前后,常解锁后减少被唤醒者立刻竞争,但正确性由谓词保证。
- 伪唤醒怎么处理? 答:始终在循环/谓词重载中重新检查状态。
- 两把锁如何避免顺序死锁? 答:统一全局顺序或用 scoped_lock/std::lock 协调获取。
- mutex 保护哪个对象? 答:保护一组共享状态及其不变量,不只是某个字段。
9. 真实问题:余额检查和扣款被拆开
线程 A 读取余额,线程 B 同时扣款。 A 通过检查后,余额已经改变。 如果检查和修改不在同一临界区,账户可能透支。 锁保护的是“检查后修改”的整体不变量。 把锁只放在 getter 上不能保护复合操作。 接口应暴露原子业务动作,而不是让调用者拼装步骤。 临界区过大会降低并发度。 临界区过小则不保证状态一致。 先写出不变量,再决定锁的范围。 锁的类型表达访问意图,也影响调试和性能。
10. 最小实验:谓词必须记录状态
std::mutex mutex;
std::condition_variable cv;
bool ready = false;
void wait_for_data() {
std::unique_lock lock(mutex);
cv.wait(lock, [] { return ready; });
}
void publish() {
{ std::lock_guard lock(mutex); ready = true; }
cv.notify_one();
}通知本身不保存事件。 等待线程可能在通知前尚未睡眠。 因此必须在互斥保护下记录 ready。 wait 返回后仍要重新检查谓词。 伪唤醒是标准允许行为。 把 lambda 改成无条件 wait 会产生丢通知和错误唤醒。 使用超时版本时也必须检查谓词和停止状态。
11. 多锁和死锁诊断
死锁通常由环形等待产生。 线程 A 持有锁 1 等锁 2。 线程 B 持有锁 2 等锁 1。 统一锁顺序可以破坏环。 std::scoped_lock 使用标准算法协调多锁获取。 跨对象转账接口应让所有调用方遵循同一顺序。 不要在持锁期间调用未知回调。 回调可能重入当前对象或反向获取其他锁。 锁层级注释可以帮助代码审查。 调试器线程栈和锁等待图能定位实际环路。
12. shared_mutex 的边界
读多写少时 shared_mutex 允许多个共享持有者。 写者仍需独占访问。 升级读锁到写锁不是原子操作。 先释放读锁再获取写锁会产生检查竞态。 需要升级语义时使用专门协议或直接独占锁。 读锁数量过多会让写者长期饥饿。 不同实现的公平策略不同。 不要仅凭类型名称推断吞吐收益。 测量读写比例、临界区长度和尾延迟。
13. 工程实践:锁的所有权表达
优先 lock_guard 表达整个作用域持锁。 需要条件变量或分段解锁时使用 unique_lock。 使用 adopt_lock 前必须证明锁已被当前线程持有。 避免手写 unlock/lock 分支。 锁保护的数据成员旁边写 guarded-by 注释。 公共函数说明是否允许在持锁状态调用。 析构函数不要等待一个可能回调自身的线程。 日志和指标最好在解锁后生成。 异常路径必须自动释放锁。 让锁对象与被保护对象生命周期一致。
14. 失败场景复盘
失败一:notify 没有谓词,消费者永久睡眠。 修复:在锁内记录状态并使用谓词 wait。 失败二:持锁执行网络请求,吞吐骤降。 修复:锁内只取快照,锁外执行 I/O。 失败三:两个 API 使用相反锁顺序,偶发死锁。 修复:统一顺序并用 scoped_lock。 失败四:shared_mutex 写者饥饿。 修复:测量公平性,必要时改用 mutex 或分层队列。 失败五:条件变量超时后直接继续处理。 修复:重新检查队列、关闭和错误谓词。
15. 自测题
问:通知是否代替状态?答:不是,通知只唤醒,状态由谓词保存。 问:锁越细越好吗?答:不一定,锁数量增加会扩大顺序和死锁复杂度。 问:unique_lock 为什么可移动?答:它需要支持条件变量等待和所有权转移。 问:能否在锁内调用用户回调?答:除非契约明确,否则应避免重入和锁反转。 问:读锁升级是否安全?答:普通 shared_mutex 不提供原子升级。
工程深化:把本篇知识落到项目里
这一节不是为了凑篇幅,而是把《锁、条件变量与并发不变量 / Locks, Condition Variables, and Invariants》从“知道概念”推进到“能在项目里稳定使用”。 阅读时可以把每个知识点都追问成四件事:它保护什么边界,失败时有什么现象,怎样最小复现,怎样写进团队流程。
concept -> boundary -> failure signal -> minimal proof -> project rule工程切片 1:最小可复现样例
**场景。**围绕 互斥量 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 互斥量、条件变量 还是 临界区 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 互斥量 -> 条件变量 -> 临界区 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 2:接口边界
**场景。**围绕 条件变量 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 条件变量、临界区 还是 等待谓词 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 条件变量 -> 临界区 -> 等待谓词 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 3:失败注入
**场景。**围绕 临界区 建一个很小的案例,不要一开始就放进完整业务系统。故意制造一个常见错误,让日志、断言或测试先失败,再用修复后的证据说明规则生效。
**边界。**先判断这里讨论的是 临界区、等待谓词 还是 死锁 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 临界区 -> 等待谓词 -> 死锁 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 4:跨平台差异
**场景。**围绕 等待谓词 建一个很小的案例,不要一开始就放进完整业务系统。至少比较 Windows、Linux 或不同编译器下的一个差异,记录差异属于标准、实现还是平台约定。
**边界。**先判断这里讨论的是 等待谓词、死锁 还是 锁粒度 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 等待谓词 -> 死锁 -> 锁粒度 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 5:性能观察
**场景。**围绕 死锁 建一个很小的案例,不要一开始就放进完整业务系统。用小规模和压力规模各跑一次,区分算法成本、同步成本、I/O 成本和工具链配置成本。
**边界。**先判断这里讨论的是 死锁、锁粒度 还是 互斥量 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 死锁 -> 锁粒度 -> 互斥量 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 6:生命周期
**场景。**围绕 锁粒度 建一个很小的案例,不要一开始就放进完整业务系统。标出资源创建、转移、共享、停止和销毁的顺序,特别关注错误返回和提前退出路径。
**边界。**先判断这里讨论的是 锁粒度、互斥量 还是 条件变量 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 锁粒度 -> 互斥量 -> 条件变量 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 7:诊断证据
**场景。**围绕 互斥量 建一个很小的案例,不要一开始就放进完整业务系统。保留命令、日志、符号、栈、测试输出或截图,让结论可以被另一个环境重新验证。
**边界。**先判断这里讨论的是 互斥量、条件变量 还是 临界区 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 互斥量 -> 条件变量 -> 临界区 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 8:维护策略
**场景。**围绕 条件变量 建一个很小的案例,不要一开始就放进完整业务系统。把一次性经验沉淀到 README、CI、脚本、示例工程或检查表里,避免只存在个人记忆中。
**边界。**先判断这里讨论的是 条件变量、临界区 还是 等待谓词 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 条件变量 -> 临界区 -> 等待谓词 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 9:版本演进
**场景。**围绕 临界区 建一个很小的案例,不要一开始就放进完整业务系统。为未来变更预留兼容策略:版本号、特性位、弃用窗口、迁移脚本和回滚入口。
**边界。**先判断这里讨论的是 临界区、等待谓词 还是 死锁 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 临界区 -> 等待谓词 -> 死锁 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 10:最小消费者
**场景。**围绕 等待谓词 建一个很小的案例,不要一开始就放进完整业务系统。从使用者角度写一个小程序或小工程,只依赖公开接口,验证安装包、头文件和运行时行为。
**边界。**先判断这里讨论的是 等待谓词、死锁 还是 锁粒度 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 等待谓词 -> 死锁 -> 锁粒度 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 11:边界复盘
**场景。**围绕 死锁 建一个很小的案例,不要一开始就放进完整业务系统。用一张表说明问题发生在源码、编译、链接、加载、运行、关闭或发布中的哪一层。
**边界。**先判断这里讨论的是 死锁、锁粒度 还是 互斥量 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 死锁 -> 锁粒度 -> 互斥量 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
本篇收束
掌握《锁、条件变量与并发不变量 / Locks, Condition Variables, and Invariants》的标志,不是记住所有名词,而是能把 互斥量、条件变量、临界区、等待谓词、死锁、锁粒度 放进一条可验证的工程链路。 先用最小样例建立判断,再用测试和脚本固定判断,最后把失败证据留给未来的自己和团队。