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

Posted

技术标签:

【中文标题】VS2015升级后的垃圾收集和Parallel.ForEach问题【英文标题】:Garbage Collection and Parallel.ForEach Issue After VS2015 Upgrade 【发布时间】:2015-10-23 05:46:07 【问题描述】:

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

我有两台开发机器(A 和 B),最近将机器 A 升级到 VS2015。我开始注意到我的代码中有大约一半的时间出现了奇怪的间歇性冻结。让它运行了很长时间,结果证明代码最终完成了。只需 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 中的代码时,它运行良好。它在一夜之间运行了几十次,没有结冰。

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

无论是否在机器 A 上附加了调试器,都会发生冻结(我一开始认为这是 VS2015 调试器的问题) 无论我是在调试模式还是发布模式下构建,都会发生冻结 如果我以 .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:只是为了将内容从 cmets 中移出以供未来的读者使用...

此修补程序: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。

【问题讨论】:

下一步是发布MCVE,这样我们就可以尝试在我们的机器上重现它,看看我们是否遇到相同的行为。这是为作为 x86 或 x64 进程运行而构建的吗? x64。明白了,正在做一个。但是很难让 GC 工作得恰到好处。希望我遗漏了一些明显的东西。 @MichaelCovelli 使用GC.Collect() 强制循环中的 GC 会发生什么? 此修补程序:support.microsoft.com/en-us/kb/3088957 完全解决了该问题。申请后我根本没有看到任何缓慢的问题。 【参考方案1】:

这确实性能太差了,后台GC在这里对你不利。我注意到的第一件事是 Parallel.ForEach() 正在使用太多任务。线程池管理器将线程行为误解为“被 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 段中的空间足够大以至于在后台 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 中修复

【讨论】:

感谢您的浏览。如果一两天后没有其他人,将标记为答案。我同意这个实例很容易优化掉。我只是在玩一些不必要的分配,直到我得到一些东西来证明我在代码中看到的东西。 .NET 4.5 和 4.6 之间的差异是最让我惊讶的地方。将在 connect.microsoft.com 上报告问题。谢谢! @MichaelCovelli 报告后请在此处发布 Microsoft Connect 链接,以便我们也可以跟踪问题。【参考方案2】:

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

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 秒 - 与它在 VS2013 中的 .NET 4.5 中使用的时间相同。【参考方案3】:

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

【讨论】:

谢谢!修补程序发布后会试一试并报告。 此修补程序:support.microsoft.com/en-us/kb/3088957 刚刚发布并完全解决了该问题。 修补程序版本因 Windows 版本而异。根据blogs.msdn.com/b/maoni/archive/2015/08/12/… 的评论,我相信我们有以下内容。对于 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:没有可用的修补程序。跨度> 根据 Lee Coward 在上面链接的 cmets 中的说法,Windows 10 的修复是以下修补程序的一部分:support.microsoft.com/en-us/kb/3093266

以上是关于VS2015升级后的垃圾收集和Parallel.ForEach问题的主要内容,如果未能解决你的问题,请参考以下文章

在 JavaFX 8 中防止中间绑定被垃圾收集的推荐方法是啥

升级后的 MFC 应用程序看起来仍然很旧

vs2013怎么升级成vs2015

JVM垃圾回收篇(扩展知识)

JVM垃圾回收篇(扩展知识)

如何将 VC++6.0 项目升级到 VS2010?