Table of Contents

互斥、回收与原子更新使用指南(SpinRWLock / FairGate / LightEpoch / Atomic128)

给谁看:需要并发协调原语的组件开发者(读写互斥 / 重试公平 / 延迟回收 / 大载荷原子更新)。 回答什么:四种原语怎么选、怎么用、什么绝对不要做。 核心命题:什么用锁、什么用 epoch、什么用 CAS——三者各管一摊;不该进 epoch 的绝不塞 epoch drain。 完整积木全景见 ../COORDINATION.md


0. 怎么选(一句话决策)

需求 用哪个 一句话
读侧零等待的状态查询(读远多于写、数据可整体替换) COW 快照发布 写侧 new 不可变对象 + volatile 引用发布;读者一次引用读 + 拷贝
单写者多读者的几个标量(读者可保守回退慢路径) seqlock 版本奇偶 + 双读复验
对象的读写互斥(读共享 vs 写/销毁排他,写不饿死) SpinRWLock 给对象挂一个 SpinRWLock 字段:读 AcquireShared,写/销毁 AcquireExclusive(写偏向:等待写者挡新读者)
重试循环获取资源的到达顺序公平(多写者竞争同资源不插队) FairGate fast path 查 HasWaiters 让位,慢路径 TryAcquireSlow,资源可获取后 Wake()
对象/内存的延迟回收(RCU:等所有读者退出再回收) LightEpoch drain action 只做轻量回收(free 指针、归还 buffer)
>64 位载荷的原子更新(指针+标志 / 水位+ABA) Atomic128<T> 16B 对齐背板 + CAS,Interlocked 做不到的 16B 原子

硬性要求:各管一摊、不可同需求重复上两套(见 §5 反模式"双轨制")。

决策树

读多写少,读侧要绝对零等待?
  ├─ 数据可整体替换、小或中等 → COW 快照(原生内存回收配 LightEpoch)
  ├─ 单写者 + 几个标量 + 读者可回退 → seqlock
  └─ 读者持有期间要屏蔽结构变更 + 事后延迟回收 → LightEpoch(保护期极短,禁 await)
真需要"读者互斥"(共享临界区内做 IO / 长计算)?
  → SpinRWLock:读 AcquireShared(可跨 await 长持),写/销毁 AcquireExclusive(短、纯内存)
     写要"拿到就赚拿不到走人" → TryAcquireExclusive(投机,不挡读者)
重试循环抢同一资源,怕后来者插队饿死先到者?
  → FairGate:快路径查 HasWaiters 让位 + TryAcquireSlow + 资源可获取后 Wake
结构性长临界区 / 持锁跨越 IO 与 await 的排他段 / 低频控制路径?
  → Monitor(lock)——别用自旋锁扛长临界区
>64 位载荷原子更新 → Atomic128<T>

实测参照(i5-12400,2026-08-20):读侧成本 COW 0.7ns / seqlock 0.6ns / LightEpoch 9.6ns / SpinRWLock 共享 16.3ns;SpinRWLock 写落地均值 0.1µs(1-11 读者锤击不恶化);LightEpoch 读并发 ×4.7@6T。完整数字见 perf/core-primitives-perf.md §7。


1. SpinRWLock —— 写偏向 RW 自旋锁

机制:单个 64-bit 原子整数 CAS 自旋 + SpinWait 让步。写偏向 = 写者进门先置 pending 挡住 新读者,再等在途读者退出——写者最多等"在途读者的临界区",不被持续读者流饿死;读者 fast path 仍是一次 CAS。

用法

// scope 工厂(常规临界区优先用——using 自动释放,防手忘 Release)
using var s = rwLock.EnterShared();      // 读(可跨 await 长持)
using var x = rwLock.EnterExclusive();   // 写/销毁(短、纯内存、禁 await)

// 手动(释放时机需显式控制的长临界区,如读计划锁跨 IO)
rwLock.AcquireShared();  /* ... */  rwLock.ReleaseShared();
rwLock.AcquireExclusive();  /* ... */  rwLock.ReleaseExclusive();

// 投机(不自旋)
if (rwLock.TryAcquireExclusive()) { /* 拿到就赚 */ }   // 失败不挂 pending 闸(不挡读者)

硬性要求(违反 = 死锁)

  1. 绝对不可重入——同线程禁止重复 AcquireShared/AcquireExclusive
  2. 禁止锁升级——持共享锁的线程直接升级排他 = 两线程同时升级互相等。先 ReleaseSharedAcquireExclusive,或一开始就持排他。
  3. 排他临界区极短——禁 IO、内存分配、await(线程关联,Debug 释放线程校验会抓); 共享临界区可长(跨 IO 持共享是设计内用法),但越长写者等越久——能锁外做的移到锁外。

排他锁天然 = "等所有共享读完成" = 安全点。需要"等读完成再销毁/重做"的,持 AcquireExclusive 直接做,不要绕去用 epoch。


2. FairGate —— 重试获取资源的到达顺序公平门

SpinRWLock 解决"读写互斥谁进门";FairGate 解决"重试循环抢资源谁插队"——资源占用/释放属 调用方,门只管两件事:让后来者不插队唤醒并让渡先手

协议(三方角色,缺一不可)

角色 调用 语义
获取方 fast path HasWaiters 有等待者时不走快路径——否则零间隙复占者永远插队
获取方 slow path TryAcquireSlow(tryAcquire) 登记等待者 → 门锁内执行占用尝试 → 成功 true / 失败 park 等唤醒后 false,回到协议循环
释放方 Wake() 资源已变为可获取后再调(唤醒过早 = 被唤醒者双检必败):PulseAll + 让渡先手

约束tryAcquire 在门锁内执行——必须纯内存、无异常、快速返回,不得获取与本门逆序的锁。 现使用方:SegmentTable._extentGate(AcquireExtent 区间占用公平性)。


3. LightEpoch —— epoch RCU 延迟回收

粒度:线程级"我正在读"标记(不是对象锁)。适合:对象/内存延迟回收——等所有读者退出 epoch 再回收(RCU 语义)。仅上层结构组件用(index 节点回收、metadata 版本回收)。

⚠️ 命名反直觉(对齐 FASTER 术语):Resume = 进入保护区Suspend = 退出保护区—— 不是"恢复/挂起线程"。

机制(一分钟)

三块拼图:全局单调 epoch 计数器 + 每线程 entry 表(记录本线程进入保护区时的 epoch)+ drain 列表 ((epoch, action) 对)。读者 Resume 登记 → 读共享对象 → Suspend 清除;写者物理摘除旧对象 → BumpCurrentEpoch(() => 旧对象.Dispose()) → 待所有 entry 的 epoch 越过旧值 → action 被"恰好此刻 触发 drain 检查的线程"顺手内联执行。drain 是协作式、无后台线程

标准用法(自包含骨架)

sealed class DeferredReclaimer
{
    private readonly LightEpoch _epoch = new();          // 组件级单实例,所有线程共用
    private Node? _head;

    public Node? Peek()                                  // 读侧:保护期内旧节点保证存活
    {
        _epoch.Resume();
        try { return _head; }                            // 不可 await!(§3.3)
        finally { _epoch.Suspend(); }                    // 同线程配对退出
    }

    public void Replace(Node newNode)                    // 写侧:摘除旧对象 + 延迟回收
    {
        _epoch.Resume();                                 // ★ bump 调用线程自己也必须在保护区
        try
        {
            var old = Interlocked.Exchange(ref _head, newNode);
            _epoch.BumpCurrentEpoch(() => old.Dispose());
        }
        finally { _epoch.Suspend(); }
    }

    public void Dispose() => _epoch.Dispose();
}

要点:一个协作域一个 LightEpoch(组件 own,随组件 Dispose——不是全局单例);保护期极短 (Resume→读→Suspend 之间只做指针读写级的事);回收时机/执行线程不确定——action 只依赖 "旧 epoch 已静默"这一事实。

什么该 / 不该给 epoch(最易错)

  • 轻量回收:free 指针、归还 buffer、丢引用——非阻塞、纳秒~微秒级。
  • 阻塞 IOPunchHolefsync、段提升)——drain 是协作式,action 由任意线程(可能是读线程) 顺手执行,塞毫秒级 IO = IO 落在读热路径。该 IO 的投递 BackgroundWorkerLoop 或持锁直接做。
  • "同步等 drain 完成"的逻辑——和异步协作本性冲突,死结。
  • 用 epoch drain 给销毁排序——销毁排序持段排他锁直接做(§1)。

一句话:epoch drain 只回收、不 IO、不排序。

临界区负面清单(Resume→Suspend 之间绝对禁止)

阻塞操作(IO/锁等待/Sleep);await/让出线程(Resume/Suspend 必须同线程配对);调用 BumpCurrentEpoch 或触发 drain 的逻辑(重入死锁——写侧 bump 在自己保护区内调一次合法,drain action 内部再 bump 不行);大量托管分配(触发 GC 拉长临界区)。


4. Atomic128<T> —— 128 位 CAS

解决 >64 位载荷的原子更新("指针+标志""水位+ABA version"无法用 Interlocked)——x86-64 lock cmpxchg16b / ARM64 ldaxp-stlxp

优先用 Atomic128<T> 封装(16B 对齐背板 + 探测降级 + 裸读不撕裂已内置);热路径用 Unsafe 快路径(TryCompareExchangeUnsafe/ReadUnsafe,~11ns vs safe ~16ns——调用方须保证 native CAS 可用且未 Dispose)。

硬约束

  • location 必须 16 字节对齐(不对齐 → 硬件异常)——用 AlignedMemoryManager 分配;
  • 每水位一个独立 64B 块(Intel Spatial Prefetcher 会绑定相邻缓存行,合并 128B 实测掉吞吐);
  • 能力探测失败 → 降级 lock(生产永不触发,但必须提供)。

标准范式(双尾水位 CAS):载荷设计(LogicalAddress 16B,Extension 兼当 ABA version)→ 对齐分配 → 零拷贝 reinterpret(Unsafe.As<LogicalAddress, Int128>)→ CAS 循环(裸读 → 条件检查 → 算新值 version+1 → CAS → 失败 SpinWait 重试)→ ABA 防护(回退时 Extension+1)。


5. 反模式(禁止重蹈)

  1. 把阻塞 IO 塞进 epoch drain action——IO 落在读热路径 + 与异步协作本性冲突死结。 正解:销毁 IO 持段排他锁直接执行,或投递 BackgroundWorkerLoop
  2. 在未持 epoch 的线程调 BumpCurrentEpoch——违反协议(Debug 立即抛;Release 下静默腐败)。 正解:调用线程先 Resume(),用完 Suspend()(骨架见 §3)。
  3. 互斥"双轨制"——同需求上 SpinRWLock + epoch 两套。互斥来源不单一,epoch drain 那套 引出反模式 1/2。正解:存储读写互斥统一用 SpinRWLockLightEpoch 只留上层结构组件的 延迟回收。
  4. SpinRWLock 排他临界区内 await——线程切换后释放线程 ID 不匹配,锁永久泄漏。 正解:异步互斥用 AsyncManualResetEvent 等异步原语(async-primitives.md)。

6. 想深入?指路

想懂什么 去哪
完整 API 表 / Debug 绊线 / 值示波器 类型 XML 注释(Primitives/Epochs/
机制细节(位布局 / 根因修复记录 / 契约测试矩阵) 源码注释与单测(SpinRWLockTests/FairGateTests/LightEpochTests
实测数字(读侧成本/并发伸缩/写落地) perf/core-primitives-perf.md §7
版本协调状态机(epoch 保护下分阶段过渡版本) version-scheme.md