← Back to Blog
EN中文

符号位里的红绿灯:一种极致的轻量级读写锁实现

在构建高吞吐量的分布式系统时,我们常常面临一个经典的并发难题:读多写极少

标准的 std::shared_mutexpthread_rwlock_t 是通用的,但通用往往意味着妥协。当你的系统 99.9% 的时间都在读取数据,而只有 0.1% 的时间需要写入时,每一次获取读锁产生的开销(哪怕只是几次额外的原子操作或缓存行竞争)累积起来都是惊人的。

今天,我们要拆解一种来自某工业级分布式基础库的读写锁设计。它通过极致地利用整数的符号位(Sign Bit)和 Linux Futex 机制,将读锁的开销压缩到了极限。为了更清晰地展示其内部逻辑,我们将使用 Zig 语言复现其核心思想。

核心设计:把锁塞进一个整数

这个锁设计的灵魂在于:整个锁的状态仅由一个 32 位整数(counter)维持。

我们知道,一个有符号的 32 位整数(i32),其最高位(第 31 位)是符号位。

  • 如果该位为 0,数值为正。
  • 如果该位为 1,数值为负。

设计者巧妙地利用了这一点:

  1. 低 31 位:用于记录当前的活跃读者数量
  2. 最高位(第 31 位):作为写锁等待/持有标记(Write Flag)。

这种布局带来了一个巨大的优势:读者获取锁的快乐路径(Happy Path)只需要一条原子指令。

读者的视角:乐观也是一种策略

在绝大多数读写锁实现中,读者进入时需要检查“是否有写者正在等待”,如果有,则通常需要排队,以避免写者饥饿。

但在这种“极速”设计中,读者采用了极度乐观的策略。

pub fn acquireRead(self: *LightweightRWLock) void {
    while (true) {
        // 1. 无论如何,先加了再说!
        // 这是一个原子加法操作,返回旧值
        const prev = self.counter.fetchAdd(1, .SeqCst);
        
        // 2. 检查结果
        // 如果符号位是 0 (正数),说明没有写者。
        // 我们已经成功把读者计数 +1 了,直接持有锁!
        if (prev & WRITE_BIT == 0) return;

        // 3. 哎呀,有写者(符号位是 1)
        // 我们刚才鲁莽地 +1 了,现在必须撤销这个操作
        _ = self.counter.fetchSub(1, .SeqCst);
        
        // 4. 乖乖去内核态排队,等待写者干完活
        self.waitForWriter();
    }
}

权衡分析:回退机制 (Backoff)

这里展示了一个有趣的权衡(Trade-off):

  • 收益:在没有写者竞争时(99.9% 的情况),读者只需要做一次 fetchAdd 和一次位运算检查。这比大多数标准库的实现都要快,因为它极少涉及复杂的内存屏障或额外的状态检查。
  • 代价:如果碰巧有写者(Write Bit 为 1),读者的那次 fetchAdd 就成了“误操作”。读者必须执行 fetchSub 把计数减回去。这不仅浪费了 CPU 周期,还可能导致缓存行颠簸(Cache Line Bouncing)。

这是一种典型的赌博式设计:赌“写操作”极少发生。一旦赌赢了,吞吐量极高;输了,代价虽有但可控。

写者的视角:霸道的符号位

写者的逻辑则更加“霸道”。当写者想要获取锁时,它不关心当前有多少读者,它关心的第一件事是:立起“禁止入内”的牌子。

pub fn acquireWrite(self: *LightweightRWLock) void {
    while (true) {
        // 1. 尝试原子地设置最高位 (Write Bit)
        // fetchOr 会将指定位设为 1,并返回旧值
        const prev = self.counter.fetchOr(WRITE_BIT, .SeqCst);
        
        // 2. 检查之前是不是已经有别的写者了?
        if (prev & WRITE_BIT != 0) {
            // 已经有同行在排队,那我去睡会儿
            self.waitForWriter();
            continue; // 醒来后重试
        }

        // 3. 成功抢到了符号位!
        // 现在新的读者进不来了(因为他们看到符号位是 1 会回退)。
        // 但是!旧的读者可能还没读完。
        
        // 4. 等待现存的读者清零
        // 只要低 31 位不是 0,我就死等
        while (self.counter.load(.SeqCst) & ~WRITE_BIT != 0) {
            self.waitForReaders();
        }
        
        // 所有读者都走了,写锁正式归我
        return;
    }
}

写者利用 fetchOr 原子地占据了符号位。这一步瞬间切断了后续读者的流量。然后,写者需要耐心地等待当前的读者计数归零。

内核态的协作:Futex

虽然原子操作很快,但如果锁被占用的时间稍长,忙轮询(Spinning)会极大地浪费 CPU。这就是 Linux Futex (Fast Userspace Mutex) 发挥作用的地方。

在上面的 Zig 代码中,waitForWriterwaitForReaders 在真实实现中会封装 syscall(SYS_futex, ...)

  • 读者等待:当读者发现符号位为 1 时,它调用 futex_wait 挂起自己,直到写者释放锁(修改符号位并调用 futex_wake)。
  • 写者等待:当写者发现还有读者(计数不为 0)时,它同样进入睡眠,等待最后一个离开的读者唤醒它。

这种用户态原子操作 + 内核态挂起的组合,是现代高性能锁的基石。

总结

这个设计给我们的启示是:没有完美的锁,只有最适合场景的锁。

  1. 极度优化热点路径:将最频繁的操作(读)简化为单一原子指令。
  2. 利用整数特性:符号位不仅仅是数学概念,在并发控制中可以是高效的状态标志。
  3. 接受回退:为了追求极致的快乐路径性能,允许在冷门路径(遇到写者)上付出回退(Rollback)的代价。

在 Zig 这样能精准控制内存布局和系统调用的语言中,重构并理解这样的底层原语,是通往系统编程高阶殿堂的必经之路。