Parallel.ForEach 的不同求和结果

Posted

技术标签:

【中文标题】Parallel.ForEach 的不同求和结果【英文标题】:Different summation results with Parallel.ForEach 【发布时间】:2010-07-29 21:58:33 【问题描述】:

我有一个正在并行化的foreach 循环,我注意到一些奇怪的东西。代码看起来像

double sum = 0.0;

Parallel.ForEach(myCollection, arg =>

     sum += ComplicatedFunction(arg);
);

// Use sum variable below

当我使用常规的 foreach 循环时,我会得到不同的结果。 ComplicatedFunction 内部可能有更深层次的东西,但 sum 变量可能会意外地受到并行化的影响?

【问题讨论】:

参见 [在 parallel.foreach 范围外增加一个计数值](***.com/questions/2394447/…)。基本上,如果需要,您可以使用Interlocked,但最好尽可能避免副作用。 【参考方案1】:

sum 变量是否可能意外地受到并行化的影响?

是的。 对double 的访问不是原子的,sum += ... 操作永远不是线程安全的,即使对于原子类型也是如此。所以你有多个竞争条件,结果是不可预测的。

你可以使用类似的东西:

double sum = myCollection.AsParallel().Sum(arg => ComplicatedFunction(arg));

或者,简而言之

double sum = myCollection.AsParallel().Sum(ComplicatedFunction);

【讨论】:

根据原帖,myCollection 是否需要线程安全? @Kevin - 不,任何IEnumerable<T> 都可以。 是因为Parallel.ForEach 知道如何以安全的方式处理线程不安全的集合吗? (另外,为了清楚起见,原文我指的是 Brian Triplett 的原始问题,而不是您的解决方案)。 是的,.ForEach() 和 .Asparallel() 都设计用于普通集合。 “变量int sum 本来是原子的” - 这在哪里可以保证是真的?【参考方案2】:

与提到的其他答案一样,从多个线程更新 sum 变量(这是 Parallel.ForEach 所做的)不是线程安全操作。在进行更新之前获取锁的简单修复将解决那个问题。

double sum = 0.0;
Parallel.ForEach(myCollection, arg => 
 
  lock (myCollection)
  
    sum += ComplicatedFunction(arg);
  
);

然而,这又带来了另一个问题。由于每次迭代都会获取锁,因此这意味着每次迭代的执行都将被有效地序列化。换句话说,最好只使用一个普通的旧 foreach 循环。

现在,正确解决此问题的诀窍是将问题划分为独立的卡盘。幸运的是,当您只想对迭代结果求和时,这非常容易做到,因为求和运算是可交换和关联的,而且迭代的中间结果是独立的。

这就是你的做法。

double sum = 0.0;
Parallel.ForEach(myCollection,
    () => // Initializer
    
        return 0D;
    ,
    (item, state, subtotal) => // Loop body
    
        return subtotal += ComplicatedFunction(item);
    ,
    (subtotal) => // Accumulator
    
        lock (myCollection)
        
          sum += subtotal;
        
    );

【讨论】:

你为什么要鼓励改造一个非常标准的***? @Novelocrat:对不起,我不清楚你在问什么。另外,由于时间原因,我可以假设您对这个答案投了反对票吗?如果是这样,答案的哪一部分是错误的?我仔细检查了代码语法,分区策略是进行Parallel.For 操作的一种非常成熟的做法,但也许我错过了一些引起你注意的东西。 您描述的实现正是 Henk 的答案描述的库函数。此外,我强烈怀疑在库实现中减少每个线程的小计(“累加器”)比基于锁的方法更有效。 是的,我也更喜欢 Henk 的回答。事实上,我是赞成票之一。只是好奇......这个答案中的什么使它更有害而不是有用,我可以改变什么来使它更有帮助?请记住,我想保留我的初衷,即演示如何以更一般的方式处理并行化。否则,根据 Henk 的帖子保留答案没有多大意义。 哦,是的,使用 PLinq 求和操作更有效地累积分区,因为不使用任何锁或线程同步原语。这部分操作同步发生在调用者线程上。不幸的是,Parallel.For 不支持相同的习语。使用Parallel.For 方法的无锁累积模式会稍微复杂一些,当然也更难解释,因为它需要在while 循环中调用Interlocked.CompareExchange【参考方案3】:

如果你认为sum += ComplicatedFunction实际上是由一堆操作组成的,那么说:

r1 <- Load current value of sum
r2 <- ComplicatedFunction(...)
r1 <- r1 + r2

所以现在我们随机交错两个(或更多)并行实例。一个线程可能持有一个陈旧的“旧值” sum 用于执行其计算,其结果在 sum 的某些修改版本之上写回。这是一个典型的竞争条件,因为基于交错的完成方式,一些结果会以一种不确定的方式丢失。

【讨论】:

你说得对,但实际上情况比你说的要糟糕得多。不仅仅是加载、计算和存储操作不是原子的。甚至不能保证访问双精度中的 是原子的! C# 规范仅保证访问 32 位(和更小)数字类型和引用是原子的。双打是 64 位的,因此不能保证是原子的。该程序可以实现为: r1 一半已复制。 说得好。我想在示例中为简单起见,我只是假设基本操作的原子性,但显然正如您所指出的,最坏的情况更加可怕。【参考方案4】:

或者您可以使用.Net 中正确定义的并行聚合操作。这是代码

        object locker = new object();
        double sum= 0.0;
        Parallel.ForEach(mArray,
                        () => 0.0,                 // Initialize the local value.
                        (i, state, localResult) => localResult + ComplicatedFunction(i), localTotal =>   // Body delegate which returns the new local total.                                                                                                                                           // Add the local value
                            
                                lock (locker) sum4+= localTotal;
                                // to the master value.
                        );

【讨论】:

以上是关于Parallel.ForEach 的不同求和结果的主要内容,如果未能解决你的问题,请参考以下文章

Parallel.ForEach 没有提供更多内核的加速[关闭]

Parallel类

C#并发实战Parallel.ForEach使用

如何限制 Parallel.ForEach?

计算 Parallel.ForEach 使用的线程数

打破parallel.foreach?