广泛使用 LOH 会导致严重的性能问题

本文关键字:性能 问题 LOH | 更新日期: 2023-09-27 18:33:51

我们在Server 2012上有一个使用WebApi 2,.NET 4.5的Web服务。我们看到偶尔的延迟增加了 10-30 毫秒,没有充分的理由。我们能够将有问题的代码段追踪到 LOH 和 GC。

有一些文本被转换为它的 UTF8 字节表示形式(实际上,我们使用的序列化库就是这样做的(。只要文本短于 85000 字节,延迟就稳定且短:平均 ~0.2 毫秒,99%。一旦越过 85000 边界,平均延迟就会增加到 ~1ms,而 99% 会跳到 16-20ms。探查器显示大部分时间都花在 GC 上。可以肯定的是,如果我放GC。在迭代之间收集,测量的延迟回到 0.2 毫秒。

我有两个问题:

  1. 延迟从何而来?据我了解,LOH未压缩。SOH 正在压缩,但不显示延迟。
  2. 有没有实用的方法可以解决这个问题?注意 我无法控制数据的大小并使其更小。

--

public void PerfTestMeasureGetBytes()
{
    var text = File.ReadAllText(@"C:'Temp'ContactsModelsInferences.txt");
    var smallText = text.Substring(0, 85000 + 100);
    int count = 1000;
    List<double> latencies = new List<double>(count);
    for (int i = 0; i < count; i++)
    {
        Stopwatch sw = new Stopwatch();
        sw.Start();
        var bytes = Encoding.UTF8.GetBytes(smallText);
        sw.Stop();
        latencies.Add(sw.Elapsed.TotalMilliseconds);
        //GC.Collect(2, GCCollectionMode.Default, true);
    }
    latencies.Sort();
    Console.WriteLine("Average: {0}", latencies.Average());
    Console.WriteLine("99%: {0}", latencies[(int)(latencies.Count * 0.99)]);
}

广泛使用 LOH 会导致严重的性能问题

性能问题通常来自两个方面:分配和碎片。

分配

运行时保证干净的内存,因此花费周期来清理它。当你分配一个大对象时,这是大量的内存,并开始向单个分配增加毫秒(老实说,.NET 中的简单分配实际上非常快,所以我们通常从不关心这个(。

当分配然后回收 LOH 对象时,会发生碎片。直到最近,GC还无法重组内存以消除这些旧的对象"间隙",因此只有在该间隙中的大小相同或更小时才能容纳下一个对象。最近,气相色谱仪被赋予了压缩LOH的能力,这消除了这个问题,但在压实过程中需要花费时间。

你遇到了这两个问题和触发 GC 运行的问题,但这取决于你的代码尝试在 LOH 中分配项目的频率。如果要执行大量分配,请尝试对象池路由。如果无法有效控制池(块状对象生存期或不同的使用模式(,请尝试对正在使用的数据进行分块以完全避免它。


您的选择

我遇到了两种LOH方法:

  • 避免它。
  • 使用它,但要意识到您正在使用它并明确管理它。

避免它

这涉及将大型对象(通常是某种数组(分块成每个块都属于 LOH 屏障。我们在序列化大型对象流时执行此操作。效果很好,但实现将特定于您的环境,所以我犹豫是否提供编码示例。

使用它

解决分配和碎片的一种简单方法是长期对象。显式创建一个(或多个(大尺寸的空数组来容纳您的大对象,并且不要删除它(或它们(。把它留在身边,像对象池一样重用它。您为此分配付费,但可以在首次使用时或在应用程序空闲期间执行此操作,但您为重新分配支付的费用较少(因为您没有重新分配(并减少碎片问题,因为您不会经常要求分配内容,也不会回收项目(这首先会导致间隙(。

也就是说,中途之家可能是有序的。预先为对象池保留一段内存。尽早完成,这些分配应该在内存中是连续的,这样你就不会得到任何间隙,并将可用内存的尾端留给不受控制的项目。请注意,这显然会对应用程序的工作集产生影响 - 无论是否使用对象池,它都会占用空间。


资源

LOH在网络上有很多涉及,但要注意资源的日期。在最新的.NET版本中,LOH受到了一些喜爱,并得到了改进。也就是说,如果您使用的是旧版本,我认为网络上的资源相当准确,因为LOH在开始和.NET 4.5(ish(之间的很长一段时间内从未真正收到任何重大更新。

例如,有2008年的这篇文章 http://msdn.microsoft.com/en-us/magazine/cc534993.aspx

以及 .NET 4.5 中的改进摘要:http://blogs.msdn.com/b/dotnet/archive/2011/10/04/large-object-heap-improvements-in-net-4-5.aspx

除了以下内容之外,请确保使用的是服务器垃圾回收器。这不会影响LOH的使用方式,但我的经验是,它确实大大减少了在GC中花费的时间。

我发现避免大型对象堆问题的最佳解决方法是创建一个持久缓冲区并重用它。因此,与其在每次调用Encoding.GetBytes时分配一个新的字节数组,不如将字节数组传递给该方法。

在这种情况下,请使用采用字节数组的 GetBytes 重载。分配一个足够大的数组,以容纳最长预期字符串的字节数,并保留它。例如:

// allocate buffer at class scope
private byte[] _theBuffer = new byte[1024*1024];
public void PerfTestMeasureGetBytes()
{
    // ...
    for (...)
    {
        var sw = Stopwatch.StartNew();
        var numberOfBytes = Encoding.UTF8.GetBytes(smallText, 0, smallText.Length, _theBuffer, 0);
        sw.Stop();
        // ...
    }

这里唯一的问题是你必须确保缓冲区足够大以容纳最大的字符串。我过去所做的是将缓冲区分配给我期望的最大大小,但每次我使用它时都会检查以确保它足够大。如果它不够大,请重新分配它。你如何做到这一点取决于你想要的严谨程度。当主要处理西欧文本时,我只会将字符串长度加倍。例如:

string textToConvert = ...
if (_theBuffer.Length < 2*textToConvert.Length)
{
    // reallocate the buffer
    _theBuffer = new byte[2*textToConvert.Length];
}

另一种方法是尝试GetString,并在失败时重新分配。然后重试。例如:

while (!good)
{
    try
    {
        numberOfBytes = Encoding.UTF8.GetString(theString, ....);
        good = true;
    }
    catch (ArgumentException)
    {
        // buffer isn't big enough. Find out how much I really need
        var bytesNeeded = Encoding.UTF8.GetByteCount(theString);
        // and reallocate the buffer
        _theBuffer = new byte[bytesNeeded];
    }
}

如果使缓冲区的初始大小足够大以容纳所需的最大字符串,则可能不会经常收到该异常。这意味着您必须重新分配缓冲区的次数将非常少。当然,您可以向bytesNeeded添加一些填充,以便在您有一些其他异常值的情况下分配更多