编程面试:实现一个线程安全的读写锁
题干与适用场景
请实现一个线程安全的读写锁:多个读者可以并发持有,写者必须独占;要求说明等待策略、释放后的唤醒规则、是否支持重入,以及如何避免写者长期饥饿。可以使用一个互斥量和条件变量,但不能直接调用现成的读写锁。
这道题适合后端、基础设施和并发编程岗位。它考察的是同步原语的正确性与取舍,不依赖某一种语言。
面试官考察点
- 能否先定义状态不变量:活跃写者最多一个,写者存在时活跃读者为零。
- 能否把“公平”变成可执行的排队规则,而不是口头承诺。
- 能否处理虚假唤醒、异常路径、递归加锁和读转写死锁。
- 能否给出复杂度、测试场景和在生产环境复用标准库的边界。
回答前需要澄清的问题
先确认四件事:是否需要可中断或超时获取;是否需要同一线程重入;是否允许读锁升级为写锁;公平性是严格 FIFO 还是只保证等待中的写者最终获得机会。若题目没有指定,建议实现“写者优先、不可重入、不可升级”的最小版本,并明确这是接口边界。
30 秒回答框架
先说状态:activeReaders、activeWriter、waitingWriters。读锁在没有写者且没有等待写者时进入;写锁在没有读者和写者时进入。所有状态都在同一互斥量下修改,释放时按策略唤醒写者或一批读者。最后补充:等待使用 while 循环复查条件,读转写需要显式升级协议或直接禁止。
分步骤深入解答
状态与不变量
activeWriter 是布尔值,activeReaders 是非负计数,waitingWriters 记录排队写者。核心不变量是:activeWriter == true 时 activeReaders == 0;写者进入前两者都必须为空。等待计数只用于策略,不代表已经持锁。
获取与释放规则
读者等待 !activeWriter && waitingWriters == 0,写者等待 !activeWriter && activeReaders == 0。每次条件变量返回都重新检查条件,避免虚假唤醒。写者释放后优先唤醒一个写者;没有写者等待时再广播给读者。读者最后一个释放时唤醒写者。
~~~text readLock(): mutex.lock() while activeWriter or waitingWriters > 0: readersCondition.wait(mutex) activeReaders += 1 mutex.unlock()
writeLock(): mutex.lock() waitingWriters += 1 while activeWriter or activeReaders > 0: writersCondition.wait(mutex) waitingWriters -= 1 activeWriter = true mutex.unlock()
writeUnlock(): mutex.lock() activeWriter = false if waitingWriters > 0: writersCondition.signal() else: readersCondition.broadcast() mutex.unlock() ~~~
公平性与吞吐量
| 策略 | 读锁条件 | 优点 | 风险 |
|---|---|---|---|
| 写者优先 | 没有活跃写者且 waitingWriters == 0 | 限制写者饥饿 | 写者持续到达时读者延迟上升 |
| 读者优先 | 没有活跃写者 | 读吞吐量高 | 写者可能饥饿 |
| 近似 FIFO | 按等待队列顺序放行 | 延迟更可预测 | 状态和队列更复杂 |
Oracle 文档指出,非公平模式可能长期推迟某一类线程,公平模式按近似到达顺序竞争,但吞吐量通常更低。面试中应把“避免饥饿”和“严格 FIFO”区分开。
高质量示范回答
我会先实现不可重入、写者优先的版本。所有计数由一个互斥量保护,读者只有在没有写者和排队写者时增加计数,写者必须等到两个计数都为空。条件变量返回后用 while 重新检查。释放时若有写者就 signal 一个写者,否则广播读者。这样能保持互斥不变量,并让持续到达的读请求不能无限插队。
我会明确不支持读转写升级,因为一个持读锁的线程等待写锁会阻塞其他读者释放,容易形成死锁;升级应由调用方先释放读锁再重新竞争,或另设计带队列的升级协议。若需要中断、超时、重入或严格公平,我会直接复用平台标准库并测试其文档定义,而不会在业务代码里复制一套不完整实现。
常见错误
- 用
if代替while,虚假唤醒后会绕过保护条件。 - 只判断活跃读者,不统计等待写者,导致写者饥饿。
- 写者释放时只唤醒一个读者,造成不必要的读并发损失;或无条件广播,造成惊群。
- 允许读锁升级却没有升级队列,两个读者会互相等待。
- 把
tryLock当作公平保证。Oracle 文档明确说明非阻塞tryLock可以插队。
追问及应对
如何验证不会破坏不变量?
在测试中维护一个原子快照:进入写临界区时断言读者数为零,进入读临界区时断言没有写者;随机生成读写线程并持续运行,失败时记录线程事件序列。
如何测试写者饥饿?
持续产生读请求,同时让一个写者等待;记录写者从排队到获得锁的时间和最大等待次数。测试目标是最终获得机会,而不是承诺固定毫秒数。
为什么不用一个普通互斥锁?
普通互斥锁实现更简单、延迟更稳定;读写锁只有在读操作占绝大多数且读临界区足够长时才可能提高并发。应以基准测试而非直觉决定。
能否支持重入?
需要记录写者线程标识和持有次数,并为每个线程记录读持有次数;这会改变升级和释放规则。若题目未要求,明确禁止重入可降低状态空间。
POSIX 接口给出什么提醒?
POSIX 将读锁和写锁获取拆成独立接口,并规定失败返回值;实现时仍须遵守平台对优先级和递归行为的限制,不能把自定义策略误称为标准保证。
什么时候应该停止手写?
当需求涉及中断、超时、诊断、重入或跨平台语义时,优先使用 Java ReentrantReadWriteLock、POSIX pthreadrwlock* 等经过验证的实现,并把公平性选项写进评审记录。