导读:高性能和易用性通常被认为是 trade-off,但 Wormhole4j 0.3.0 的并发设计却故意选择了一条"更难用"的路——要求每个使用它的线程都必须手动注册和注销。

从单线程到多线程:有序索引的并发困境

打开网易新闻 查看精彩图片

Wormhole4j 是 EuroSys 2019 论文《Wormhole: A Fast Ordered Index for In-memory Data》的 Java 实现。这个数据结构融合了哈希表、前缀树和 B+ 树的优势,将最坏情况查找复杂度控制在 O(log L),L 为键长度。这个复杂度让它在点查和范围扫描上都很快。

但 0.3.0 之前的版本有个硬伤:只支持单线程。今天的高性能系统几乎都需要线程安全,这个限制让它的应用场景被大幅压缩。

版本 0.3.0 解决了这个问题。但实现方式很特殊——它没有走"自动透明"的常规路线,而是把一部分责任交给了使用者。

为什么有序索引的并发比哈希表难得多

哈希表的并发相对简单:键之间相互独立,一个线程操作 A 键,另一个线程操作 B 键,基本不会冲突。

有序索引完全不同。键按特定顺序链接,以支持范围扫描。这意味着键与键之间存在结构性关联,一个节点的改动可能影响整个序列的可见性。

最粗暴的方案是"大锁"——整个索引一把锁,所有操作串行。这确实能保证正确性,但多线程的优势被完全抹杀。

Wormhole4j 采用了原论文的策略:把操作分成三类,区别对待。核心目标只有一个:确保结构性变更(比如节点分裂)不会让读取者看到不一致的数据。

QSBR RCU:用"静默状态"替代引用计数

为了让读取保持高速,Wormhole4j 对元数据使用了 QSBR RCU(基于静默状态的回收读-复制-更新)。

具体实现是:索引维护两份元数据,一份活跃表,一份非活跃表。当发生结构性变更时,写入者更新非活跃表,然后翻转指针使其成为新的活跃表。

问题在于:旧的活跃表什么时候能安全回收?必须等到所有正在读取它的线程都结束。

常见的解决方案是引用计数,但这在高并发下开销很重。QSBR RCU 换了一种思路:线程主动报告自己的"静默状态"——即当前不在使用索引的时刻。

系统通过追踪这些静默状态,判断何时所有读取者都已离开旧版本。

强制线程注册:故意为之的设计选择

QSBR 机制要正常工作,必须知道哪些线程是活跃的。这就是 Wormhole4j 要求每个线程在使用前注册、结束后注销的原因。

代码看起来是这样:

// 为 QSBR RCU 机制注册线程

wormhole.registerThread();

try {

String value = wormhole.get("some_key");

} finally {

// 完成时发出静默状态信号

wormhole.unregisterThread();

这个设计是有意为之。虽然增加了使用者的负担,但省去了追踪并发读取者的沉重开销,让库的整体性能大幅提升。

这不是疏忽,而是一种明确的取舍:用 API 的繁琐换取运行时的效率。

细粒度锁与版本号:局部变更的并发控制

对于非结构性的局部变更,Wormhole4j 采用了另一套机制。

每个叶子节点有自己的锁。写入者只与访问同一节点的其他线程竞争,粒度足够细。

但读取者不能被这些锁阻塞。解决方案是版本号:读取者在读节点前后各检查一次版本时间戳。如果两次不一致,说明期间发生了变更,读取者自动重试。

这是一种乐观并发策略:假设冲突很少发生,真的冲突时付出代价重试,换取无锁读取的常态性能。

验证并发正确性:比功能测试更难的事

并发 bug 的特殊之处在于:常规测试很难触发。它们只在特定的线程交错时才会出现,而这种交错在测试环境中往往概率极低。

Wormhole4j 的开发者显然意识到了这一点。虽然原文没有详细披露验证方法,但这类系统通常会采用压力测试、模型检测或形式化验证等手段来确保并发安全。

性能数据:线程扩展性如何

根据官方基准测试,Wormhole4j 0.3.0 在线程扩展性上表现良好。随着线程数增加,吞吐量基本呈线性增长,没有出现明显的性能瓶颈。

这验证了设计选择的有效性:手动注册带来的开销远小于自动追踪机制的成本,最终实现了真正的并发高性能。