从零实现读写锁:一个 TypeScript 并发控制的入门示例

时间:2026-07-09 08:30:55 来源:互联网
在多线程并发编程中,互斥锁保护共享资源虽安全可靠,却让所有操作串行排队等待,效率大打折扣。读写锁正是为优化此场景而生:多个读操作可同时并行执行,仅在写入时才需要独占资源,大幅提升系统吞吐量。

为什么需要读写锁

在并发编程实践中,最直接的方式是采用互斥锁(Mutex)来守护共享资源,任何线程访问前都必须排队等待,同一时刻仅允许一个线程进入。这种方式虽然确保了数据安全,却造成了严重的性能浪费。

从零实现读写锁:一个 TypeScript 并发控制的入门样本

以缓存系统为例,绝大多数操作属于读取缓存的查询行为,仅有少量操作为更新缓存的写入行为。读取操作之间天然不冲突——多个线程同时读取同一份数据并不会产生问题。真正需要严格互斥的场景只有一条规则:写入时禁止读取,读取时禁止写入。

读写锁(Read-Write Lock)正是专门针对此类场景设计的同步机制:

  1. 多个读取操作能够同时并发执行
  2. 写入操作必须独占执行,在其运行期间不允许任何读取或写入操作同时发生

接下来这份不足90行的ReadWriteLock实现,使用TypeScript完整演绎了上述规则,同时揭示了一个经典的公平性问题:写者饥饿(Writer Starvation)

核心状态:三个字段说清楚锁在干什么

 复制代码export class ReadWriteLock {
  private exclusiveLock = false;
  private concurrentCount = 0;
  private waitQueue: Array<() => void> = [];
  1. exclusiveLock:标识是否存在写者正在独占执行
  2. concurrentCount:记录当前正在并发读取的读者数量
  3. 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。只要这条“读者接力”不断,写者就永远无法获取锁。

这并非程序缺陷,而是读写锁朴素实现中固有的权衡:它优化了读取的吞吐量,却未对写者提供任何优先级保障。生产级实现(如Java的ReentrantReadWriteLock公平模式)通常会增加一条规则:“若已有写者在排队,后续读者也必须排队”,通过牺牲部分读取并发性来避免写者饿死。

小结

这份ReadWriteLock实现以不足90行代码完整呈现了读写锁的核心机制:

  1. 并发读取、独占写入——两套差异化的准入条件
  2. while循环重新检查条件——唤醒不等于获得锁,这是防止竞态条件的关键所在
  3. try/finally保障锁必定释放——即使业务逻辑抛出异常
  4. 公平性需要额外设计——不加干预时,持续涌入的读者将使写者无限等待

通过这份精炼的TypeScript实现,读者可以清晰理解读写锁的并发读独占写机制、while循环重检条件、异常安全释放以及公平性设计等核心原理,为在真实项目中合理运用并发控制奠定基础。