编写线程安全代码的问题在于,很难涵盖代码并发运行时可能发生的所有情况。最有问题的是,本土的线程安全数据结构可能看起来像预期的那样工作,但在生产中经常随机失败。
比基于锁的算法更复杂的是无锁或无等待算法。无锁算法保证即使一个线程被挂起,其他线程也能取得进展。无等待算法(无锁)保证所有线程都能取得进展。
除了实际的算法之外,您还必须始终考虑实现算法的平台。多线程代码取决于编译器和处理器的内存模型,尤其是在不使用锁的情况下。std::atomic
提供对无锁/无等待算法所需的原子原语的独立于平台的访问。但是,这并没有使编写正确的自定义线程安全数据结构变得容易得多。
简短的回答是:不要这样做。
长答案:
最重要的一点是您需要数据结构的确切场景。在此基础上,您可以得出需求并评估自己实施是否可行。为了理解这种实现的底层机制,实验是有意义的。为了生产代码,这通常会再次困扰您,因此很少会获胜。
由于您不能依赖标准容器的未定义行为(接口契约不能暗示的行为),因此很难将它们用作实现的基础。文档通常定义单线程 POV 的预期行为。但是,对于多线程,您需要了解内部结构才能依赖它们——当然,除非数据结构是在考虑到并发性的情况下实现的。
回到您的场景:假设您需要一个具有固定数量的桶的哈希表,可以在不阻塞的情况下读取。插入可以序列化,不需要删除。这对于缓存来说很常见。
作为构建块,您只需要一个锁和固定数量的链表,这些链表代表哈希表存储桶并处理冲突。
查找算法如下(伪代码):
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;
}
可以不阻塞地读取哈希表,插入是序列化的。这仅适用于从未从存储桶中删除项目的情况。否则,您可能会访问已释放的数据。
还有第二个警告不是很明显,它说明了编写多线程代码的复杂性。仅当新创建的节点在其指针插入存储桶之前已完全分配并且对其他线程可见时,这才能按预期工作。如果不维护该顺序,则读取器可能会触发分段错误,因为它们访问了部分初始化的节点。顺序受编译器和 CPU 的影响,只要单线程代码的 POV 行为不改变,它们都可以自由地重新排序指令。
在这种特定情况下,订单是高度相关的。因此,我们需要通知编译器和 CPU,new
必须在add
. 此外,读取器 ( find
) 需要在读取任何其他数据之前读取指针。这是通过影响两个操作的内存顺序来实现的。在 C++11 中,将节点指针表示为 astd::atomic<node*>
并使用load
和store
读取/写入指针解决了该问题,因为默认的内存顺序是std::memory_order_seq_cst
,这提供了顺序一致性保证。有一种更细致入微的方法可以生成更高效的代码(使用std::memory_order_acquire
forload
和std::memory_order_release
forstore
)。您还可以通过适当地放置所谓的内存屏障/栅栏来影响顺序(这些是由提到的内存顺序参数隐式触发的)。
纯粹基于锁的算法通常不必处理内存排序的原因是锁定原语已经隐式地触发了内存屏障/lock
栅栏unlock
。
长话短说:如果您不需要创建自己的线程安全数据结构,请不要这样做,而是依赖已经过彻底审查和测试的现有实现。