VS2015 升级后的垃圾收集和 Parallel.ForEach 问题

Garbage Collection and Parallel.ForEach Issue After VS2015 Upgrade

我有一些代码可以在我自己的 R-like C# DataFrame 中处理数百万行数据 class。有许多 Parallel.ForEach 调用用于并行迭代数据行。此代码已使用 VS2013 和 .NET 4.5 运行宁一年多没有问题。

我有两台开发机器(A 和 B),最近将机器 A 升级到 VS2015。我开始注意到我的代码中大约有一半时间出现 st运行ge 间歇性冻结。让它 运行 很长一段时间,事实证明代码最终确实完成了。只需 15-120 分钟,而不是 1-2 分钟。

由于某种原因,尝试使用 VS2015 调试器破解全部失败。所以我插入了一堆日志语句。事实证明,这种冻结发生在 Parallel.ForEach 循环期间有 Gen2 收集时(比较每个 Parallel.ForEach 循环前后的收集计数)。整个额外的 13-118 分钟都花在 Parallel.ForEach 循环调用恰好与 Gen2 集合(如果有)重叠的情况下。如果在任何 Parallel.ForEach 循环期间没有 Gen2 集合(我 运行 时大约有 50% 的时间),那么一切都会在 1-2 分钟内完成。

当我 运行 在机器 A 上的 VS2013 中使用相同的代码时,我得到了相同的冻结。当我 运行 机器 B(从未升级过)上的 VS2013 中的代码时,它工作得很好。 运行过夜几十次都没有冻结。

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

我根本没有更改默认的 GC 设置。根据 GCSettings,所有 运行 都发生在 LatencyMode Interactive 和 IsServerGC 为 false 的情况下。

我可以在每次调用 Parallel.ForEach 之前切换到 LowLatency,但我真的更想了解发生了什么。

有没有其他人看到 st运行ge 在 VS2015 升级后在 Parallel.ForEach 中冻结?关于下一步的好的想法是什么?

更新 1:在上面的模糊解释中添加一些示例代码...

这里有一些示例代码,我希望它能演示这个问题。此代码 运行s 在 B 机器上持续 10-12 秒。它遇到了一些 Gen2 集合,但它们几乎没有花费任何时间。如果我取消注释这两个 GC 设置行,我可以强制它没有 Gen2 收集。它比 30-50 秒要慢一些。

现在在我的 A 机器上,代码需要 运行dom 时间。似乎在 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/en-us/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。

这确实性能太差了,后台GC在这里对你不利。我注意到的第一件事是 Parallel.ForEach() 使用了太多任务。线程池管理器将线程行为误解为 "bogged down by I/O" 并启动了额外的线程。这使问题变得更糟。解决方法是:

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

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

这可以更好地了解 VS2015 中新诊断中心的程序问题。只需 单个 内核即可完成任何工作,这很容易从 CPU 用法中看出。偶尔出现尖峰,它们不会持续很长时间,与橙色 GC 标记一致。当您仔细查看 GC 标记时,您会发现它是一个 gen #1 集合。用了 非常 的时间,在我的机器上大约 6 秒。

第 1 代收集当然不会花那么长时间,您在这里看到的是第 1 代收集在等待后台 GC 完成其工作。换句话说,实际上是后台 GC 花费了 6 秒。仅当 gen #0 和 gen #1 段中的 space 足够大以至于在后台 GC 运行时不需要 gen #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。


更新:this blog post 中确认了 GC 错误。即将修复。


更新:a hotfix was released.


更新:已在 .NET 4.6.1 中修复

我们(和其他用户)遇到了类似的问题。我们通过在应用程序 app.config 中禁用后台 GC 来解决这个问题。请参阅 https://connect.microsoft.com/VisualStudio/Feedback/Details/1594775.

评论中的讨论

app.config 用于 gcConcurrent(非并发工作站 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>

看来问题已经解决了,见http://blogs.msdn.com/b/maoni/archive/2015/08/12/gen2-free-list-changes-in-clr-4-6-gc.aspx