数据结构的线程安全性,在哪里添加同步原语

Thread Safety of datatstructures, Where to add synchronization primitives?

本文关键字:在哪里 添加 同步原语 安全性 线程 数据结构      更新时间:2023-10-16

这是一个困扰我一段时间的设计问题。实际上,这很简单,当你提供数据结构库时,你是在线程安全原语中构建,还是只是提供结构,让使用它的系统决定如何实现实际操作。

一个快速示例,一个支持生产者、消费者模型的循环缓冲区。2个方法,Get和Write,每个方法都更新一个全局变量填充计数。现在,您是否只提供要锁定的互斥对象,让使用缓冲区的代码获取互斥对象,或者在内部进行锁定,并提供开箱即用的互斥。

STL似乎采取了从外部执行的方法,但您想要提供细粒度锁定是出于性能原因。

想法?

IMHO此次讨论没有明确的赢家。双方各有利弊:

将同步作为API的一部分(模块内部):

  • 确保调用者不必花太多时间考虑同步
  • 确保调用者不会在同步时出错(因为在没有内置同步结构支持的语言中进行同步可能很容易出错)
  • 您可以执行细粒度锁定并优化库

让呼叫者同步

  • 赋予调用者更多的控制权
  • 在单线程程序中,调用方不会花费时间锁定/解锁

你可以根据情况做出决定:

  • 如果它是一个很可能在多线程情况下使用的库,那么提供内置锁定
  • 如果锁定实现起来非常乏味(比如线程安全队列的每个节点锁定),那么将其作为库的一部分提供
  • 考虑提供两个版本的库-锁定和解锁。使用C++中的模板为其提供一个良好的语法,并结合ThreadSafe接口等并发模式
  • 始终如一!如果在库中提供一组模块,请确保线程安全模块与非线程安全模块的语法一致。这是我对JavaSwing的不满,因为它们不一致。库的某些部分是线程安全的,而其他部分则不是

希望这能有所帮助!

我前段时间就在考虑这个确切的问题。因此,我继续编写了一些示例代码,以了解各种方法的优缺点。因此,与其给出理论答案,不如让我为您在OP中提到的相同问题提供一些代码,即具有多个生产者和消费者的循环缓冲区(队列)。

给你。

也许看一下代码可以给你一些澄清。如果需要的话,我会添加更多的点……但现在,看看代码,得出显而易见的!

如果可以,则完全不锁定。

如果没有办法,你有两个选择:(1)内部锁定(2)外部锁定

(1) 最好的方法是内部锁定。(2) 另一种方法是让用户解决并发问题。

无论采用哪种方式,都必须对类进行文档化,让用户/调用方知道它是如何处理并发的。

以下是Effective Java:的摘要

总之,每个类都应该清楚地记录其线程安全特性措辞严谨的散文描述或线程安全注释。这个synchronized修饰符在本文档中不起任何作用。有条件地线程安全类必须记录哪些方法调用序列需要外部同步,以及执行这些同步时要获取哪个锁序列。如果您编写了一个无条件线程安全类,请考虑使用private锁定对象以代替同步的方法。这样可以防止同步客户端和子类的干扰,并为您提供灵活性在稍后发布的中采用更复杂的并发控制方法

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

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

第1点有一些有趣的含义。如果你在内部锁定,那么如果你只单独使用每个操作,你是安全的。但是,如果您可能在一个序列中使用两个或多个操作,请记住,每个操作的原子性并不能保证整个序列的原子性,因此在任何情况下都需要外部锁定。例如:

if(buffer not empty)
    extract from buffer

尽管这两个操作中的每一个本身都是原子操作,但由于明显的原因,上面的代码不是线程安全的。

第2点再次是反对内部锁定的论点:在单线程环境中,您不需要锁,因此通过获取和释放内部锁会产生不必要的开销。例如,这就是HashTableVector类在Java中被弃用的原因之一。

Herb Sutter和Andrei Alexandrescu建议如下[来源]:

如果您的应用程序跨线程共享数据,请安全地这样做:

  • 有关本地同步原语,请参阅目标平台的文档
  • 更喜欢将平台的基元封装在自己的抽象中
  • 确保在多线程程序中使用的类型是安全的
  • 保证非共享对象是独立的
  • 记录调用方在不同线程中使用该类型的同一对象需要做什么

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

如果只有一个线程在读,只有一个螺纹在写,则在通过在一个操作中写入各自的新值来更新头和尾indes的条件下,不需要同步:

// 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可能会有两个不同的tail值可供比较。

如果有多个线程读取和多个线程写入,我建议您使用spinlock,因为操作本身所需的时间可能很短。

如果处理数据结构的方法很小,只有一些指令,那么根本不应该进行锁定。原子能行动是前进的道路,在这里。C++11和C11都为此提供了新的接口。许多编译器已经有了这样的接口,作为对以前版本标准的扩展。