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

作者:Sébastien RosRick Anderson

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

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

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

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

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

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

  • 为初始堆段保留一些内存。
  • 在运行时加载时提交一小部分内存。

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

GC.Collect 注意事项

通常,生产中的 ASP.NET Core 应用不应显式使用 GC.Collect。 在次佳时间诱发垃圾回收可能会导致性能显著降低。

GC.Collect 在调查内存泄漏时非常有用。 调用 GC.Collect() 会触发一个阻止垃圾回收周期,该周期会尝试回收无法从托管代码访问的所有对象。 这对于了解堆中可访问的实时对象的大小和随时间推移跟踪内存大小的增长是一种很有用的方法。

分析应用的内存使用情况

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

  • 对对象引用进行计数
  • 度量 GC 对 CPU 使用情况的影响程度
  • 度量用于每个代系的内存空间

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

检测内存问题

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

  • 表示 ASP.NET 进程使用的内存量。
  • 包括应用处于活动状态的对象和其他内存使用者(如本机内存使用情况)。

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

示例显示内存使用应用

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

  • 包含收集应用的实时内存和 GC 数据的诊断控制器。
  • 具有显示内存和 GC 数据的索引页面。 索引页面每秒刷新一次。
  • 包含提供各种内存负载模式的 API 控制器。
  • 不是受支持的工具,但它可用于显示 ASP.NET Core 应用的内存使用模式。

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

Chart showing 0 Requests Per Second (RPS)

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

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

暂时性对象

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

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

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

Graph showing memory allocations for a relatively small load

上面的图表显示:

  • 4K RPS(每秒请求数)。
  • 第 0 代 GC 回收大约每两秒进行一次。
  • 工作集大约恒定为 500 MB。
  • CPU 为 12%。
  • 内存消耗和释放(通过 GC)是稳定的。

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

Chart showing max throughput

上面的图表显示:

  • 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 的内存配置文件。

Chart showing memory profile for a Workstation 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 异常。

Chart showing a memory leak

在上图中:

  • 测试 /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 时的内存配置文件。

Chart showing a native memory leak

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

由于以下情况之一,用户代码中可能会发生相同的泄漏:

  • 未正确释放类。
  • 忘记调用应释放的相关对象的 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 终结点的内存配置文件:

Chart showing memory profile of allocating bytes

下面的图表显示在只是多分配一个字节的情况下调用 /api/loh/84976 终结点的内存配置文件:

Chart showing memory profile of allocating one more byte

注意:此 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 方法:

有关详细信息,请参阅:

HttpClient

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

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

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

HttpClient 实现了 IDisposable,但是不应在每次调用时进行释放。 而是应重用 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 实例设为静态,并由所有请求重用。 重用可防止资源耗尽。

对象池:

  • 使用重用模式。
  • 适用于创建成本高昂的对象。

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

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 的情况:

Chart showing calls to API with moderate load

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

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

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

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

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

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;
}

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

Chart showing fewer allocations

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

其他资源