【问题标题】:Thread-safe linked list with fine grained locks具有细粒度锁的线程安全链表
【发布时间】:2011-10-28 23:01:58
【问题描述】:

在一个程序中,我有一个 M 类:

class M{
    /*
      very big immutable fields
    */
    int status;
};

我需要一个 M 类型对象的链表。

三种类型的线程正在访问列表:

  • 生产者:生产对象并将其附加到列表的末尾。所有新生成的对象的状态=NEW。 (操作时间 = O(1))
  • Consumers:使用列表开头的对象。如果一个对象的 status=CONSUMER_ID,它可以被消费者消费。每个消费者都保留它可以消费的链表中的第一项,因此消费是(摊销?)O(1)(见下面的注释)。
  • 析构函数:当有通知指出对象已被正确使用时删除已使用的对象(操作时间 = O(1))。
  • 修改器:根据状态图更改对象的状态。任何对象的最终状态都是消费者的 id(每个对象的操作时间 = O(1))。

消费者的数量少于 10 个。生产者的数量可能多达几百个。有一个修饰符。

注意:修饰符可能会修改已经消费的对象,因此消费者存储的物品可能会来回移动。对于这个问题,我没有找到更好的解决方案(虽然,对象之间的比较是O(1),操作不再是摊销O(1))。

性能非常重要。因此,我想使用原子操作或细粒度锁(每个对象一个)来避免不必要的阻塞。

我的问题是:

  1. 首选原子操作,因为它们更轻。我想我必须使用锁来更新析构线程中的指针,并且我可以使用原子操作来处理其他线程之间的争用。请让我知道我是否遗漏了什么,并且有一个原因是我不能在状态字段上使用原子操作。

  2. 我认为我不能使用 STL 列表,因为它不支持细粒度锁。但是您会推荐使用 Boost::Intrusive 列表(而不是自己编写)吗? Here 提到侵入式数据结构更难使线程安全?细粒度锁也是这样吗?

  3. 生产者、消费者和析构函数将根据某些事件异步调用(我计划使用 Boost::asio。但我不知道如何运行修饰符以尽量减少与其他线程的争用。选项是:

    • 与生产者异步。
    • 与消费者异步。
    • 使用自己的计时器。

只有在某些条件成立时,任何此类调用才会在列表上运行。我自己的直觉是,我如何称呼修饰符没有区别。我错过了什么吗?

我的系统是 Linux/GCC,我正在使用 boost 1.47 以防万一。

类似问题:Thread-safe deletion of a linked list node, using the fine-grained approach

【问题讨论】:

  • 首先,为什么需要析构函数?如果消费者在出队后正确消费了一个对象,它可以销毁该对象本身。如果出现允许稍后重试的类型的错误,它可以适当地设置状态并将其推回列表中。
  • 为了简单起见,我会放弃使用三个列表的想法。一种用于 Producer->Modifier,一种用于 Modifier->Consumer,一种用于 Consumer->Destructor。我认为它会简化代码,如果不是锁定的话。
  • 我很想允许修改器在从列表中提取其目标对象的短暂时间内锁定整个列表。然后它可以在不受其他实体干扰的情况下进行修改,然后重新加载对象。
  • @Martin。感谢您的回答。我需要一个析构函数,因为在消费之后,对象可能会或可能不会被安排重新消费。我无法再次插入对象,因为插入对象的顺序很重要(它们最初是排序的)。
  • @Mooding 感谢您的回答。同样的原因会阻止使用多个列表。每个列表都应该保持排序,我不能在 O(1) 中的另一个列表中间插入一个对象。总之,不能重新插入任何对象。

标签: c++ linux multithreading boost linked-list


【解决方案1】:

性能非常重要。因此,我想使用原子操作或细粒度锁(每个对象一个)来避免不必要的阻塞。

这会增加竞争(访问相同数据)线程在不同内核上同时运行的可能性,从而降低性能。如果锁太细,线程可能会竞争(它们的缓存之间的乒乓数据)并以缓慢的锁步运行,而不会阻塞锁,从而导致糟糕的性能。

您希望使用足够粗糙的锁,以便争用相同数据的线程尽快相互阻塞。这将强制调度程序调度非竞争线程,消除破坏性能的缓存乒乓。

您有一个普遍的误解,即阻止是不好的。事实上,争用是不好的,因为它会降低核心到总线速度。阻塞结束争用。阻塞很好,因为它取消了竞争线程的调度,允许调度非竞争线程(可以全速并发运行)。

【讨论】:

  • 如果只有链表操作(点分配)在锁下完成,那么为队列设置一个标准操作系统锁应该可以正常工作。
  • 我想使用细粒度锁来避免争用。每个线程都有一些目标并且不会接触其他对象。例如,每个消费者只对分配给它的对象感兴趣。使用粗锁将阻止所有消费者(以及修改器和生产者)对其目标进行操作。我知道使用细粒度锁有一些缺点,但好处要多得多,特别是在所描述的情况下。
  • +1 用于解释自旋锁的问题,即由于自旋锁争用,就绪线程数接近或超过内核数。省去了我的麻烦 :) 有了这种复杂的要求,我当然希望首先让系统以功能正确的方式工作,使用内核锁,并进行繁重的负载测试。
  • @Shayan:您可能需要将保护对象的锁与保护集合的锁分开。
【解决方案2】:

如果您已经计划使用 Boost Asio,那么好消息!您现在可以停止编写自定义异步生产者-消费者队列。

Boost Asio io_service 类是一个异步队列,因此您可以轻松地使用它来将对象从生产者传递给消费者。使用io_service::post() 方法将绑定的函数对象加入队列以供另一个线程异步回调。

boost::asio::io_service io_service_;

void produce()
{
    M* m = new M;
    io_service_.post(boost::bind(&consume, m));
}

void consume(M* m)
{
    delete m;
}

让你的生产者线程调用produce(),然后让你的消费者线程调用io_service_.run(),然后consume()将在你的消费者线程上被回调。即时生产者-消费者!

此外,如果您愿意,您可以将各种其他异构事件排入io_service_ 以由您的消费者线程处理,例如网络读取和等待信号。 Boost Asio 不仅仅是一个网络库——它还是一种表达前摄器、反应器、生产者-消费者、线程池或任何其他类型的线程架构的简单方法。

编辑

哦,还有一个提示。不要单独创建专用生产者线程和专用消费者线程池。只需为您的机器上可用的每个内核创建一个线程(4 核机器 => 4 个线程)。然后让所有这些线程调用io_service_.run()。使用io_service_ 从文件或网络或其他任何地方异步读取要生成的内容,然后再次使用io_service_ 异步使用生成的内容。

这是性能最高的线程架构。每个内核一个线程。

【讨论】:

  • 感谢您的回答。我打算使用 io_service 来异步运行任务,我在模型中所说的只是一个抽象。然而,io_service 的问题(特性)是它不能保护任务的并发执行(除非我们使用链)。因此,即使在使用 io_service 时,我们也需要同步。
  • 是的,确切地说,如果您需要保证对io_service 上的异步事件进行排序,而io_service::run() 被多个线程调用,请使用io_service::strand。您已经清楚地查看了 boost::asio,我认为您应该更仔细地查看。我几乎可以保证,您会发现 Christopher Kohlhoff 已经考虑到您在生产者-消费者队列中可能遇到的所有问题,并提供了解决方案。
【解决方案3】:

正如@David Schwartz 相当指出的那样,阻塞并不总是很慢,并且旋转(在用户空间多线程应用程序中)可能非常危险。

此外,linux pthread 库具有 pthread_mutex 的“智能”实现。它被设计为“轻量级”,即当一个线程试图锁定已经获得的互斥锁时,它会旋转一些时间,在阻塞之前多次尝试获取锁。尝试次数不足以损害您的系统甚至破坏实时要求(如果有)。额外的 linux 特定功能是所谓的fast user space mutex (FUTEX),它减少了系统调用的数量。主要思想是它只会在线程真的需要阻塞互斥体时才会执行 mutex_lock 系统调用(当线程锁定未获取的互斥体时,它不会执行系统调用)。

实际上在大多数情况下,您不需要重新发明轮子或引入一些非常具体的锁定技术。如果必须这样做,那么要么设计有问题,要么您正在处理高度并发的环境(乍一看,10 个消费者似乎并不这样,所有这些似乎都过度工程化了)。

  • 如果我是你,我更愿意使用条件变量 + 互斥锁来保护列表。
  • 我要做的另一件事是再次检查设计。当消费者需要进行搜索以查明列表是否包含带有其 ID 的项目时,为什么要使用一个全局列表(如果是,则将其删除/出列)?为每个消费者制作一个单独的列表会更好吗?在这种情况下,您可能可以摆脱状态字段。
  • 读访问是否比写访问更频繁?如果是这样,最好使用 R/W 锁或RCU
  • 如果我对 pthread 原语和 futex 的东西不满意(如果我不满意,我会通过测试证明锁定原语是瓶颈,而不是消费者的数量或我选择的算法),然后我会尝试考虑复杂的算法,包括引用计数、单独的 GC 线程以及将所有更新限制为原子的。

【讨论】:

    【解决方案4】:

    我会建议你用一种稍微不同的方法来解决这个问题:

    生产者:将对象排入共享队列 (SQ) 的末尾。醒来 通过信号量的修饰符。

    producer()
    {
      while (true)
      {
        o = get_object_from_somewhere ()
        atomic_enqueue (SQ.queue, o)
        signal(SQ.sem)
      }
    }
    

    消费者:从每个消费者队列 (CQ[i]) 的前面对对象进行 Deque。

    consumer()
    {
      while (true)
      {
        wait (CQ[self].sem)
        o = atomic_dequeue (CQ[self].queue)
        process (o)
        destroy (o)
      }
    }
    

    析构函数:析构函数不存在,在消费者完成后 一个对象,消费者销毁它。

    修饰符:修饰符从共享队列中取出对象, 处理它们并将它们排入适当消费者的私有队列。

    modifier()
    {
      while (true)
      {
        wait (SQ.sem)
        o = atomic_dequeue (SQ.queue)
        FSM (o)
        atomic_enqueue (CQ [o.status].queue, o)
        signal (CQ [o.status].sem)
      }
    }
    

    伪代码中各种atomic_xxx 函数的注释:this 并不一定意味着使用 CAS、CAS2 等原子指令, LL/SC 等。它可以使用原子、自旋锁或普通互斥锁。一世 会建议以最直接的方式实施它 (例如互斥锁)并在以后对其进行优化,如果它被证明是 性能问题。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2012-02-20
      • 2011-10-12
      • 1970-01-01
      • 2015-02-02
      • 1970-01-01
      • 1970-01-01
      • 2010-12-13
      • 1970-01-01
      相关资源
      最近更新 更多