锁定与ToArray:用于线程安全的List集合foreach访问

26

我有一个List集合,希望在多线程应用程序中对其进行迭代。每次迭代时,我需要保护它,因为它可能会发生更改,我不希望在使用foreach时出现“集合已修改”异常。

正确的方法是什么?

  1. 每次访问或循环时都使用锁。我非常担心死锁。也许我只是过于担心了使用锁,不该这样。如果我选择这个方法,我需要知道什么才能避免死锁?锁是否相当有效率?

  2. 使用List<>.ToArray()在每次使用foreach时复制到数组中。这会导致性能下降,但很容易做到。我担心内存抖动以及复制所需的时间。使用ToArray是否线程安全?

  3. 不要使用foreach,改用for循环。这样做是否需要每次进行长度检查以确保列表没有缩小?那看起来很麻烦。


列表可能是当前最安全的线程安全数据结构! - Egon
1
您可以使用CHESS - 自动并发测试工具MSDN来测试您的假设。 - Robert Paulson
5个回答

48

不必害怕死锁,因为很容易检测出来。当程序停止运行时,就会暴露出来。真正需要担心的是线程竞争问题,这种错误发生在你没有进行必要的锁定时。非常难以诊断。

  1. 使用锁是没问题的,只要确保在任何访问列表的代码中都使用完全相同的锁对象。例如,添加或删除列表项的代码。如果该代码在遍历列表的同一线程上运行,则不需要锁定。通常情况下,唯一可能导致死锁的机会是当你有依赖于线程状态(如Thread.Join())的代码,同时也持有该锁定对象。这应该很少出现。

  2. 是的,迭代列表的副本始终是线程安全的,只要在ToArray()方法周围使用锁即可。请注意,你仍然需要锁定,没有结构性改进。优点在于你将短暂地保持锁定状态,提高程序的并发性。缺点是其O(n)的存储需求、只保护列表中的元素而不是整个列表以及始终具有旧的列表内容视图的棘手问题。特别是最后一个问题很微妙,很难分析。如果你无法理清副作用,那么可能不应考虑此方法。

  3. 确保将foreach检测到的竞争能力视为一种礼物而不是问题。是的,显式的for(;;)循环不会抛出异常,它只会发生故障。例如迭代相同的项两次或完全跳过某个项。你可以通过反向迭代来避免重新检查项目数。只要其他线程只调用Add()而不是Remove(),就会执行类似于ToArray()的操作,得到旧的列表视图。请注意,这在实践中不起作用,因为对列表进行索引也不是线程安全的。List<>将在必要时重新分配其内部数组。这样做只会导致无法预测的故障。

这里有两种观点。你可以感到恐慌并遵循通常的智慧,这样你会得到一个能用但可能不是最优的程序。这是明智的做法,能让老板满意。或者你可以进行实验,并自己发现如何违反规则会给你带来麻烦。这会让你快乐,成为一个更好的程序员。但你的生产力会受到影响。我不知道你的时间表是什么样子。


11

如果您的列表数据大多是只读的,您可以使用ReaderWriterLockSlim允许多个线程同时安全地访问它。

您可以在这里找到一个线程安全字典的实现,以便您开始使用。

另外,我想提醒一下,如果您使用的是 .Net 4.0,则BlockingCollection 类会自动实现此功能。 我希望几个月前我已经知道了这个!


2
ReaderWriterLockSlim是一个很好的选择。它允许多个线程同时枚举集合,而不像简单锁只允许一个线程。它也不会产生ToArray()所涉及的开销。 - Jack Leitch

5

您还可以考虑使用不可变数据结构-将列表视为值类型。

如果可能的话,使用不可变对象可以是多线程编程的绝佳选择,因为它们消除了所有笨重的锁定语义。基本上,任何更改对象状态的操作都会创建一个全新的对象。

例如,我编写了以下内容来演示这个想法。我很抱歉它绝不是参考代码,并且开始变得有点冗长。

public class ImmutableWidgetList : IEnumerable<Widget>
{
    private List<Widget> _widgets;  // we never modify the list

    // creates an empty list
    public ImmutableWidgetList()
    {
        _widgets = new List<Widget>();
    }

    // creates a list from an enumerator
    public ImmutableWidgetList(IEnumerable<Widget> widgetList)
    {
        _widgets = new List<Widget>(widgetList);
    }

    // add a single item
    public ImmutableWidgetList Add(Widget widget)
    {
        List<Widget> newList = new List<Widget>(_widgets);

        ImmutableWidgetList result = new ImmutableWidgetList();
        result._widgets = newList;
        return result;
    }

    // add a range of items.
    public ImmutableWidgetList AddRange(IEnumerable<Widget> widgets)
    {
        List<Widget> newList = new List<Widget>(_widgets);
        newList.AddRange(widgets);

        ImmutableWidgetList result = new ImmutableWidgetList();
        result._widgets = newList;
        return result;
    }

    // implement IEnumerable<Widget>
    IEnumerator<Widget> IEnumerable<Widget>.GetEnumerator()
    {
        return _widgets.GetEnumerator();
    }


    // implement IEnumerable
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return _widgets.GetEnumerator();
    }
}
  • 我添加了 IEnumerable<T> 以允许使用 foreach 实现。
  • 你提到担心创建新列表的空间/时间性能,所以也许这对你不起作用。
  • 你可能还想实现 IList<T>

4
通常情况下,为了提高性能,集合不具备线程安全性,除了哈希表(Hash Table)。你需要使用IsSynchronized和SyncRoot将它们变得线程安全。请参见这里这里
以下是来自MSDN的示例:
ICollection myCollection = someCollection;
lock(myCollection.SyncRoot)
{
    foreach (object item in myCollection)
    {
        // Insert your code here.
    }
}

编辑:如果您正在使用 .net 4.0,您可以使用并发集合


3

使用lock(),除非你有其他原因需要复制。死锁只会发生在请求多个锁的不同顺序时,例如:

线程1:

lock(A) {
  // .. stuff
  // Next lock request can potentially deadlock with 2
  lock(B) {
    // ... more stuff
  }
}

线程 2:

lock(B) {
  // Different stuff
  // next lock request can potentially deadlock with 1
  lock(A) {
    // More crap
  }
}

在这里,线程1和线程2可能会导致死锁,因为线程1可能持有A而线程2则持有B,双方都无法继续执行直到对方释放锁。

如果您必须获取多个锁,请始终按相同顺序获取。如果只获取一个锁,则不会导致死锁...除非您在等待用户输入时持有锁,但这不是技术上的死锁,并且还需要注意:永远不要保持锁比绝对必要的时间长。


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