线程同步
线程同步协调共享状态访问与执行顺序。本文负责互斥锁、条件变量、信号量和自旋等待。
1. 面试核心
先记住这几条,再读正文2. 临界区与共享不变量
余额初始为 100。两个线程各加 10,若都先读到 100,就都写回 110。最终丢失一次更新。
应把读取、计算和写回放在同一临界区。单个计数器也可使用合适的原子读改写操作。多变量约束则需保证整组操作一起成立。
转账同时修改两个账户。观察者也要遵守同一同步规则,避免读到中间状态。仅保护写入而放任读者访问,会留下数据竞争。
3. 条件变量
以下是条件变量的伪代码:
lock(mutex)
while queue is empty:
wait(notEmpty, mutex)
item = queue.removeFirst()
unlock(mutex)
wait 原子地释放 mutex 并进入等待。返回前,它重新获取 mutex。生产者在相同互斥规则下入队,再通知等待者。
消费者用 while 检查队列。虚假唤醒会使线程在没有新元素时返回。其他消费者也能在当前线程获锁前取走元素。因此唤醒后要重新检查谓词。
存在等待者时,signal 至少唤醒一个。broadcast 则唤醒全部等待者。被唤醒线程仍需竞争 mutex。通知时没有等待者,条件变量不会保存这次通知。
4. 信号量与许可
信号量保存非负许可计数。sem_wait 获取一份许可,计数为 0 时等待。sem_post 归还许可,也可由另一线程执行。
例如并发任务上限为 8,可用初值为 8 的信号量。任务取得许可后进入,退出时归还许可。计数限制容量,业务对象仍需自己的并发保护。
互斥锁具有持有者语义,应由持有线程解锁。信号量表达资源数量或通知计数。二者应按共享状态的要求选择。
5. 自旋与阻塞等待
自旋线程持续检查锁状态。持有时间短且持有者正在运行时,自旋能减少调度成本。临界区较长或持有者被抢占时,自旋会空耗 CPU。
阻塞等待让线程休眠,并在可用时唤醒。它节省等待期间的 CPU,但增加调度开销。读多写少且临界区足够长时,还可评估读写锁。读写锁的公平性策略会影响写者等待。
6. 使用边界
同步既协调执行,也要满足数据可见性规则。C/C++ volatile 本身不提供线程同步。具体语言需使用对应的锁与原子机制。
持锁执行慢网络调用会扩大临界区等待。锁顺序和失败释放策略见死锁。
7 · 面试题卡片
本页题目与参考回答
7.1 线程间有哪些同步方式,互斥锁和条件变量分别解决什么问题?
考察重点:互斥访问、条件等待与许可数量的区别,以及唤醒后的重新检查。
参考回答
互斥锁让临界区互斥执行,并建立相应的可见性。
条件变量让线程释放锁并等待条件变化。唤醒后重新持锁检查谓词。信号量保存许可计数,适合控制有限资源。
7.2 条件变量的通知为什么不能当作信号量许可使用?
考察重点:谓词、丢失通知和许可计数。
参考回答
条件变量只通知等待者检查共享状态。
通知时没有等待者,通知不会保留下来。信号量则保存许可计数,后续 wait 可以消耗它。条件变量必须配合互斥锁和谓词循环。