List.Add() 线程安全性

109

我知道一般来说,List 不是线程安全的,但如果线程从未执行其他操作(比如遍历),那么仅向 List 中添加项目会有问题吗?

例子:

List<object> list = new List<object>();
Parallel.ForEach(transactions, tran =>
{
    list.Add(new object());
});

4
这是“List<T> 线程安全性”问题的完全重复。 - JK.
2
我曾经使用过List<T>来添加多个并行任务中的新对象。有时候,非常罕见的情况下,在所有任务完成后遍历列表时,会出现一个记录为空的情况。如果没有额外的线程参与,这几乎是不可能发生的。我猜想,在列表在内部重新分配其元素以扩展时,某个线程通过尝试添加另一个对象而将其搞乱了。因此,这样做并不是一个好主意! - osmiumbin
目前我正在看到的情况与@osmiumbin相同,即在从多个线程简单添加时,一个对象不可思议地为空。感谢确认。 - Blackey
这个回答解决了你的问题吗?线程安全的List<T>属性 - Michael Freidgeim
9个回答

81
在幕后,许多事情都在发生,包括重新分配缓冲区和复制元素。那段代码会造成危险。简单来说,当向列表添加元素时,没有原子操作,至少需要更新Length属性,并将项目放在正确的位置,(如果有单独的变量)需要更新索引。多个线程可能会相互干扰。如果需要扩展,则会有更多的操作。如果有东西正在写入列表,其他任何东西都不应该读取或写入它。
在.NET 4.0中,我们有并发集合,它们非常方便地支持多线程,并且不需要锁。

这非常有道理,我一定会查看新的并发集合。谢谢。 - e36M3
18
请注意,.NET Framework 中没有内置的“ConcurrentList”类型。虽然有并发的 bags、dictionaries、stacks、queues 等等,但是没有 lists。要实现并发列表需要自己编写代码。 - LukeH

16

你目前的方法不是线程安全的 - 我建议完全避免使用这种方法 - 因为你基本上进行了数据转换,PLINQ可能是更好的方法(我知道这只是一个简化的例子,但最终你会将每个事务投影到另一个“状态”对象中)。

List<object> list = transactions.AsParallel()
                                .Select( tran => new object())
                                .ToList();

我提供了一个过于简化的例子来强调我感兴趣的List.Add方面。实际上,我的Parallel.Foreach将会完成大量工作,并不是简单的数据转换。谢谢。 - e36M3
4
如果不必要地使用并发集合,可能会影响您的并行性能。另一种方法是使用固定大小的数组,并使用Parallel.ForEach重载,该重载接受索引 - 在这种情况下,每个线程都在操作不同的数组条目,因此您可以放心使用。 - BrokenGlass

10

我使用 ConcurrentBag<T> 替代了 List<T>,这样解决了我的问题:

ConcurrentBag<object> list = new ConcurrentBag<object>();
Parallel.ForEach(transactions, tran =>
{
    list.Add(new object());
});

请注意,ConcurrentBag不遵守List的顺序。 - Mario Codes

7
如果您想从多个线程使用List.add并且不关心排序,那么您可能根本不需要List的索引功能,而应该使用一些可用的并发集合。
如果您忽略这个建议,只使用add,您可以使add线程安全,但顺序是不可预测的,如下所示:
private Object someListLock = new Object(); // only once

...

lock (someListLock)
{
    someList.Add(item);
}

如果您接受这种不可预测的排序,那么很可能您不需要一个可以进行索引的集合,例如someList[i]

6

这不是一个不合理的要求。有些情况下,某些方法与其他方法组合使用可能会导致线程安全问题,但如果它们是唯一被调用的方法,则是安全的。

然而,当您考虑在反射器中显示的代码时,这显然不是这种情况:

public void Add(T item)
{
    if (this._size == this._items.Length)
    {
        this.EnsureCapacity(this._size + 1);
    }
    this._items[this._size++] = item;
    this._version++;
}

即使 EnsureCapacity 本身是线程安全的(实际上它并不是),上述代码显然不会是线程安全的,因为同时调用增量运算符可能导致错误写入。您可以锁定、使用 ConcurrentList,或者使用无锁队列作为多个线程写入的位置,并在完成工作后从中读取 - 直接或通过填充列表 - (我假设多个同时写入后由单个线程读取是您的模式,根据您的问题判断,否则我无法看到仅调用 Add 方法的条件有任何用处)。

5
这会引起问题,因为List是建立在数组之上的,而且不是线程安全的,你可能会得到索引超出范围异常或某些值覆盖其他值,取决于线程所在的位置。基本上,不要这样做。
存在多个潜在的问题……不要这样做。如果需要线程安全的集合,请使用锁或System.Collections.Concurrent集合之一。

3

如果线程从未对列表执行其他操作,仅将项添加到列表中是否有任何问题?

简短回答:是的。

详细回答:运行以下程序。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

class Program
{
    readonly List<int> l = new List<int>();
    const int amount = 1000;
    int toFinish = amount;
    readonly AutoResetEvent are = new AutoResetEvent(false);

    static void Main()
    {
        new Program().Run();
    }

    void Run()
    {
        for (int i = 0; i < amount; i++)
            new Thread(AddTol).Start(i);

        are.WaitOne();

        if (l.Count != amount ||
            l.Distinct().Count() != amount ||
            l.Min() < 0 ||
            l.Max() >= amount)
            throw new Exception("omg corrupted data");

        Console.WriteLine("All good");
        Console.ReadKey();
    }

    void AddTol(object o)
    {
        // uncomment to fix
        // lock (l) 
        l.Add((int)o);

        int i = Interlocked.Decrement(ref toFinish);

        if (i == 0)
            are.Set();
    }
}

@royi 你是在单核机器上运行吗? - Bas Smit
嗨,我认为这个例子存在问题,因为它在找到数字1000时就设置了AutoResetEvent。由于它可以随时处理这些线程,所以它可能会在处理999之前就达到1000。如果在AddTol方法中添加Console.WriteLine,则会发现编号不是按顺序的。 - Dave Walker
@dave,当i == 0时设置事件。 - Bas Smit

2

正如其他人所说,您可以使用System.Collections.Concurrent命名空间中的并发集合。如果您可以使用其中之一,则应首选。

但是,如果您真的想要一个仅同步的列表,您可以查看System.Collections.Generic中的SynchronizedCollection<T>类。

请注意,您必须包含System.ServiceModel程序集,这也是我不太喜欢它的原因。但有时我会使用它。


0

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