同时写入和读取哈希表

Writing to and reading from a hashtable simultaneously

本文关键字:读取 哈希表      更新时间:2023-10-16

我似乎应该能够实现一个矢量类型的对象,我可以像这样同时插入和读取:

  1. 如果向量中有空间,我可以插入东西;这不应该干扰阅读
  2. 如果我必须重新分配,我可以分配,然后复制,然后更新指向数据的指针,然后释放
  3. 如果我想从向量中读取,我只需要确保提取指向数据的指针和从中读取是原子式的

通过这种方式,如果我在重新定位时从矢量中读取,我只从旧位置读取,这仍然有效。(当然,删除不会是线程安全的,但这没关系;如果你想删除东西,你只需要考虑到这一点,这无论如何不会比你现在使用std::vector更糟糕。)

反过来,我应该能够将其调整为哈希表,而不会遇到太多麻烦——只需为bucket使用其中一个向量,然后用其中一个矢量返回每个bucket。(我意识到你应该用某种自平衡二叉树来支持桶,以获得最佳的渐近复杂度,但向量对我的应用程序来说很好,我不想在这里偏离太远。)

两个问题:

  1. 这有道理吗?还是我错过了什么?(我不相信我对线程安全性的直觉。)
  2. 如果是这样的话,是否可以使用C++标准库中的一些容器作为基元来构建这个或类似的东西,或者我唯一的选择是从头开始写整个东西?(当然,我想我会在一些地方使用std::atomic,但有没有办法在这里使用std::vectorstd::unordered_map之类的东西?)

或者,有关于这个主题的书或什么我可以读的吗?

编写线程安全代码的问题在于,很难涵盖代码并发运行时可能出现的所有场景。最有问题的是,自制的线程安全数据结构可能看起来像预期的那样工作,但在生产中往往会随机失败。

甚至比基于锁的算法更复杂的是无锁或无等待算法。无锁算法保证即使一个线程被挂起,其他线程也能取得进展。无等待算法(无锁)保证所有线程都能取得进展。

除了实际的算法之外,您还必须考虑实现算法的平台。多线程代码取决于编译器和处理器的内存模型,尤其是在不使用锁的情况下。std::atomic提供了对无锁/无等待算法所需的原子基元的独立于平台的访问。不过,这并不能使编写正确的自定义线程安全数据结构变得更容易。

简单的答案是:不要这么做。

答案很长:

最重要的一点是您需要数据结构的确切场景。在此基础上,您可以推导出需求,并评估自己实施它是否可行。为了理解这种实现的底层机制,实验是有意义的。出于生产代码的考虑,这通常会再次困扰您,因此很少获胜。

由于您不能依赖于标准容器的未定义行为(接口契约不能隐含的行为),因此很难将它们用作实现的基础。文档通常定义单线程POV的预期行为。然而,对于多线程,您需要了解内部结构才能依赖它们——当然,除非在实现数据结构时考虑到了并发性。

回到您的场景:假设您需要的是一个具有固定数量的bucket的哈希表,这些bucket可以在不阻塞的情况下读取。插入可以序列化,不需要删除。这在缓存中非常常见。

作为构建块,您所需要的只是一个锁和固定数量的链表,这些链表表示哈希表桶并处理冲突。

查找算法如下(伪代码):

node* lookup(key) {
// concurrency issue (see below)
node = buckets[hash(key)].find(key);
if (node) {
return node;
}
lock();
node = buckets[hash(key)].find(key);
if (node) {
return node;
}
node = new node(key);
// concurrency issue (see below)
buckets[hash(key)].add(node);
unlock();
return node;
}

哈希表可以在不阻塞的情况下读取,插入是序列化的。只有在从未从存储桶中移除项目时,这才有效。否则,您可能会访问已释放的数据。

还有第二个警告并没有立即显现出来,它说明了编写多线程代码的复杂性。只有当新创建的节点在其指针插入bucket之前被完全分配并对其他线程可见时,这才能正常工作。如果不保持该顺序,读取器可能会触发分段故障,因为它们访问了部分初始化的节点。顺序受编译器和CPU的影响,只要单线程代码的POV行为不变,两者都可以自由地对指令进行重新排序。

在这种特定情况下,订单具有高度相关性。因此,我们需要通知编译器和CPU,new必须发生在add之前。此外,读取器(find)需要在读取任何其他数据之前读取指针。这是通过影响两个操作的内存顺序来实现的。在C++11中,将节点指针表示为std::atomic<node*>并使用loadstore来读/写指针解决了这个问题,因为默认的内存顺序是std::memory_order_seq_cst,这提供了顺序一致性保证。有一种更细致的方法可以生成更高效的代码(对load使用std::memory_order_acquire,对store使用std::memory_order_release)。您还可以通过适当地放置所谓的内存屏障/栅栏来影响顺序(这些屏障/栅栏是由提到的内存顺序参数隐式触发的)。

纯基于锁的算法通常不必处理内存排序的原因是,锁原语已经隐式地触发了每个lockunlock的内存屏障/栅栏。

长话短说:如果你不需要创建自己的线程安全数据结构,就不要这样做,而是依赖于经过彻底审查和测试的现有实现。