同时写入和读取哈希表
Writing to and reading from a hashtable simultaneously
看来我应该能够实现一个向量类型的对象,我可以像这样同时插入和读取:
- 如果向量中有space,我就可以插入东西;这不应该影响阅读。
- 如果我必须重新分配,我可以分配,然后复制,然后更新指向数据的指针,然后释放。
- 如果我想从向量中读取,我只需要确保以原子方式获取指向数据的指针并从中读取。
通过这种方式,如果我在重新分配时从向量中读取,我只是从仍然有效的旧位置读取。 (当然 deletion 不会是线程安全的,但没关系;如果你想删除东西,你只需要考虑到这一点,这至少不比什么差你现在有 std::vector
。)
反过来,我应该能够毫不费力地将它改编成哈希表——只需将这些向量之一用于存储桶,并用这些向量之一支持每个存储桶。 (我知道你应该用某种自平衡二叉树来支持桶以获得最佳的渐近复杂度,但向量对我的应用程序来说很好,我不想在这里把事情弄得太远。)
两个问题:
- 这有意义吗?还是我遗漏了什么? (我不相信我对线程安全的直觉。)
- 如果是这样,是否可以使用 C++ 标准库中的一些容器作为原语来构建这个或类似的东西,或者我唯一的办法是从头开始编写整个东西吗? (当然,我想我会在一些地方使用
std::atomic
,但是有什么方法可以在这里使用 std::vector
或 std::unordered_map
之类的东西吗?)
或者,有没有我可以阅读的有关该主题的书籍或书籍?
编写线程安全代码的问题是很难涵盖所有可能发生的情况,因为代码是 运行 同时发生的。最有问题的是,本土开发的线程安全数据结构可能看起来像预期的那样工作,但在生产中经常随机失败。
比基于锁的算法更复杂的是无锁或无等待算法。无锁算法保证即使一个线程被挂起,其他线程也能取得进展。无等待算法(无锁)保证所有线程都能取得进展。
除了实际的算法之外,您还必须始终考虑要为其实现算法的平台。多线程代码取决于编译器和处理器的内存模型,尤其是在不使用锁的情况下。 std::atomic
提供对 lock-free/wait-free 算法所需的原子原语的独立于平台的访问。不过,这并不能使编写正确的自定义线程安全数据结构变得容易得多。
简短的回答是:不要这样做。
长答案:
最重要的一点是您需要数据结构的确切场景。在此基础上,您可以推导出需求并评估自己实施是否可行。为了理解此类实现的底层机制,进行试验是有意义的。为了生产代码,这通常会回来困扰你,因此很少会成功。
由于您不能依赖标准容器的未定义行为(接口契约不能隐含的行为),因此很难将它们用作实现的基础。文档通常定义单线程 POV 的预期行为。但是,对于多线程,您需要了解内部原理才能依赖它们——当然,除非数据结构是在考虑并发的情况下实现的。
回到你的场景:假设你需要的是一个散列 table 具有固定数量的桶,可以从中读取而不会阻塞。插入可以序列化,不需要删除。这对于缓存来说很常见。
作为构建块,您只需要一个锁和固定数量的链表,这些链表代表哈希 table 桶和处理冲突。
查找算法如下(伪代码):
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;
}
散列table可以无阻塞读取,插入序列化。这仅在项目从未从桶中移除时才有效。否则,您可能会访问已释放的数据。
还有第二个警告,它不是很明显,它说明了编写多线程代码的复杂性。如果新创建的节点在其指针被插入到桶中之前被完全分配并且对其他线程可见,这只会按预期工作。
如果不维护该顺序,readers 可能会触发分段错误,因为它们访问了部分初始化的节点。
顺序受编译器和 CPU 的影响,只要单线程代码的 POV 行为不改变,它们都可以自由地重新排序指令。
在这种特定情况下,顺序是高度相关的。因此,我们需要通知编译器和 CPU,new
必须在 add
之前发生。此外,reader (find
) 需要在任何其他数据之前读取指针。这是通过影响两个操作的内存顺序来实现的。在 C++11 中,将节点指针表示为 std::atomic<node*>
并使用 load
和 store
到 read/write 指针解决了该问题,因为默认内存顺序是 std::memory_order_seq_cst
,它提供了顺序一致性保证。有一种更细微的方法可能会生成更高效的代码(使用 std::memory_order_acquire
表示 load
,使用 std::memory_order_release
表示 store
)。您还可以通过适当放置所谓的内存 barriers/fences 来影响顺序(这些由提到的内存顺序参数隐式触发)。
纯粹基于锁的算法通常不必处理内存排序的原因是锁定原语已经在每个 lock
和 unlock
中隐式触发内存 barriers/fences。
长话短说:如果您不需要创建自己的线程安全数据结构,请不要这样做,而是依赖已经过全面审查和测试的现有实现。
看来我应该能够实现一个向量类型的对象,我可以像这样同时插入和读取:
- 如果向量中有space,我就可以插入东西;这不应该影响阅读。
- 如果我必须重新分配,我可以分配,然后复制,然后更新指向数据的指针,然后释放。
- 如果我想从向量中读取,我只需要确保以原子方式获取指向数据的指针并从中读取。
通过这种方式,如果我在重新分配时从向量中读取,我只是从仍然有效的旧位置读取。 (当然 deletion 不会是线程安全的,但没关系;如果你想删除东西,你只需要考虑到这一点,这至少不比什么差你现在有 std::vector
。)
反过来,我应该能够毫不费力地将它改编成哈希表——只需将这些向量之一用于存储桶,并用这些向量之一支持每个存储桶。 (我知道你应该用某种自平衡二叉树来支持桶以获得最佳的渐近复杂度,但向量对我的应用程序来说很好,我不想在这里把事情弄得太远。)
两个问题:
- 这有意义吗?还是我遗漏了什么? (我不相信我对线程安全的直觉。)
- 如果是这样,是否可以使用 C++ 标准库中的一些容器作为原语来构建这个或类似的东西,或者我唯一的办法是从头开始编写整个东西吗? (当然,我想我会在一些地方使用
std::atomic
,但是有什么方法可以在这里使用std::vector
或std::unordered_map
之类的东西吗?)
或者,有没有我可以阅读的有关该主题的书籍或书籍?
编写线程安全代码的问题是很难涵盖所有可能发生的情况,因为代码是 运行 同时发生的。最有问题的是,本土开发的线程安全数据结构可能看起来像预期的那样工作,但在生产中经常随机失败。
比基于锁的算法更复杂的是无锁或无等待算法。无锁算法保证即使一个线程被挂起,其他线程也能取得进展。无等待算法(无锁)保证所有线程都能取得进展。
除了实际的算法之外,您还必须始终考虑要为其实现算法的平台。多线程代码取决于编译器和处理器的内存模型,尤其是在不使用锁的情况下。 std::atomic
提供对 lock-free/wait-free 算法所需的原子原语的独立于平台的访问。不过,这并不能使编写正确的自定义线程安全数据结构变得容易得多。
简短的回答是:不要这样做。
长答案:
最重要的一点是您需要数据结构的确切场景。在此基础上,您可以推导出需求并评估自己实施是否可行。为了理解此类实现的底层机制,进行试验是有意义的。为了生产代码,这通常会回来困扰你,因此很少会成功。
由于您不能依赖标准容器的未定义行为(接口契约不能隐含的行为),因此很难将它们用作实现的基础。文档通常定义单线程 POV 的预期行为。但是,对于多线程,您需要了解内部原理才能依赖它们——当然,除非数据结构是在考虑并发的情况下实现的。
回到你的场景:假设你需要的是一个散列 table 具有固定数量的桶,可以从中读取而不会阻塞。插入可以序列化,不需要删除。这对于缓存来说很常见。
作为构建块,您只需要一个锁和固定数量的链表,这些链表代表哈希 table 桶和处理冲突。
查找算法如下(伪代码):
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;
}
散列table可以无阻塞读取,插入序列化。这仅在项目从未从桶中移除时才有效。否则,您可能会访问已释放的数据。
还有第二个警告,它不是很明显,它说明了编写多线程代码的复杂性。如果新创建的节点在其指针被插入到桶中之前被完全分配并且对其他线程可见,这只会按预期工作。 如果不维护该顺序,readers 可能会触发分段错误,因为它们访问了部分初始化的节点。 顺序受编译器和 CPU 的影响,只要单线程代码的 POV 行为不改变,它们都可以自由地重新排序指令。
在这种特定情况下,顺序是高度相关的。因此,我们需要通知编译器和 CPU,new
必须在 add
之前发生。此外,reader (find
) 需要在任何其他数据之前读取指针。这是通过影响两个操作的内存顺序来实现的。在 C++11 中,将节点指针表示为 std::atomic<node*>
并使用 load
和 store
到 read/write 指针解决了该问题,因为默认内存顺序是 std::memory_order_seq_cst
,它提供了顺序一致性保证。有一种更细微的方法可能会生成更高效的代码(使用 std::memory_order_acquire
表示 load
,使用 std::memory_order_release
表示 store
)。您还可以通过适当放置所谓的内存 barriers/fences 来影响顺序(这些由提到的内存顺序参数隐式触发)。
纯粹基于锁的算法通常不必处理内存排序的原因是锁定原语已经在每个 lock
和 unlock
中隐式触发内存 barriers/fences。
长话短说:如果您不需要创建自己的线程安全数据结构,请不要这样做,而是依赖已经过全面审查和测试的现有实现。