互斥、回收与原子更新使用指南(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 闸(不挡读者)
硬性要求(违反 = 死锁):
- 绝对不可重入——同线程禁止重复
AcquireShared/AcquireExclusive。 - 禁止锁升级——持共享锁的线程直接升级排他 = 两线程同时升级互相等。先
ReleaseShared再AcquireExclusive,或一开始就持排他。 - 排他临界区极短——禁 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、丢引用——非阻塞、纳秒~微秒级。
- ❌ 阻塞 IO(
PunchHole、fsync、段提升)——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. 反模式(禁止重蹈)
- ❌ 把阻塞 IO 塞进 epoch drain action——IO 落在读热路径 + 与异步协作本性冲突死结。
正解:销毁 IO 持段排他锁直接执行,或投递
BackgroundWorkerLoop。 - ❌ 在未持 epoch 的线程调
BumpCurrentEpoch——违反协议(Debug 立即抛;Release 下静默腐败)。 正解:调用线程先Resume(),用完Suspend()(骨架见 §3)。 - ❌ 互斥"双轨制"——同需求上 SpinRWLock + epoch 两套。互斥来源不单一,epoch drain 那套
引出反模式 1/2。正解:存储读写互斥统一用
SpinRWLock;LightEpoch只留上层结构组件的 延迟回收。 - ❌ 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 |