ASP.NET Core中的内存管理和垃圾回收(GC)

内存管理十分复杂的,即使在托管框架(如 .NET)中也是如此。分析和了解内存问题可能极具挑战性。本文:

  • 撰写动机来自许多内存泄漏和 GC 未正常工作问题。其中大多数问题都是由于不了解 .NET Core 中的内存消耗工作方式或不了解其度量方式所导致。

  • 演示了有问题的内存使用,并提出了替代方法。

垃圾回收 (GC) 在 .NET Core 中的工作方式

GC 会分配堆段,其中每个段都是一系列连续的内存。置于堆中的对象归类为 3 个代系之一:0、1 或 2。代系可确定 GC 尝试在应用不再引用的托管对象上释放内存的频率。编号较低的代系会更加频繁地进行 GC。.

对象会基于其生存期从一个代系移到另一个代系。随着对象生存期延长,它们会移到较高代系。如前所述,较高代系进行 GC 的频率较低。短期生存的对象始终保留在第 0 代中。例如,在 Web 请求存在期间引用的对象的生存期较短。应用程序级别单一实例通常会迁移到第 2 代。

当 ASP.NET Core 应用启动时,GC 会:

  • 为初始堆段保留一些内存。

  • 在运行时加载时提交一小部分内存。

进行以上内存分配是出于性能方面的原因。性能优势来自连续内存中的堆段。

调用 GC.Collect

显式调用 GC.Collect:

  • 不应由生产 ASP.NET Core 应用进行。

  • 在调查内存泄漏时非常有用。

  • 在进行调查时会验证 GC 是否从内存中删除了所有无关联对象,以便可以度量内存。

分析应用的内存使用情况

专用工具可帮助分析内存使用情况:

  • 对对象引用进行计数

  • 度量 GC 对 CPU 使用情况的影响程度

  • 度量用于每个代系的内存空间

使用以下工具可分析内存使用情况:

  • dotnet-trace可在生产计算机上使用。

  • 分析不使用 Visual Studio 调试器情况下的内存使用情况

  • Visual Studio 中的配置文件内存使用

检测内存问题

任务管理器可用于了解 ASP.NET 应用使用的内存量。任务管理器内存值:

  • 表示 ASP.NET 进程使用的内存量。

  • 包括应用处于活动状态的对象和其他内存使用者(如本机内存使用情况)。

如果任务管理器内存值无限增加且从未保持稳定,则应用程序发生内存泄漏。以下部分演示并说明了几种内存使用模式。

示例显示内存使用应用

GitHub 上提供了 MemoryLeak 示例应用。MemoryLeak 应用:

  • 包含收集应用的实时内存和 GC 数据的诊断控制器。

  • 具有显示内存和 GC 数据的索引页面。索引页面每秒刷新一次。

  • 包含提供各种内存负载模式的 API 控制器。

  • 不是受支持的工具,但它可用于显示 ASP.NET Core 应用的内存使用模式。

运行 MemoryLeak。分配的内存缓慢增加,直到进行 GC。内存增加是因为该工具会分配自定义对象来捕获数据。下图显示进行第 0 代 GC 时的 MemoryLeak 索引页面。此图表显示 0 RPS(每秒请求数),因为尚未调用 API 控制器中的 API 终结点。

ASP.NET Core中的内存管理和垃圾回收(GC)

该图表显示内存使用情况的两个值:

  • Allocated:托管对象占用的内存量

  • Working set:进程的虚拟地址空间中当前驻留在物理内存中的页集。显示的工作集与任务管理器显示的值相同。

暂时性对象

下面的 API 创建一个 10-KB 字符串实例,并将它返回给客户端。对于每个请求,会在内存中分配一个新对象并将它写入响应中。字符串作为 UTF-16 字符存储在 .NET 中,因此每个字符都需要 2 字节内存。

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

下图是在负载相对较小的情况下生成,用于显示 GC 如何影响内存分配。

ASP.NET Core中的内存管理和垃圾回收(GC)

上面的图表显示:

  • 4K RPS(每秒请求数)。

  • 第 0 代 GC 回收大约每两秒进行一次。

  • 工作集大约恒定为 500 MB。

  • CPU 为 12%。

  • 内存消耗和释放(通过 GC)是稳定的。

下面的图表是在可以由计算机处理的最大吞吐量情况下生成。

ASP.NET Core中的内存管理和垃圾回收(GC)

上面的图表显示:

  • 22K RPS

  • 第 0 代 GC 回收每秒进行多次。

  • 触发了第 1 代回收,因为应用每秒分配的内存量显著增加。

  • 工作集大约恒定为 500 MB。

  • CPU 为 33%。

  • 内存消耗和释放(通过 GC)是稳定的。

  • CPU (33%) 未使用过度,因此垃圾回收可以跟上大量分配。

工作站 GC 与服务器 GC

.NET 垃圾回收器具有两种不同的模式:

  • 工作站 GC:针对桌面设备进行了优化。

  • 服务器 GC。ASP.NET Core 应用的默认 GC。针对服务器进行了优化。

可以在项目文件或已发布应用的文件中 runtimeconfig.json 显式设置 GC 模式。下面的标记显示项目文件中的 ServerGarbageCollection 设置:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

在项目文件中更改 ServerGarbageCollection 需要重新生成应用。

注意:服务器垃圾回收在具有单个核心的计算机上不可用。有关详细信息,请参阅 IsServerGC。

下图显示使用工作站 GC 的低于 5K RPS 的内存配置文件。

ASP.NET Core中的内存管理和垃圾回收(GC)

此图表与服务器版本之间的差异十分显著:

  • 工作集从 500 MB 下降到 70 MB。

  • GC 每秒(而不是每两秒)进行多次第 0 代回收。

  • GC 从 300 MB 下降到 10 MB。

在典型 Web 服务器环境中,CPU 使用率比内存更重要,因此服务器 GC 更好。如果内存利用率较高而 CPU 使用率相对较低,则工作站 GC 可能性能更高。例如,在内存短缺的情况下高密度托管多个 Web 应用。

使用 Docker 和小型容器的 GC

当多个容器化应用在一台计算机上运行时,工作站 GC 的性能可能比服务器 GC 更高。有关详细信息,请参阅在小型容器中使用服务器 GC 运行和在小型容器方案中使用服务器 GC 运行第 1 部分 – GC 堆的硬限制。

持久性对象引用

GC 无法释放所引用的对象。引用但不再需要的对象会导致内存泄露。如果应用经常分配对象,但在不再需要对象之后未能释放它们,则内存使用量会随着时间推移而增加。

下面的 API 创建一个 10-KB 字符串实例,并将它返回给客户端。与上一个示例的不同之处在于,此实例由静态成员引用,这意味着它从不可进行回收。

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

前面的代码:

  • 是典型内存泄漏的示例。

  • 频繁调用时,会导致应用内存增加,直到进程崩溃并出现 OutOfMemory 异常。

ASP.NET Core中的内存管理和垃圾回收(GC)

在上图中:

  • 测试 /api/staticstring 终结点的负载会导致内存线性增加。

  • GC 会在内存压力增加时,通过调用第 2 代回收来尝试释放内存。

  • GC 无法释放泄漏的内存。已分配内存和工作集会随时间而增加。

某些方案(如缓存)需要保持对象引用,直到内存压力迫使释放它们。WeakReference 类可用于此类型的缓存代码。 WeakReference 对象会在内存压力下进行回收。IMemoryCache 的默认实现使用 WeakReference

本机内存

某些 .NET Core 对象依赖于本机内存。GC 无法回收本机内存。使用本机内存的 .NET 对象必须使用本机代码进行释放。

.NET 提供了 IDisposable 接口,使开发人员能够释放本机内存。即使未调用 Dispose,正确实现的类也会在终结器运行时调用 Dispose

请考虑以下代码:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider 是托管类,因此将在请求结束时收集任何实例。

下图显示连续调用 fileprovider API 时的内存配置文件。

ASP.NET Core中的内存管理和垃圾回收(GC)

上面的图表显示此类的实现的一个明显问题,因为它会不断增加内存使用量。这是在此问题中所跟踪的已知问题。

用户代码中可能会发生相同的泄漏,如下所示之一:

  • 未正确释放类。

  • 忘记调用 Dispose 应释放的依赖对象的方法。

大型对象堆

频繁的内存分配/释放周期可能会导致内存碎片,尤其是在分配大型内存区块时。对象在连续内存块中进行分配。为了减少碎片,当 GC 释放内存时,它会尝试对其进行碎片整理。此过程称为压缩。压缩涉及移动对象。移动大型对象会造成性能损失。因此,GC 会为大型对象创建特殊内存区域,称为大型对象堆 (LOH)。大于 85,000 字节(大约 83 KB)的对象:

  • 置于 LOH 上。

  • 不进行压缩。

  • 在第 2 代 GC 期间进行回收。

当 LOH 已满时,GC 会触发第 2 代回收。第 2 代回收:

  • 在本质上速度较慢。

  • 还会产生对所有其他代系触发回收的成本。

下面的代码会立即压缩 LOH:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

有关压缩 LOH 的信息,请参阅 LargeObjectHeapCompactionMode。

在使用 .NET Core 3.0 及更高版本的容器中,LOH 会自动压缩。

下面的 API 阐释了此行为:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

下面的图表显示在最大负载下调用 /api/loh/84975 终结点的内存配置文件:

ASP.NET Core中的内存管理和垃圾回收(GC)

下图显示了调用 /api/loh/84976 终结点的内存配置文件, 只分配一个字节

ASP.NET Core中的内存管理和垃圾回收(GC)

注意:此 byte[] 结构具有开销字节。这便是 84,976 字节会触发 85,000 限制的原因。

比较上面两个图表:

  • 工作集对于这两种方案是相似的(大约 450 MB)。

  • 低于 LOH 请求(84,975 字节)大部分显示第 0 代回收。

  • 高于 LOH 请求生成恒定的第 2 代回收。第 2 代回收成本高昂。需要更多 CPU,吞吐量几乎下降 50%。

临时大型对象尤其有问题,因为它们会导致第 2 代 GC。

为了获得最佳性能,应最大程度减少大型对象使用。如果可能,请拆分大型对象。例如,ASP.NET Core 中的响应缓存中间件会将缓存项拆分为小于 85,000 字节的块。

以下链接显示在 LOH 限制下保留对象的 ASP.NET Core 方法:

  • ResponseCaching/Streams/StreamUtilities.cs

  • ResponseCaching/MemoryResponseCache.cs

有关详细信息,请参阅:

  • 未覆盖的大型对象堆

  • 大型对象堆

HttpClient

未正确使用 HttpClient 可能会导致资源泄漏。系统资源(如数据库连接、套接字、文件句柄等):

  • 比内存更短缺。

  • 在泄漏时出现的问题比内存更多。

经验丰富的 .NET 开发人员知道要对实现 IDisposable 的对象调用 Dispose。未释放实现 IDisposable 的对象通常会导致内存泄漏或系统资源泄漏。

HttpClientIDisposable实现,但不应在每个调用上释放。而是应重用 HttpClient

下面的终结点会对每个请求创建并释放新的 HttpClient 实例:

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

在负载下,记录了以下错误消息:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

即使释放了 HttpClient 实例,实际网络连接也需要一些时间才能由操作系统释放。持续创建新连接时,会发生端口耗尽。每个客户端连接都需要自己的客户端端口。

防止端口耗尽的一种方法是重用同一个 HttpClient 实例:

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

HttpClient 实例会在应用停止时释放。此示例演示并非每个可释放资源都应在每次使用后释放。

请参阅以下内容,了解处理 HttpClient 实例的生存期的更好方式:

  • HttpClient 和生存期管理

  • HTTPClient 工厂博客

对象池

上面的示例演示了如何将 HttpClient 实例设为静态,并由所有请求重用。重用可防止资源耗尽。

对象池:

  • 使用重用模式。

  • 适用于创建成本高昂的对象。

池是预初始化对象的集合,这些对象可以在线程间保留和释放。池可以定义分配规则,例如限制、预定义大小或增长速率。

NuGet 包 Microsoft.Extensions.ObjectPool 包含有助于管理此类池的类。

下面的 API 终结点会实例化 byte 缓冲区,该缓冲区对每个请求使用随机数字进行填充:

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

下面的图表显示在中等负载下调用前面 API 的情况:

在上面的图表中,第 0 代回收大约每秒进行一次。

可以使用 ArrayPool<T> 创建 byte 缓冲区池,从而优化上面的代码。静态实例可在请求间重用。

此方法的不同之处在于,会从 API 返回共用对象。也就是说:

  • 从方法返回后,对象会立即脱离控制。

  • 无法释放对象。

若要设置对象的释放,请执行以下操作:

  • 将共用数组封装在可释放对象中。

  • 向 HttpContext.Response.RegisterForDispose 注册共用对象。

RegisterForDispose 将负责调用 Dispose 目标对象,以便仅在 HTTP 请求完成时释放它。

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

应用与非共用版本相同的负载会生成以下图表:

ASP.NET Core中的内存管理和垃圾回收(GC)

主要差异是分配的字节数,因而第 0 代回收数要少得多。