【问题标题】:Thread-safe data structure design线程安全的数据结构设计
【发布时间】:2010-03-19 10:37:29
【问题描述】:

我必须设计一个要在多线程环境中使用的数据结构。基本 API 很简单:插入元素、删除元素、检索元素、检查元素是否存在。该结构的实现使用隐式锁定来保证单个 API 调用的原子性。在我实现这个之后,很明显,我真正需要的是跨多个 API 调用的原子性。例如,如果调用者需要在尝试插入元素之前检查元素是否存在,即使每个单独的 API 调用 都是原子的,他也不能原子地执行此操作:

if(!data_structure.exists(element)) {
   data_structure.insert(element);
}

这个例子有点尴尬,但基本的一点是,在我们从原子上下文返回后,我们不能再相信“存在”调用的结果(生成的程序集清楚地显示了两次调用之间的上下文切换的可能性很小)。

我目前想解决这个问题的是通过数据结构的公共 API 公开锁。这样,客户将不得不显式地锁定事物,但至少他们不必创建自己的锁。对于这类问题,是否有更好的众所周知的解决方案?只要我们还在,您能推荐一些关于线程安全设计的优秀文献吗?

编辑:我有一个更好的例子。假设元素检索返回一个引用或指向存储元素的指针,而不是它的副本。调用返回后,如何保护调用者安全地使用此指针\引用?如果您认为不返回副本是个问题,那么请考虑深副本,即对象还应该复制它们在内部指向的另一个对象。

谢谢。

【问题讨论】:

  • 关于您的编辑:想想这种情况,指向存储元素的指针返回,而其他线程试图从 data_structture 中删除该元素。无论 oof 锁定模型,您至少需要选择应该实现的行为。向试图删除对象的线程返回错误?等待对象变为未引用等。

标签: multithreading synchronization data-structures


【解决方案1】:

您要么提供一种外部锁定机制(不好),要么重新设计 API,例如 putIfAbsent。后一种方法例如用于 Java 的 concurrent data-structures.

而且,当涉及到此类基本集合类型时,您应该检查您选择的语言是否已在其标准库中提供它们。

[编辑]澄清一下:外部锁定对类的用户不利,因为它引入了另一个潜在错误来源。是的,有时候,对于并发数据结构的性能考虑确实比外部同步数据结构更糟糕,但这些情况很少见,然后它们通常只能由比我有更多知识/经验的人解决/优化。

在下面的Will's answer 中可以找到一个可能很重要的性能提示。 [/编辑]

[edit2]给定你的新例子:基本上你应该尽量保持集合的同步和元素的分离。如果元素的生命周期与它在一个集合中的存在绑定,那么您将遇到问题;当使用 GC 时,这种问题实际上变得更简单了。否则,您将不得不使用一种代理而不是原始元素才能在集合中;在最简单的 C++ 情况下,您可以使用boost::shared_ptr,它使用原子引用计数。在此处插入通常的性能免责声明。当您使用 C++ 时(正如我在谈论指针和引用时我怀疑的那样),boost::shared_ptrboost::make_shared 的组合应该足够了一段时间。 [/edit2]

【讨论】:

  • 为什么外部锁定不好?可能 API 的用户更了解何时需要锁定事物(例如,您可能有一个集合的实例,它甚至没有在线程之间共享)。出于性能原因,使用外部锁定可能是完全合理的。
  • @Micheal:是的,是的。但是外部锁定将负担转移到数据结构的用户身上。这一切都取决于用户。但是,您是对的,在某些情况下需要外部锁定,但即便如此,我也会使用普通的非并发数据结构并在外部进行所有锁定。
  • @frunsi,这是一个很好的观点......一个对象应该完全是线程安全的,或者根本不是线程安全的。当我在考虑外部锁定时,我认为数据结构将是非并发的,但现在我重读了这篇文章,我发现作者提出了某种混合,这确实非常讨厌。
  • 很抱歉投了反对票。我会支持它,但现在不允许我这样做。
  • 是的,这也发生在我身上一次; SO 只允许您在某个时限内或经过大量编辑(或任何编辑,不知道)后重新投票。我希望通过添加的文本,我现在可以更好地解决性能问题。
【解决方案2】:

有时创建要插入的元素会很昂贵。在这些情况下,您真的负担不起例行创建可能已经存在的对象以防万一。

一种方法是让insertIfAbsent() 方法返回一个被锁定的“光标”——它将一个占位符插入到内部结构中,这样其他线程就不会相信它不存在,但不会插入新对象.占位符可以包含一个锁,以便其他想要访问该特定元素的线程必须等待它被插入。

在像 C++ 这样的 RAII 语言中,您可以使用智能堆栈类来封装返回的游标,以便在调用代码未提交时自动回滚。在 Java 中,finalize() 方法会延迟它一点,但仍然可以工作。

另一种方法是让调用者创建不存在的对象,但如果另一个线程“赢得比赛”,则在实际插入中偶尔会失败。例如,这就是完成 memcache 更新的方式。它可以很好地工作。

【讨论】:

    【解决方案3】:

    将存在性检查移到.insert() 方法中怎么样?客户调用它,如果它返回false,您就知道出了点问题。就像 malloc() 在普通的旧 C 中所做的一样——如果失败,返回 NULL,设置 ERRNO

    显然你也可以返回一个异常,或者一个对象的实例,然后让你的生活变得复杂......

    但请不要依赖用户设置自己的锁。

    【讨论】:

      【解决方案4】:

      以 RAII 风格的方式,您可以创建访问器/句柄对象(不知道它是如何调用的,可能存在这样的模式),例如一个列表:

      template <typename T>
      class List {
          friend class ListHandle<T>;
          // private methods use NO locking
          bool _exists( const T& e ) { ... }
          void _insert( const T& e ) { ... }
          void _lock();
          void _unlock();
      public:
          // public methods use internal locking and just wrap the private methods
          bool exists( const T& e ) {
              raii_lock l;
              return _exists( e );
          }
          void insert( const T& e ) {
              raii_lock l;
              _insert( e );
          }
          ...
      };
      
      template <typename T>
      class ListHandle {
          List<T>& list;
      public:
          ListHandle( List<T>& l ) : list(l) {
              list._lock();
          }
          ~ListHandle() {
              list._unlock();
          }
          bool exists( const T& e ) { return list._exists(e); }
          void insert( const T& e ) { list._insert(e); }
      };
      
      
      List<int> list;
      
      void foo() {
          ListHandle<int> hndl( list ); // locks the list as long as it exists
          if( hndl.exists(element) ) {
              hndl.insert(element);
          }
          // list is unlocked here (ListHandle destructor)
      }
      

      您复制(甚至三份)公共界面,但您可以让用户在需要时在内部和安全和舒适的外部锁定之间进行选择。

      【讨论】:

      • 这有点矫枉过正......如果他想使用锁,那么他可以写一个insertIfNotFound方法: bool insertIfNotFound(T element){ lock.lock();试试 { if(!data_structure.exists(element)) { data_structure.insert(element);返回真; } 返回 false } 最后 { lock.unlock(); } }
      • @Lirik:是否矫枉过正很大程度上取决于使用情况。实际上,我根本不使用“并发”数据结构,而是或多或少地使用外部锁定。但是这里这个东西是有道理的,如果你有比insertIf[not]Found更复杂的需求(那些用例存在),那么这种方式就可以了。此外,过度杀戮将被优化掉,所以它只是大量的打字。
      • @frunsi insertIfNotFound 主要被称为putIfAbsent,这是并发数据结构中的常用方法(澄清我自己,我相信你已经看过它)。所以你的并发数据结构的替代方案是外部锁定?
      • @Lirik:我的答案中提供了我的替代方案,非常详细!阅读它...答案包含在其中。 putIfAbsent 和/或任何其他 doIfBlabla 方法适用于通常情况,可以帮助您解决大多数给定问题,但我的方法(再次阅读我的答案)是一种通用解决方案。
      【解决方案5】:

      首先,您应该真正分离您的关注点。您需要担心两件事:

      1. 数据结构及其方法。
      2. 线程同步。

      我强烈建议您使用表示您正在实现的数据结构类型的接口或虚拟基类。创建一个根本不执行任何锁定的简单实现。然后,创建第二个实现,包装第一个实现并在其之上添加锁定。这将允许在不需要锁定的情况下实现更高性能的实现,并将大大简化您的代码。

      看起来您正在实现某种字典。您可以做的一件事是提供具有与组合语句等效的语义的方法。例如setdefault 是一个合理的函数,仅当字典中不存在对应的键时才会设置一个值。

      换句话说,我的建议是找出经常一起使用的方法组合,并简单地创建以原子方式执行该操作组合的 API 方法。

      【讨论】:

        猜你喜欢
        • 2013-07-30
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-01-22
        • 2011-01-31
        • 2020-01-28
        相关资源
        最近更新 更多