数据结构的线程安全,应该在哪里添加同步机制?

7
这是一个困扰我已久的设计问题。其实很简单,当您提供数据结构库时,您是否应该内置线程安全原语,还是只提供结构并让使用它的系统决定如何实现实际操作。
举个例子,一个支持生产者、消费者模型的循环缓冲区。有两个方法:Get和Write,每个方法都会更新全局变量fill count。现在,您是仅提供互斥体以进行锁定并让使用缓冲区的代码获取互斥体,还是在内部进行锁定并直接提供互斥排除。
STL似乎采取外部方式来完成,但出于性能原因,您希望提供更细粒度的锁定。
您怎么想?

如果你直接将线程安全性构建到数据结构中,不仅会紧密耦合两个独立的设计概念,而且可能会降低一些性能。 - AJG85
https://dev59.com/YFDTa4cB1Zd3GeqPI2UL - Bojan Komazec
@AJG85 好的,就性能参数而言,如果将其构建到数据结构中,您实际上可以设计更细粒度的锁原语。我认为性能可能会更好。 - creatiwit
@shrin:我只是指获取锁需要额外的资源。在单线程应用程序或某些类型的容器中,您可能会为不需要的东西付费。 - AJG85
7个回答

3
在这个讨论中,我认为没有明显的胜者。每一方都有其利弊:
将同步作为API的一部分(在模块内):
- 确保调用者不必花费太多时间考虑同步问题 - 确保调用者不会出现同步错误(因为在没有内置同步构造支持的语言中进行同步可能会出现错误) - 您可以进行更细粒度的锁定并优化您的库
让调用者进行同步:
- 给予调用者更多控制权 - 在单线程程序中,调用者不需要花费时间进行加锁/解锁。
您可以根据情况做出决策:
- 如果是一个可能在多线程环境下使用的库,则提供内置锁定。 - 如果实现锁定非常繁琐(例如对于线程安全队列的每个节点进行锁定),则将其作为库的一部分提供。 - 考虑提供两个版本的库-已锁定和未锁定。使用C++中的模板提供带有ThreadSafe接口的漂亮语法。 - 保持一致!如果您正在提供一组库中的模块,请确保在线程安全与非线程安全模块的语法方面保持一致。这是我对Java Swing的抱怨,因为它们不一致。库的某些部分是线程安全的,而另一些则不是。
希望这可以帮到您!

2
如果可以的话,尽量不要进行锁定。
如果无法避免,有两个选择:(1)内部锁定(2)外部锁定。
最好的方法是使用内部锁定。
另一个方法是让用户解决并发问题。
无论选择哪种方式,都必须记录类以让用户/调用者知道它如何处理并发。
以下是Effective Java中的摘要:
要总结一下,每个类都应该用精心措辞的散文描述或线程安全注释清楚地记录其线程安全属性。synchronized修饰符在此文档中没有任何作用。有条件线程安全的类必须记录哪些方法调用序列需要外部同步以及执行这些序列时要获取哪个锁。如果您编写了一个无条件线程安全的类,请考虑使用私有锁对象来代替同步方法。这可以保护您免受客户端和子类的同步干扰,并为您提供在以后的版本中采用更复杂的并发控制方法的灵活性。

最好的方法是使用内部锁定。为什么它是最好的?最适合什么情况?如果我在单线程中使用数据结构,我不希望浪费时间来处理锁定。 - R. Martinho Fernandes
这就是我说的,如果可以的话,请不要使用任何锁定。然后尝试内部锁定,再尝试外部锁定。并且记录您的类。 - Adrian
我认为无锁操作是内部加锁的一个子类。在这两种情况下,库会处理好所有事情,调用者不必担心。使用无锁算法的能力确实是内部加锁的优点之一,但我不认为它是绝对的优势。 - ugoren
@ugoren 我不同意,因为它必须在类内部进行文档记录。如果我们没有文档记录,那么就像你所说的那样,它将是“子类”,因为你不知道某些东西是否线程安全,但是文档会告诉你。Java也是这样做的;例如,请参见ConcurrentMap和Map。 - Adrian
我不确定我们在术语上是否达成一致。"无锁"是指库是线程安全的,尽管不使用锁。内部锁定是指库使用锁来保证线程安全。外部锁定是指调用者有责任在适当的锁定下调用类(或仅在单个线程中)。我认为第一个是第二个的子类。关于文档,用户不需要关心它们之间的区别。 - ugoren

2
我曾经思考过这个问题。因此,我写了一些示例代码来了解各种方法的优缺点。所以,与其给出一个理论上的答案,不如让我为您提供一些代码,解决您在OP中提到的同样的问题,即具有多个生产者和消费者的循环缓冲区(队列)。 这里是代码。
也许查看代码可以给您一些澄清。如果需要,我将添加更多要点。但现在,请查看代码并推导出明显的结论!

你的代码是一个很好的例子,对于整个方法和While循环都使用了互斥锁。现在,这正是我想通过内部锁避免的确切行为。在循环队列中,你实际上只需要锁定如何更新填充计数,就这样。 - creatiwit
@shrin 现在你说的对了,朋友..那很好!编写示例程序有助于我理解一些设计问题。我很高兴它在某种程度上对你有所帮助!谢谢! :) - Sangeeth Saravanaraj
@sangeeth-saravanarj 是的,我也经历了同样的过程,使用内部锁定存在一个问题。假设您有内部Enqueue等待直到有空间可用和Dequeue等待如果为空。现在,只有外部实体知道是否还有可能进入队列的更多数据,因此您最终会陷入队列始终繁忙等待的境地。 - creatiwit
是的,我同意。在这种情况下可能会出现一些竞态条件。然而,当我使用100个生产者和100个消费者,在队列大小仅为100的情况下运行了整夜的程序后,我没有遇到任何死锁或活锁。这就是多线程编程的美妙之处,很难测试边缘情况! - Sangeeth Saravanaraj

1

如果只有一个线程进行读取,而只有一个线程进行写入,则在更新头部和尾部索引的新值时,只需通过一次操作即可,无需进行同步:

// adding single bytes

i=circ.head;
circ.buffer[i]=chr;
++i;
if (i==circ.limit) i=0;
circ.head=i;

// removing single bytes

i=circ.tail;
if (i!=circ.head)    /* there's data in the buffer */
{
  chr=circ.buffer[i];
  ++i;
  if (i==circ.limit) i=0;
  circ.tail=i;
}

通过在circ结构之外计算新的索引值,确保不会将部分值与其他线程混淆:如果直接增加cirf.tail,测试限制并可能清除使用circ.head的线程,则运行风险存在两个不同的尾巴值可用于比较。
如果有多个线程读取和多个线程写入,建议使用自旋锁,因为操作本身所需的时间可能非常短。

1

这里有两个重要的问题需要考虑:

  1. 这些操作是单独使用还是可以在某些情况下一起使用?
  2. 这些操作可能也会在单线程环境中使用吗?

第一个问题有一些有趣的含义。如果您在内部进行锁定,那么如果您只单独使用每个操作,则是安全的。但是,如果您可能在序列中使用两个或更多操作,请记住,每个操作的原子性不能保证整个序列的原子性,因此无论如何都需要外部锁定。例如:

if(buffer not empty)
    extract from buffer

尽管这两个操作本身都是原子的,但由于明显的原因,上述代码在多线程环境下不是线程安全的。
第二点再次反对内部锁定:在单线程环境中,您不需要锁定,因此通过获取和释放内部锁定而产生了不必要的开销。这也是为什么Java中的HashTable和Vector类已被弃用的原因之一。

1

Herb Sutter和Andrei Alexandrescu建议如下 [source]

如果您的应用程序在不同线程之间共享数据,请确保安全:

  • 查阅目标平台文档以获取本地同步原语
  • 最好将平台原语包装在自己的抽象中
  • 确保您使用的类型在多线程程序中是安全的
  • 保证未共享的对象是独立的
  • 记录调用者需要做什么才能在不同线程中使用相同类型的对象

本文讨论了三种线程安全设计方式:内部外部无锁,因此您可能会发现它很有用。


0
如果你的数据结构处理方法很小,只有一些指令,那么你根本不需要进行锁定。原子操作是解决这个问题的方法。C++11和C11都提供了新的接口来实现原子操作。许多编译器已经将这样的接口作为标准的扩展版本。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接