原文链接:Designing A Fast Concurrent Hash Table (ibraheem.ca)
原文作者:Ibraheem Ahmed
我最近发布了 papaya,它是 Rust 的一个快速且功能完备的并发哈希表。在这篇文章中,我将深入介绍创建 papaya 哈希表的设计和研究过程,以及为什么你可以考虑使用 papaya 而不是现有的解决方案。如果你想了解 papaya 的概况,查阅文档可能会更有帮助。
设计哲学
并发哈希表是计算机科学领域中一个被广泛探讨的话题,无论是学术文献还是开源实现中。在某种程度上,它们代表了并发数据结构的终极目标。另一方面,并发哈希表是一个不太优雅的共享可变数据的集合,通常是程序设计不佳的标志。一般来说,哈希表有许多让人叫苦不迭的特性,其中大部分在并发环境中往往会被放大。不过,尽管哈希表有这样那样的缺点,但它也是不可或缺(让人又爱又恨)的一种数据结构,尤其是在读取操作为主的场景中,其他数据结构在性能方面无法与之相匹敌。
并发哈希表关心几个重要属性:
- 读取吞吐量/延迟
- 写入吞吐量/延迟
- 内存使用情况
并发哈希表的范围很广,这取决于上述哪些属性被优先考虑。Papaya 更多地关注读取器 readers
而不是写入器writers
。读取操作应该具有极低的延迟,并且永远不会被缓慢的写入器阻塞。然而,它也非常关心整体的可预测延迟。虽然写入吞吐量可能并不出众,但无论是读取器还是写入器都不应遭受延迟峰值。
一般来说,关心写入吞吐量的用例可能更适合使用其他类型的数据结构,如哈希树(hash tries),这些结构值得更多的尝试。Papaya 旨在服务于以读取为主的工作负载。
另一个重要的考虑因素是拥有一个易于使用的 API。虽然 Papaya 可能在内部使用锁,但经过仔细考虑,API 是无锁的,不可能出现死锁。
基本设计
考虑一个基本的读写锁 RwLock<HashMap<K, V>>
。这里有几个明显的问题,其中最主要的问题是每次写操作都需要对哈希表进行独占访问。这不仅非常昂贵的同步成本,而且意味着即使只有一个写入器,读取器也无法继续进行。
然而,即使在以读取为主或只读的工作负载中,读写锁 RwLock
也并非理想。有了读写锁,读取器可以并行执行。然而,锁仍然是一个单点竞争,即使是对于读取器来说。这意味着每个读操作将尝试获取锁状态的独占访问以获取锁,导致大量的缓存一致性流量,并使可扩展性陷入停滞。对于任何可扩展的数据结构来说,使用读写锁都是不切实际的。
我们可以通过分片(sharding)来做出一个小改进,以大大提高可扩展性:而不是迫使每个内核获取相同的锁,我们可以将键分散到多个映射上,使用 Box<[RwLock<HashMap<K, V>>]>
,并根据它们的哈希值决定哪些键进入哪个映射。现在,只要有足够数量的分片,竞争就被分布在多个锁上。这是 dashmap 使用的策略。
分片减少了竞争,但这远非理想。读取器仍然需要修改共享内存。共享内存的共享程度较低,但它仍然是共享的,并且对共享内存的写入是昂贵的。此外,写操作仍然会阻塞读取器,这意味着即使是少量的写入器也可以极大地影响整体的可扩展性。一般来说,锁对读取延迟分布构成了重大问题,因为一个慢写入器可能导致所有读取器出现延迟峰值。
那么我们如何做得更好呢?最简单的无锁哈希表看起来是这样的:
rust
struct HashMap<K, V> {
buckets: AtomicPtr<[AtomicPtr<Node<K, V>>]>
}
struct Node<K, V> {
key: K,
value: V,
next: AtomicPtr<Node<K, V>>,
}
这里有几个重要的层。整个表被包裹在一个原子指针周围,这样就可以在调整大小时原子交换。此外,每个键值对都位于一个原子指针后面,碰撞形成一个并发链表。
使用原子指针很重要。大多数 CPU 只支持原子地读取高达 128 位的值,而不会断裂^1^。为了支持任意大小的键和值,需要分配条目。这允许我们原子地交换指针。
请注意,为每个条目分配内存是一个重大的基本设计决策。这意味着在大量写负载下,由于分配器压力,会牺牲写入吞吐量。然而,这种权衡是值得的,因为它允许读取器与写入者并发地访问表。这是 C# 的 ConcurrentDictionary 所采用的设计。然而,它引入了另一个关键问题。
现在,每个键值对都被分配了,读取器在遍历链表时必须通过指针来访问键值,这意味着会有缓存未命中。在并发环境中,缓存未命中的代价更大,因为条目正在被写入器修改,从而导致争用。我们希望尽可能少地访问共享内存。
本地缓存也是大多数现代哈希表选择开放寻址 而不是"封闭"的链地址法的原因。使用开放寻址,每个桶包含一个键值对。不是使用链表来解决冲突,写入器探测后续的桶,直到找到一个空的。当读取器在序列中遇到一个空桶时,他们可以停止探测,知道键不在映射中。这允许整个表由一个平面的 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ ( K , V ) ] [(K, V)] </math>[(K,V)] 表示,使得访问非常友好于缓存。
乍一看,在并发设置中,开放寻址似乎没有提供太多好处,因为条目无论如何都被分配了。
rust
struct HashMap<K, V> {
buckets: AtomicPtr<[AtomicPtr<(K, V)>]>
}
不过,这也为关键的优化打开了大门。除了条目数组,我们还可以加入第二个数组,即元数据表。每个键值对都有一个对应的元数据字节,其中包含哈希值的子集。
rust
struct HashMap<K, V> {
table: AtomicPtr<Table<K, V>>
}
struct Table<K, V> {
metadata: [AtomicU8],
entries: [AtomicPtr<(K, V)>],
}
元数据表允许读取极其高效的缓存,因为它们可以探测元数据而不是条目。需要注意的是,由于元数据只有 8 位,因此仍有可能出现误报,但这仍然是一个巨大的进步。
元数据表存在于大多数现代哈希表中,包括作为 std::collections::HashMap
基础的 swiss tables 。在并发哈希表中,元数据表更为重要,因为条目是分配的,直接探查条目是不切实际的。
探测策略
对于开放式寻址表来说,最重大的决定之一就是探测策略。探测策略决定了在初始桶已满的情况下,探测桶的顺序。虽然有很多有趣的策略,如 cuckoo、robin-hood或 hopscotch 散列,但这些策略在并发实现时需要额外的同步,尤其是有元数据表的情况下,实现起来成本较高。
另一方面,元数据表的存在意味着探测相对便宜,因此采用更简单的探测策略更有意义。例如,hashbrown 使用了混合线性和二次探测策略。使用 SIMD 例程并行探测 16 组元数据条目,而分组探测则是二次探测。这允许在避免线性探测的常见陷阱------主键聚类的同时,实现缓存高效的缓存探测。
遗憾的是,并发哈希表中的 SIMD 探测存在一个问题--原子加载必须对齐。这意味着我们不能简单地从探测位置加载下一个 16 个条目,我们必须加载对齐的分组。不幸的是,在我的测试中,当需要这种对齐时,事实证明 SIMD 探测并不值得。事实上,由于哈希位的增加带来的熵增加,在切换到非对齐读取时,swiss tables 的性能提高了 20%。因此, Papaya 坚持使用传统的二次探测策略,以及典型的快速模数转换的二次幂容量。
负载因子
哈希表的另一个重要部分是其负载因子。负载因子决定了哈希表过于满时应该调整大小。确定是否达到负载因子需要跟踪哈希表中的条目数量。然而,在并发设置中,维护计数器成本非常高,因为它形成了另一个单一的争用点!尽管计数器只被写入器访问,但它仍然严重影响性能。
解决这个问题有几种方法。最明显的是将长度计数器分片。虽然这减少了增加计数器时的争用,但它使得访问总长度的成本更高。Papaya 使用了分片计数器,并为了方便起见暴露了长度,但在每次写入时访问所有计数器分片是不切实际的。
一种解决方案是依赖于类似 HyperLogLog 的概率计数器进行调整大小。不过,受到了这篇文章的启发,Papaya 采取了不同的方法。哈希表没有设置负载因子,而是根据表的容量设置最大探测限制。一旦达到限制,表就会调整大小。探测限制基于表容量的 <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g 2 log2 </math>log2,这通常趋向于大约 80% 的负载因子。我有兴趣将探测限制和它们与负载因子的关系正式化,但这个数字在实践中似乎非常一致,并且避免了同步并发计数器的需要。
删除
在开放式寻址中,不能简单地从链表中解除链接来删除某个值。取而代之的是,通常需要放置一个标记值,即 tombstone 。还有更复杂的删除方案,如后移删除,但这些方案很难在不引入额外同步的情况下同时实现。
Tombstones 有点不合适,因为它们会导致探测序列变长。然而,如果插入操作在其探测序列中遇到 tombstone ,该条目可以被重用为新主键。这在一定程度上缓解了问题。
然而,并发删除对元数据表带来问题。试想以下事件序列:
- 线程 1 插入键"a"
- 线程 2 删除键"a"
- 线程 2 在同一插槽中插入键"b"
- 线程 2 写入元数据 0b01
- 线程 1 延迟写入元数据 0b10
同步条目及其元数据位于不同的位置,因此在重复使用插槽时很难同步。一个解决方案是为每个条目存储一个锁,当存储条目及其元数据时获取该锁。这确保了同步,但对写入器来说是一个显著的减速。
不过,还有另一种选择。我们可以不使用锁,而是通过在删除条目后不允许被重复使用。这意味着只有一个元数据值被写入给定的槽,所以我们不必担心同步问题。Cliff Click著名的无锁哈希表采用了这种方法,尽管它使用这种方法来同步键和值而不是元数据。然而,这是一个相当大的权衡,因为这意味着插入和删除大量键的工作负载必须更频繁地调整大小以释放条目。我们稍后会更多地讨论调整大小。
内存回收
到目前为止,我们一直忽视了一个大问题,内存回收。在无锁环境中,并发删除变得更加困难。特别是,由于任意读取器都可能同时访问一个对象,因此没有明显的方法来判断何时释放该对象是安全的。
显然,解决这个问题的解决方案是某种形式的引用计数。遗憾的是,引用计数的代价与读写锁类似,每次访问都需要修改共享内存。特别是,这对于同步访问表本身来说是灾难性的,因为它为所有操作创建了一个单一的争用点。
有许多算法可以解决这个问题。一个流行的方案是 hazard pointers,它迫使线程通过线程本地的指针列表宣布对给定对象的访问。虽然这种方法非常节省内存,但对读取器来说也非常昂贵。
另一种算法是隔代回收(epoch-based reclamation,EBR)。线程不会跟踪单个对象,而是基于偶尔增加的全局代计数器 跟踪它们所处的时间段 epoch 。对象会在给定的时间段内退役,一旦所有线程都从该时间段转移,就可以安全地回收对象。
EBR 非常轻量级。然而,它在内存效率上不如其他算法,因为它分批跟踪对象。虽然这对提高性能来说是可以接受的,但 EBR 还有其他一些缺点。
EBR 和相关方案最大的缺点是回收的可预测性不强。只有在所有线程都从该时间段退出后,这一批对象才能被回收。这意味着要回收一批对象,你必须检查所有活动线程的状态,而这需要访问线程本地共享内存,成本非常高 。这导致回收平衡和性能之间的权衡,取决于尝试回收的频率。例如,crossbeam-epoch crate 每运行 128 次就会检查一次垃圾。重要的是,读取器和写入器都必须执行检查,这就导致回收不可预测地触发,并导致延迟分布不佳。
由于 Papaya 为每个条目分配内存,并且不重复使用 tombstones ,内存效率是一个非常重要的因素。不幸的是,现有的内存回收算法在我的测试中并不达标。
几年前,我偶然发现了一种名为 hyaline 的算法,它解决了这些问题,该算法已经在 seize crate 中实现。在 hyaline 中,当一批对象退役时,会执行成本极高的跨线程检查。这批对象只向所有活动线程传播一次。在这个初始退役阶段之后,使用引用计数回收这批对象。这个回收过程可预测性要高得多,因为线程可以在每次操作之前检查新垃圾,而不会牺牲性能。在实践中,由于工作负载平衡带来的并行性收益,它往往优于 EBR。
Hyaline 还解决了 EBR 的另一个问题,鲁棒性。在 EBR 中,一个慢速线程可以阻止给定时间段内所有对象的回收。Hyaline 通过跟踪对象创建的时间,在回收新对象时过滤掉慢速线程,从而解决了这个问题。这些额外的特性使 hyaline 成为 Papaya 的完美选择。
重新分配大小
一旦哈希表变得太满,它就需要调整大小,将所有键和值重新定位到一个更大的表中。在并发设置中,这可能非常昂贵。为了降低调整大小的成本,多个线程可以并行协助迁移和复制条目。
在实现并发调整大小时,有许多权衡要做。理想情况下,读取器应该不受调整大小的影响。这将要求所有写入器在取得进展之前完成迁移,为读取器提供单一的真实来源。然而,调整大小可能很慢,为写入器引入延迟峰值。对于大型表,调整大小可能需要几百毫秒甚至几秒钟才能完成。对于许多应用程序来说,这是不可接受的延迟量。
为了避免延迟峰值,我们可以实施增量调整大小,条目被逐步复制到新表,而不是阻塞。即使是单线程哈希表,如griddle crate,也采取了这种方法。
同时管理两个表的状态非常棘手,但 Papaya 实现了一种迁移算法,允许对旧表进行并发更新并将原子拷贝到新表。这意味着在迁移期间,许多操作在搜索条目时必须检查新旧两个表。然而,这通常是一个可以接受的折中方法,因为调整大小操作通常并不常见,短时间稍微增加的延迟比极端延迟峰值要好。
增量调整大小还抵消了永久 tombstones 的影响,因为调整大小的成本被摊销了。然而,为了灵活性,Papaya支持两种调整大小模式作为选项。当写入吞吐量或读取延迟是主要关注点时,可以使用阻塞调整大小。
需要注意的是,调整大小是 Papaya 不带锁的唯一情况。分配下一个表时需要加锁,以防止分配器压力过大。此外,如果写操作的键正在被复制到新表,写操作可能会阻塞。在这种情况下,Papaya 使用混合旋转策略,然后回退到阻塞。然而,请注意,复制条目不涉及分配,通常非常快。阻塞是一个有意的设计决策,因为真正的无锁调整大小代价非常高,但我们还是小心谨慎,以减少阻塞可能带来的任何问题。
其他功能
除了上述所有性能特点外,papaya 还具有一些独特的功能。
由于 papaya 不包含锁,因此执行复杂操作更具挑战性。相反,papaya 公开了许多原子操作。其中最强大的是 HashMap::compute
,它允许使用比较和交换(CAS)函数更新条目:
rust
let map = papaya::HashMap::new();
let compute = |entry| match entry {
// Remove the value if it is even.
Some((_key, value)) if value % 2 == 0 => {
Operation::Remove
}
// Increment the value if it is odd.
Some((_key, value)) => {
Operation::Insert(value + 1)
}
// Do nothing if the key does not exist
None => Operation::Abort(()),
};
map.pin().compute('A', compute);
这样,尽管没有锁,也能执行复杂的操作。
papaya 的另一个独特功能是支持异步。dashmap 最大的缺点之一是使用同步锁,因此持有对 Dashmap 项目的引用将导致死锁。由于 papaya 的 API 是无锁的,因此不可能出现死锁。不过,访问映射仍需要获取一个内存回收保护,即上例中对 pin 的调用。该保护是!发送的,因为它与当前线程的内存回收状态绑定。不过,papaya 也公开了独立于任何给定线程的自有保护,即 Send 和 Sync。创建这些保护的成本较高,但在使用抢工调度程序时,允许跨 .await
点保留这些保护:
rust
async fn run(map: Arc<HashMap<i32, String>>) {
tokio::spawn(async move {
let map = map.pin_owned(); // <--
for (key, value) in map.iter() {
tokio::fs::write("db.txt", format!("{key}: {value}\n")).await;
}
});
}
据我所知,现有的并发哈希表都不支持异步,这让我非常兴奋。
对比
现有的并发哈希表 crates 有很多。不过,与 papaya 相比,它们大多在读取吞吐量和可预测延迟方面存在不足。此外,异步支持也是一个很难找到的特性。不过,在某些情况下,你可能需要考虑另一种代码 crates 。
- dashmap 在 hashbrown 的基础上进行了非常简单的设计。它与
std::collections::HashMap
的 API 非常相似。对于写入量大的工作负载,它可能会提供更好的性能。此外,它在内存使用方面的开销也较低。 - scc 类似于 dashmap,但会更积极地分片桶锁。对于写入量大的工作负载,它可能是你的首选,不过代码本身似乎相当复杂且难以审计。
- flurry 是一种封闭式寻址表,具有条带锁,但读取无锁。然而,由于分配器的压力,它在性能和内存使用方面存在问题。对于大多数工作负载而言,Papaya 的总体性能应该优于 Flurry。
- evmap 非常适合读取量极大的情况。不过,它最终会保持一致,而且写入成本相对较高。即使对于 99% 的重读取工作负载,可扩展性也会受到影响。
- leapfrog 性能出色,但仅限于 64 位 Copy 值。这种限制在学术文献中很常见,而且对于任意值类型,leapfrog 会退回到自旋锁,这对于通用映射来说是很不幸的。
如需了解更多信息,请参阅基准测试,但仍需谨慎对待。
参考链接: