C#多线程列表操作

5
如果我有这样的东西(伪代码):
class A
{
    List<SomeClass> list;

    private void clearList()
    {
        list = new List<SomeClass>();
    }

    private void addElement()
    {
        list.Add(new SomeClass(...));
    }
}

当这两个函数并行执行时,是否可能遇到多线程问题(或任何意外行为)?

使用案例是一个错误列表,可以随时清除(通过简单地分配一个新的空列表)。

编辑:我的假设是

  • 只有一个线程添加元素
  • 遗忘的元素是可以接受的(即在清除和添加新元素之间存在竞争条件),只要清除操作成功即可
  • .NET 2.0
6个回答

10

这里存在两个问题的可能性:

  • 新增项可能会立即被遗忘,因为您清空并创建了一个新列表。这是一个问题吗?基本上,如果同时调用AddElementClearList,则存在竞争条件:要么元素最终会出现在新列表中,要么出现在旧(遗忘的)列表中。
  • List<T>不适用于多线程修改,因此如果两个不同的线程同时调用AddElement,结果不能保证

考虑到您正在访问共享资源,我建议在访问时加锁。尽管如此,在添加或删除项目之前/之后立即清除列表的可能性仍然需要考虑。

编辑:我对它只从一个线程添加时没问题的评论已经有点可疑了,原因有两个:

  • 您可能会尝试添加到尚未完全构造的List<T>中。我不确定,而且.NET 2.0内存模型(与ECMA规范中的模型不同)可能足够强大,可以避免该问题,但很难说。
  • 添加线程可能无法立即“看到”list变量的更改,仍将添加到旧列表中。实际上,在没有任何同步的情况下,它可能永远看不到新值

当您将“在GUI中迭代”加入混合物时,情况变得非常棘手,因为您无法在迭代时更改列表。这个问题的最简单解决方案可能是提供一个方法,该方法返回列表的副本,然后UI可以安全地迭代:

class A
{
    private List<SomeClass> list;
    private readonly object listLock = new object();

    private void ClearList()
    {
        lock (listLock)
        {
            list = new List<SomeClass>();
        }
    }

    private void AddElement()
    {
        lock (listLock)
        {
            list.Add(new SomeClass(...));
        }
    }

    private List<SomeClass> CopyList()
    {
        lock (listLock)
        {
            return new List<SomeClass>(list);
        }
    }

}

只有一个线程在添加元素,所以第二个点对我来说不是问题。我知道竞态条件的存在,但对我来说并不是很重要——但我根本没有调用Clear,而是创建了一个新的列表。如果同时调用ClearAdd会有问题吗(您说List不是线程安全的)? - AndiDog
抱歉,我是指AddElement和ClearList。只创建一个新列表应该没问题,如果您只从单个线程添加,那就没问题了。不要忘记,当您从列表中读取时,可能会出现进一步的复杂情况 - 这只会在执行Add操作的同一线程中发生吗? - Jon Skeet
不,另一个GUI线程应该能够迭代它。这将在几周内实现。如果GUI使用foreach(Element e in instanceOfC.list) { ... },那么这会引起麻烦吗?如果我在此迭代期间分配一个新的List,GUI仍将在旧列表上工作,对吗? - AndiDog
抱歉,我忘记了另外几个问题 - 我会在几个小时后回家更新。 - Jon Skeet
谢谢您的编辑。我已经考虑过复制列表供GUI使用了。还有一件事: “确实,如果没有任何同步,它可能会永远看到旧值” - 您有参考资料吗?这样的缓存将是可怕的。 - AndiDog
@AndiDog:在大多数情况下,这更像是一个理论问题而不是实际问题 - 但基本上看一下内存模型并查看实际上有什么保证。除非您有适当的内存屏障,否则您的读取可以有效地“向后移动”(例如通过缓存)永远无法结束。 - Jon Skeet

2

是的 - 这是可能的。实际上,如果这些确实同时被调用,那么很有可能会发生。

此外,如果两个单独的addElement调用同时发生,也很可能会引起问题。

对于这种多线程情况,您真的需要在列表本身周围使用某种互斥锁,以便只能同时调用底层列表上的一个操作。

粗略的锁定策略可以帮助解决这个问题。例如:

class A
{
    static object myLock = new object()
    List<SomeClass> list;

    private void clearList()
    {
        lock(myLock)
        {
          list = new List<SomeClass>();
        }

    }

    private void addElement()
    {
        lock(myLock)
        {
          list.Add(new SomeClass(...));
        }
    }
}

2

.NET中的集合(3.5及以下版本)不是线程安全或非阻塞(并行执行)。您应该通过从IList派生并使用ReaderWriterLockSlim来执行每个操作来实现自己的集合。例如,您的Add方法应该如下所示:

    public void Add(T item)
    {
        _readerWriterLockSlim.EnterWriteLock();
        try { _actualList.Add(item); }
        finally { _readerWriterLockSlim.ExitWriteLock(); }
    }

在这里,您必须了解一些并发技巧。例如,您必须有一个GetEnumerator方法,它返回一个新实例作为IList;而不是实际的列表。否则,您将遇到问题;其应该看起来像:

    public IEnumerator<T> GetEnumerator()
    {
        List<T> localList;

        _lock.EnterReadLock();
        try { localList= new List<T>(_actualList); }
        finally { _lock.ExitReadLock(); }

        foreach (T item in localList) yield return item;
    }

并且:

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return ((IEnumerable<T>)this).GetEnumerator();
    }

注意:当实现线程安全或并行集合(事实上,每个其他类)时,请不要派生自类,而是接口!因为总会存在与该类的内部结构或某些方法相关的问题,这些方法不是虚拟的,您必须隐藏它们等等。如果必须这样做,请非常小心!

1

当你想要清空一个列表时,仅仅创建一个新的列表可能不是一个好主意。

我假设你在构造函数中分配了列表,这样就不会遇到空指针异常。

如果你清空了列表并添加了元素,它们可以被添加到旧列表中,这应该没问题吧?但是如果同时添加了两个元素,就可能会遇到问题。

研究一下 .Net 4 中处理多线程任务的新集合 :)

补充: 如果你使用 .Net 4,请查看 System.Collections.Concurrent 命名空间。在那里你会找到: System.Collections.Concurrent.ConcurrentBag<T> 和许多其他好用的集合 :)

你还应该注意,如果不小心使用锁,会显著降低性能。


抱歉,我没有提到我正在使用.NET 2.0 - 所以.NET 2.0内置了线程安全集合吗?还是必须使用“lock”? - AndiDog
嗯,我会创建自己的类,比如ThreadSafeList,在其中将方法锁定,而不是明确地声明lock等。这样,您就不必多次编写锁定代码(也许还会忘记)。 - Lasse Espeholt
我刚刚发现这个链接:http://blogs.msdn.com/jaredpar/archive/2009/02/11/why-are-thread-safe-collections-so-hard.aspx 但是在使用之前请先检查代码 :) - Lasse Espeholt

1
如果您在多个线程中使用此类的一个实例,那么是的,您将遇到问题。.Net框架(版本3.5及以下)中的所有集合都不是线程安全的。特别是当您开始更改集合时,而另一个线程正在迭代它时。
在多线程环境中使用锁定并提供“副本”集合,或者如果您可以使用.Net 4.0,则使用新的并发集合。

0

从您的问题编辑中可以看出,您并不真正关心通常在此出现的罪犯 - 没有同一对象的方法的同时调用。

本质上,您正在询问是否可以在并行线程访问列表时将引用分配给它。

据我所知,它仍然可能会导致麻烦。这完全取决于硬件级别上引用分配的实现方式。更准确地说,这个操作是否是原子性的。

我认为尽管可能性很小,在多处理器环境中仍然存在机会,即进程会因为在访问时只被部分更新而得到破坏的引用。


我不相信硬件差异或引用分配会在这里造成问题。就我所了解的C#规范(第5.5节:http://msdn.microsoft.com/en-us/library/aa691278%28VS.71%29.aspx),引用分配应该是原子的。 - AndiDog

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