最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
从零构建读写锁:一个 TypeScript 并发控制的入门范例
时间:2026-07-08 09:18:46 编辑:袖梨 来源:一聚教程网
为什么需要读写锁
在并发编程里,最简单粗暴的做法是用一把互斥锁(Mutex)保护共享资源——谁想访问,谁就得排队,一次只放一个进去。这样做安全,但浪费。

设想一个缓存系统:大部分操作是"读"(查询缓存),少部分操作是"写"(更新缓存)。读操作彼此之间并不冲突——你读我也读,数据又没变,为什么要排队?真正需要互斥的,只有"写的时候不能有人读,读的时候不能有人写"这一条规则。
读写锁(Read-Write Lock)就是为这种场景设计的:
- 多个读操作可以并发执行
- 写操作必须独占——执行期间不允许任何读或写同时发生
下面这份不到 90 行的 ReadWriteLock 实现,把这套规则用 TypeScript 完整地跑了一遍,还顺带暴露出了一个经典的公平性问题:写者饥饿(Writer Starvation)。
核心状态:三个字段说清楚锁在干什么
复制代码export class ReadWriteLock {
private exclusiveLock = false;
private concurrentCount = 0;
private waitQueue: Array<() => void> = [];
exclusiveLock:是否有写者正在独占执行concurrentCount:当前有多少个读者正在并发读waitQueue:被阻塞的操作,以resolve回调的形式存起来
整个锁的逻辑,本质上就是"根据这两个布尔/计数状态,决定谁能通过、谁得等"。
读锁:计数 + 等待
复制代码private async acquireConcurrent(): Promise<void> {
while (this.exclusiveLock) {
await new Promise<void>((r) => this.waitQueue.push(r));
}
this.concurrentCount++;
}private releaseConcurrent(): void {
this.concurrentCount--;
if (this.concurrentCount === 0) this.drainQueue();
}
拿读锁的逻辑很直接:只要没有写者在独占,就把并发计数 +1,直接进去。释放的时候计数 -1,如果减到 0 了(意味着"最后一个读者走了"),就唤醒等待队列——因为这时候可能有个写者正等着这一刻。
注意这里 while (this.exclusiveLock) 用的是 while 而不是 if。这是整个实现里最容易被忽略、但又最关键的一个细节,下面单独讲。
写锁:独占,条件更严格
复制代码private async acquireExclusive(): Promise<void> {
while (this.exclusiveLock || this.concurrentCount > 0) {
await new Promise<void>((r) => this.waitQueue.push(r));
}
this.exclusiveLock = true;
}private releaseExclusive(): void {
this.exclusiveLock = false;
this.drainQueue();
}
写者的通行条件更苛刻:不仅不能有别的写者在跑(exclusiveLock),也不能有任何读者在跑(concurrentCount > 0)。两个条件占一个就得等。写完释放时直接把 exclusiveLock 置回 false,然后唤醒所有等待者——不管是读者还是下一个写者,都有机会重新竞争。
为什么必须用 while 而不是 if
这是并发控制里一个经典的坑:"被唤醒"不等于"抢到锁"。
drainQueue 唤醒等待队列时,是一次性把所有等待的 resolve 全部调用掉:
复制代码private drainQueue(): void {
const waiting = this.waitQueue.splice(0);
for (const resolve of waiting) resolve();
}
假设此刻队列里同时排着 3 个读者和 1 个写者,锁一释放,这 4 个 resolve 全被触发,它们的 await 都会恢复执行——但恢复执行不代表条件已经满足了。如果用 if 判断一次就往下走,写者和读者可能会在同一时刻都以为自己"拿到锁"了,独占性直接被打破。
用 while 的话,每个被唤醒的操作都会重新检查一遍条件:如果条件还不满足(比如写者醒来一看,还有读者在跑),就重新把自己的 resolve 塞回 waitQueue,接着等下一次唤醒。这保证了状态的最终一致性,代价是可能会有几轮"空唤醒"——这是用简单性换来的合理开销。
用 withRead / withWrite 保证异常安全
复制代码async withRead<T>(fn: () => Promise<T> | T): Promise<T> {
await this.acquireConcurrent();
try {
return await fn();
} finally {
this.releaseConcurrent();
}
}
这里的 try/finally 是必须的。如果 fn() 内部抛出异常,没有 finally 兜底的话,releaseConcurrent() 根本不会被执行,concurrentCount 就会永远多算一个——这个读锁会变成一把再也无法完全释放的"僵尸锁",后续所有写者都会被永远卡住。withWrite 同理。
调用方完全不需要手动配对 acquire/release,业务代码写起来是这样的:
复制代码export async function reader(id: string, durationMs: number) {
await lock.withRead(async () => {
// 读逻辑
});
}
特性还是缺陷?插队与写者饥饿
这份实现里注释写得很实在——它没有回避自己的局限性,反而专门设计了三个场景来暴露问题。
场景二演示了一个"插队"现象:一个写者已经在排队了,但因为此刻 exclusiveLock 还是 false(写者还没真正拿到锁,只是在等),新来的读者判断 acquireConcurrent 里的 while (this.exclusiveLock) 时,发现条件是 false,可以直接通过——完全不会检查"是否有写者正在排队"。这是这个实现刻意保留的一个特性/局限:读者的准入条件只看"是否有写者正在执行",不看"是否有写者正在等待"。
场景三把这个问题放大成了完整的"写者饥饿"演示:
复制代码reader("G0", 400);
const pendingWriter = writer("2", 300).then(() => {
writerDone = true;
});
await sleep(150);
for (let i = 1; i < 8; i++) {
reader(`G${i}`, 400);
if (i < 7) await sleep(150);
}
一个读者先占住并发锁,写者紧接着请求、发现 concurrentCount > 0 只能排队。然后后续读者每隔 150ms 接力到来、每人读 400ms——因为读者的到达间隔(150ms)小于单个读者的持续时间(400ms),读者之间首尾重叠,concurrentCount 就永远不会降到 0。只要这条"读者接力"不断,写者就永远拿不到锁。
这不是 bug,是读写锁这种朴素实现的固有权衡:它优化了读的吞吐量,但没有对写者做任何优先级保证。生产级的实现(比如 Java 的 ReentrantReadWriteLock 支持公平模式)通常会加一条规则:"如果已经有写者在排队,后续读者也必须排队",用牺牲一部分读并发性换取写者不被饿死。
小结
这份 ReadWriteLock 用不到 90 行代码,把读写锁的核心机制讲得很完整:
- 并发读、独占写——两套不同的准入条件
while循环重新检查条件——唤醒不等于拿到锁,这是避免竞态条件的关键try/finally保证锁一定被释放——即使业务逻辑抛异常- 公平性是要额外设计的——不加干预的话,源源不断的读者会让写者永远等待
源码:ReadWriteLock
相关文章
- Ubuntu PHP 并发如何处理 07-08
- 如何通过 Debian Node.js 日志进行故障预测 07-08
- 命运圣契超凡精灵弥弥 命运圣契弥弥角色强度解析与实战玩法攻略 07-08
- Debian Node.js 日志中网络问题的定位方法 07-08
- 怎么通过 Debian Node.js 日志监控应用状态 07-08
- Debian Node.js 日志文件清理方案 07-08