这个 Parallel.ForEach() 的使用是否线程安全?

24

基本上,我正在使用这个:

var data = input.AsParallel();
List<String> output = new List<String>();

Parallel.ForEach<String>(data, line => {
    String outputLine = ""; 
    // ** Do something with "line" and store result in "outputLine" **

    // Additionally, there are some this.Invoke statements for updating UI

    output.Add(outputLine);
});

输入是一个List<String>对象。 ForEach()语句对每个值进行一些处理,更新UI,并将结果添加到outputList中。这种方法本身有什么问题吗?

注:

  • 输出顺序不重要

更新:

根据我收到的反馈,我已经在output.Add语句和UI更新代码中手动添加了lock


1
你对“线程安全”的定义是什么?输出顺序是否重要?如果您提供所有相关代码,将会更有帮助。 - Sanjeevakumar Hiremath
@Sanjeevakumar Hiremath:抱歉,我应该包括那些细节。 - Chris Laplante
2
我编辑了我的答案,展示了一种不涉及lock等的方法。 - Marc Gravell
4个回答

35

是的;List<T>不是线程安全的,因此从任意线程(ad-hoc)随意地添加元素(很可能同时进行)注定会出错。你应该使用线程安全的列表,或手动添加锁。或者也许有一个Parallel.ToList

还有,如果需要的话:插入顺序不能保证。

然而,这个版本是安全的:

var output = new string[data.Count];

Parallel.ForEach<String>(data, (line,state,index) =>
{
    String outputLine = index.ToString();
    // ** Do something with "line" and store result in "outputLine" **

    // Additionally, there are some this.Invoke statements for updating UI
    output[index] = outputLine;
});

在这里,我们使用index来更新每个并行调用的不同数组索引。


谢谢!幸运的是,在我的情况下,输出顺序不重要,因此我只是在 add 语句外面加了一个锁。 - Chris Laplante
1
没有Parallel.ToList,但是有ConcurrentQueue、ConcurrentStack和其他与TPL同时设计的类。它们是线程安全且易于使用的。由于顺序不重要,它们在这里可以使用。而且没有锁! - Scott Wylie
2
还有.AsParallel()和ParallelEnumerable扩展方法ToList() - Scott Chamberlain
2
请注意,在并行编程中向数组进行写入,虽然线程安全,但不一定会很快。当多个线程尝试写入驻留在同一内存块中的索引时,它们会互相等待。我还没有将其与线程安全集合进行比较,但是可能会有相同的限制,因为这是底层的东西。 - Timo

14

这段代码有什么本质问题吗?

有,全都有。这段代码存在潜在的安全隐患。在多个线程同时更新列表时,列表不安全;在非 UI 线程中不能更新 UI。


1
@SimpleCoder:你可能会发现,与将工作交给多个核心相比,你花费的时间在争夺锁方面更多。如果工作相对于将其添加到锁定列表中的成本不是很昂贵,那么通过并行处理来做这件事情可能会让你的程序变得更慢。你可以尝试使用专为同步设计的集合,这样也许能获得更好的结果。 - Eric Lippert
1
@SimpleCoder:在UI更新中添加锁定错误的。UI是单元线程化,而不是租赁式线程化。UI只能从一个线程,即指定的UI线程中调用。加上锁然后从错误的线程中调用仍然是非法的。如果你想从工作线程更新UI,那么工作线程必须向UI线程发出信号,告诉UI线程需要进行工作。 - Eric Lippert
@SimpleCoder - lock 对于 UI 来说是不够的,你需要使用本地的“在 UI 上执行此操作”的代码;对于 WinForms,请使用 Control.Invoke,对于 WPF,请使用 Dispatcher.Invoke - Marc Gravell
2
@SimpleCoder - 那个lock是多余的;消息队列本身就是线程安全的(为了正常工作它必须如此)。因此,你可以从任何地方同时进行Invoke操作。添加一个lock有可能导致死锁,所以我建议将那个去掉。 - Marc Gravell
@SimpleCoder,这并不重要,但是如果我没记错的话,在这种情况下MethodInvoker会得到特殊处理,避免使用DynamicInvoke。虽然签名相同,但作为一种微观优化(即几乎肯定不会有影响),您可能希望使用MethodInvoker updateProgressBar = ... - Marc Gravell
显示剩余2条评论

5

文档中提到了关于List<T>的线程安全性:

该类型的公共静态成员(在 Visual Basic 中为 Shared 成员)是线程安全的。任何实例成员都不能保证是线程安全的。

List(Of T) 可以支持多个读取器同时访问,只要集合不被修改即可。枚举集合本质上不是一个线程安全的过程。在罕见的情况下,如果枚举与一个或多个写访问竞争,则确保线程安全的唯一方法是在整个枚举期间锁定集合。为了允许多个线程访问和写入集合,必须实现自己的同步。

因此,output.Add(outputLine) 不是线程安全的,您需要自行确保线程安全,例如通过将添加操作包装在 lock 语句中。


0

当您需要并行操作的结果时,PLINQParallel 类更方便。您可以通过将 input 转换为 ParallelQuery<T> 来开始:

ParallelQuery<string> data = input.AsParallel();

...但是你把data传递给了Parallel.ForEach,它将其视为标准的IEnumerable<T>。所以AsParallel()是浪费的。它没有提供任何并行化,只增加了开销。这里是使用PLINQ的正确方法:

List<string> output = input
    .AsParallel()
    .Select(line =>
    {
        string outputLine = ""; 
        // ** Do something with "line" and store result in "outputLine" **
        return outputLine;
    })
    .ToList();

你需要注意以下几点区别:

  1. Parallel 默认在 ThreadPool 上运行代码,但它是 可配置的。PLINQ 仅使用 ThreadPool
  2. Parallel 默认具有无限并行性(它使用 ThreadPool 的所有可用线程)。PLINQ 默认使用最多 Environment.ProcessorCount 线程。

关于结果的顺序,PLINQ 默认不保留顺序。如果您想保留顺序,可以附加 AsOrdered 运算符。


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