垃圾回收和Parallel.ForEach在升级VS2015后出现问题

34

我有一些代码,用自己类似R的C# DataFrame类来处理数百万数据行。其中有许多Parallel.ForEach调用,以并行迭代数据行。此代码已使用VS2013和.NET 4.5运行了一年多,没有问题。

我有两台开发机器(A和B),最近将A机升级到了VS2015。我开始注意到我的代码会时不时奇怪地冻结。长时间运行后,发现代码确实最终完成。只是需要15-120分钟而不是1-2分钟。

尝试在VS2015调试器中使用Break All功能失败了。因此,我插入了一堆日志语句。结果发现,在Parallel.ForEach循环期间进行Gen2收集(比较每个Parallel.ForEach循环之前和之后的收集计数)时会出现这种冻结。如果有任何一个Parallel.ForEach循环调用与Gen2收集重合(如果有的话),额外的13-118分钟都会花在里面。如果在任何Parallel.ForEach循环期间没有Gen2收集(我运行它时大约有50%的时间),那么一切都会在1-2分钟内完成。

当我在A机上使用VS2013运行相同的代码时,我遇到了相同的冻结。当我在Machine B上(未经升级的机器)运行代码时,完全正常。它在一夜之间运行了数十次而没有冻结。

我注意到/尝试过的一些事情:

  • 在A机上附加或不附加调试器时都会冻结(起初我认为是与VS2015调试器有关)
  • 无论我以Debug模式还是Release模式构建,都会发生冻结
  • 如果我目标定位为.NET 4.5或.NET 4.6,也会发生冻结
  • 我尝试禁用RyuJIT,但这并未影响冻结

我并没有改变默认的GC设置。根据GCSettings,所有运行都使用LatencyMode Interactive和IsServerGC为false。

我可以在每次调用Parallel.ForEach之前切换到LowLatency,但我真的希望了解正在发生的事情。

在VS2015升级后,有人看到Parallel.ForEach中的奇怪冻结吗?下一步该做什么?

更新1:添加一些样例代码来解释上面的含糊不清的内容...

以下是一些示例代码,我希望能演示此问题。B机上的代码在10-12秒内稳定运行。它遇到了许多Gen2收集,但几乎不需要时间。如果我取消注释两个GC设置行,我可以强制它没有Gen2收集。此时速度稍慢,大约为30-50秒。

现在,在我的A机上,代码需要随机的时间。似乎在5到30分钟之间。如果遇到越来越多的Gen2收集,情况似乎会变得更糟。如果我取消注释两个GC设置行,A机上也需要30-50秒(与B机器相同)。

这可能需要对行数和数组大小进行一些调整,才能在另一台机器上显示出来。

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
using System.Runtime;    

public class MyDataRow
{
    public int Id { get; set; }
    public double Value { get; set; }
    public double DerivedValuesSum { get; set; }
    public double[] DerivedValues { get; set; }
}

class Program
{
    static void Example()
    {
        const int numRows = 2000000;
        const int tempArraySize = 250;

        var r = new Random();
        var dataFrame = new List<MyDataRow>(numRows);

        for (int i = 0; i < numRows; i++) dataFrame.Add(new MyDataRow { Id = i, Value = r.NextDouble() });

        Stopwatch stw = Stopwatch.StartNew();

        int gcs0Initial = GC.CollectionCount(0);
        int gcs1Initial = GC.CollectionCount(1);
        int gcs2Initial = GC.CollectionCount(2);

        //GCSettings.LatencyMode = GCLatencyMode.LowLatency;

        Parallel.ForEach(dataFrame, dr =>
        {
            double[] tempArray = new double[tempArraySize];
            for (int j = 0; j < tempArraySize; j++) tempArray[j] = Math.Pow(dr.Value, j);
            dr.DerivedValuesSum = tempArray.Sum();
            dr.DerivedValues = tempArray.ToArray();
        });

        int gcs0Final = GC.CollectionCount(0);
        int gcs1Final = GC.CollectionCount(1);
        int gcs2Final = GC.CollectionCount(2);

        stw.Stop();

        //GCSettings.LatencyMode = GCLatencyMode.Interactive;

        Console.Out.WriteLine("ElapsedTime = {0} Seconds ({1} Minutes)", stw.Elapsed.TotalSeconds, stw.Elapsed.TotalMinutes);

        Console.Out.WriteLine("Gcs0 = {0} = {1} - {2}", gcs0Final - gcs0Initial, gcs0Final, gcs0Initial);
        Console.Out.WriteLine("Gcs1 = {0} = {1} - {2}", gcs1Final - gcs1Initial, gcs1Final, gcs1Initial);
        Console.Out.WriteLine("Gcs2 = {0} = {1} - {2}", gcs2Final - gcs2Initial, gcs2Final, gcs2Initial);

        Console.Out.WriteLine("Press Any Key To Exit...");
        Console.In.ReadLine();
    }

    static void Main(string[] args)
    {
        Example();
    }
}

更新2:为了将事情从评论中挪出来留给未来的读者...

这个热修复程序:https://support.microsoft.com/zh-cn/kb/3088957 完全解决了这个问题。我在应用后没有看到任何缓慢问题。

我认为这与 Parallel.ForEach 没有任何关系,基于这个:http://blogs.msdn.com/b/maoni/archive/2015/08/12/gen2-free-list-changes-in-clr-4-6-gc.aspx,尽管热修复程序的一些说明提到了 Parallel.ForEach。


4
下一步是发布一个MCVE(最小可复现示例),以便我们可以尝试在我们的机器上重现这个问题,并查看我们是否遇到了相同的行为。这是构建为x86还是x64进程运行的? - Yuval Itzchakov
x64。明白了,正在处理一个。但是很难让GC正常工作。希望我错过了一些显而易见的东西。 - Michael Covelli
@MichaelCovelli 当你在循环中使用GC.Collect()强制进行垃圾回收时会发生什么? - svick
这个热修补程序:https://support.microsoft.com/zh-cn/kb/3088957 完全解决了问题。应用后我没有看到任何减慢问题。 - Michael Covelli
3个回答

29

确实表现非常糟糕,后台垃圾收集器在这里没有起到作用。我注意到的第一件事是Parallel.ForEach()使用了太多的任务。线程池管理器将线程行为误解为"受I/O阻滞"并启动额外的线程。这使问题变得更糟。对此的解决方法是:

var options = new ParallelOptions();
options.MaxDegreeOfParallelism = Environment.ProcessorCount;

Parallel.ForEach(dataFrame, options, dr => {
    // etc..
}

这个VS2015的新诊断中心为我们更深入地了解程序的问题提供了帮助。很容易从CPU使用率中发现只有一个核心在工作。虽然偶尔会有与橙色GC标记相符合的短暂峰值,但它们不会持续很久。如果你仔细观察GC标记,你会发现这是一次1代回收,非常费时,在我的机器上需要大约6秒钟。

当然,一次1代回收本来不需要这么长时间,这里发生的情况是1代回收等待后台GC完成其工作,换句话说,实际上是后台GC花费了6秒钟。只有在1代和0代段的空间足够大以至于不需要在后台GC运行时进行2代回收时,后台GC才能发挥作用。但这个应用程序并不适用这种方式,它会以非常高的速度消耗内存。你看到的小峰值是多个任务被解除阻塞,重新能够分配数组。当1代回收再次等待后台GC时,应用程序很快就会停止运行。

值得注意的是,这个代码的分配模式对GC非常不友好。它将长寿命数组(dr.DerivedValues)与短寿命数组(tempArray)交替排列。这会给GC带来很多工作,因为每个分配的数组最终都要被移动。

显然,.NET 4.6 GC的一个缺陷是后台回收似乎从未有效地压缩堆。它看起来一遍又一遍地完成了这项工作,好像之前的回收根本没有压缩过。这是设计问题还是漏洞很难说,我不再拥有干净的4.5机器了。我的倾向是漏洞。你应该在connect.microsoft.com上报告这个问题,让Microsoft来看看它。


解决方法很容易想到,只需要避免长期和短期对象的尴尬交错即可,方法是预先分配它们:

    for (int i = 0; i < numRows; i++) dataFrame.Add(new MyDataRow { 
        Id = i, Value = r.NextDouble(), 
        DerivedValues = new double[tempArraySize] });

    ...
    Parallel.ForEach(dataFrame, options, dr => {
        var array = dr.DerivedValues;
        for (int j = 0; j < array.Length; j++) array[j] = Math.Pow(dr.Value, j);
        dr.DerivedValuesSum = array.Sum();
    });

当然,也可以完全禁用后台GC。


更新:在这篇博客文章中确认了GC错误。很快就会有修复。


更新:已发布热补丁


更新:在.NET 4.6.1中修复。


感谢您的查看。如果一两天后没有其他答案,我会将其标记为答案。我同意这个实例很容易被优化掉。我只是在玩耍,直到我得到了一些可以证明我在代码中看到的东西。.NET 4.5和4.6之间的差异让我感到最惊讶。我会在connect.microsoft.com上报告这个问题。谢谢! - Michael Covelli
2
@MichaelCovelli 请在报告问题后在此处发布Microsoft Connect链接,以便我们也可以跟踪该问题。 - cremor

10
我们(以及其他用户)遇到了类似的问题。我们通过在应用程序的app.config中禁用后台GC来解决它。请参见评论中的讨论

gcConcurrent的app.config(非并发工作站GC)

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup> 
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.1" />
    </startup>
<runtime>
    <gcConcurrent enabled="false" />
</runtime>

你也可以切换到服务器GC,虽然这种方法似乎会使用更多的内存(在未饱和的机器上?)。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup> 
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.1" />
    </startup>
<runtime>
    <gcServer enabled="true" />
</runtime>
</configuration>

这两种解决方法都可以解决它。将GC切换到服务器模式会使用更多的内存,但可以将执行时间降至5秒。将gcConcurrent设置为false会使应用程序需要约10秒钟的时间 - 这与在.NET 4.5中在VS2013中所需的时间相同。 - Michael Covelli

5

这个热补丁:https://support.microsoft.com/en-us/kb/3088957 刚刚发布,完全修复了这个问题。 - Michael Covelli
热修补版本根据Windows版本而异。根据http://blogs.msdn.com/b/maoni/archive/2015/08/12/gen2-free-list-changes-in-clr-4-6-gc.aspx中的评论,我认为我们有以下内容。对于Windows Vista、Windows 7、Windows Server 2008和Windows Server 2008 R2:3088957。对于Windows 8和Windows Server 2012:3088955。对于Windows 8.1和Windows Server 2012 R2:3088956。对于Windows 10:没有可用的热修补程序。 - Michael Covelli
1
根据上面链接中的评论,Lee Coward指出,Windows 10的修复程序是以下热补丁的一部分:https://support.microsoft.com/zh-cn/kb/3093266 - mpeac

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