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

作者:Sébastien RosRick Anderson

内存管理十分复杂的,即使在托管框架(如 .NET)中也是如此。 分析和解决内存问题可能很困难。 内存泄漏和垃圾回收(GC)问题通常是由于对内存消耗在.NET中的工作原理缺乏了解,或者不了解测量使用情况的过程。

本文演示了常见的内存使用模式,这些模式可能会有问题,并建议使用替代方法。

在.NET中探索垃圾回收(GC)

GC 进程分配堆段,其中每个段都是连续的内存范围。 堆中放置的对象分为三代之一:0、1 或 2。 生成确定 GC 尝试释放应用不再引用的托管对象上的内存的频率。 GC 更频繁地处理编号较低的代系。

对象会基于其生存期从一个代系移到另一个代系。 随着对象的生存时间更长,它们将移到更高的代系中。 如前所述,GC 在更高级别的代系上运行频率较低。 生存期较短的对象始终保留在 Gen 0 中。 例如,在 Web 请求存在期间引用的对象的生存期较短。 应用程序级别单例通常迁移到 Gen 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 终结点。

显示运行 MemoryLeak 和 Gen 0 GC 后每秒 0 个请求数 (RPS) 的图表。

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

  • 已分配:托管对象占用的内存量。
  • 工作集:当前驻留在物理内存中的进程的虚拟地址空间中的页面集。 显示的工作集与任务管理器显示的值相同。 有关详细信息,请参阅 工作集

暂时性对象

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

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

下列图表生成的负载相对较小,显示了 GC 如何影响内存分配。

显示相对较小的负载的内存分配的图表。

该图表演示了以下详细信息:

  • 4K RPS (每秒请求数)
  • 第 0 代 GC 集合大约每 2 秒发生一次
  • 常量工作集,大约 500 MB
  • CPU 为 12%
  • 稳定的内存消耗和释放(通过 GC)

下图采用计算机可以处理的最大吞吐量。

显示最大吞吐量的图表。

该图表演示了以下详细信息:

  • 22 K RPS
  • 第 0 代 GC 集合每秒发生多次
  • 第 1 代集合触发,因为应用每秒分配的内存要大得多
  • 常量工作集,大约 500 MB
  • CPU 为 33%
  • 稳定的内存消耗和释放(通过 GC)
  • CPU (33%) 没有被过度使用,因此 GC 可以跟上大量的分配

工作站 GC 与服务器 GC

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

  • 工作站 GC:针对桌面进行了优化。
  • Server GC:ASP.NET Core应用的默认 GC。 针对服务器进行了优化。

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

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

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

注释

服务器垃圾回收在单核计算机上可用。 有关详细信息,请参阅 IsServerGC 属性。

下图显示 5K RPS 下使用 Workstation GC 的内存性能分析。

显示工作站 GC 的内存配置文件的图表。

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

  • 工作集从 500 MB 下降到 70 MB
  • GC 每秒多次执行第 0 代集合,而不是每 2 秒收集一次
  • GC 从 300 MB 下降到 10 MB

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

使用 Docker 和小型容器的 GC

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

持久性对象引用

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

以下 API 创建一个 20 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 异常。

显示永久性未使用的引用导致的内存泄漏的图表。

该图表演示了以下详细信息:

  • 负载测试 /api/staticstring 端点会导致内存以线性方式增加
  • GC 通过调用 Gen 2 集合来尝试释放内存,因为内存压力增加
  • GC 无法释放泄漏的内存;已分配内存和工作集内存随着时间推移而增加

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

本机内存

某些.NET对象依赖于本机内存,但 GC 无法收集本机内存。 使用本机内存的 .NET 对象必须使用本机代码释放它。

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

考虑下列代码:

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

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

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

图表显示了本机内存泄露

上面的图表显示此类实现的一个明显的缺陷,因为它会不断增加内存使用量。 此结果是在 GitHub dotnet/aspnetcore 问题 #844 中跟踪的已知问题。

在以下方案中,用户代码中发生相同的泄漏:

  • 未正确释放类
  • 忘记调用应释放的相关对象的 Dispose 方法

大型对象堆

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

  • 放置在 LOH 上
  • 未压缩
  • 在第 2 代收集过程中进行处理

当 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 终结点的内存配置文件:

显示字节分配内存使用情况的图表

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

图表显示了分配一个额外字节时的内存使用情况

注释

byte[] 结构具有开销字节,这就是为什么 84,976 字节触发 85,000 个限制的原因。

比较上面两个图表:

  • 对于这两种情况,工作集类似,大约为 450 MB
  • LOH 请求(84,975 字节)下主要显示第 0 代集合
  • 过度 LOH 请求会生成恒定的第 2 代集合,这很昂贵。 需要更多的 CPU,吞吐量会下降约 50%。

临时大型对象是有问题的,因为它们会导致第 2 代集合。

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

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

有关详细信息,请参阅:

HttpClient

错误地使用 HttpClient 类可能会导致资源泄漏。

系统资源(如数据库连接、套接字、文件句柄等)存在两个问题:

  • 它们比记忆更稀缺。
  • 当它们泄漏时,比内存的问题更大。

经验丰富的.NET开发人员知道对实现 Dispose 接口的对象调用 IDisposable 方法。 未释放实现 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:

显示对具有中等负载的 API 调用的图表。

该图表显示,第 0 代集合大约每秒发生一次。

可以通过使用 > 类将缓冲区池化来优化代码。 静态实例可在请求间重用。

此方法的区别在于,从 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;
}

应用与非池化版本相同的负载会产生以下图表:

图表显示资源分配减少。

主要区别是分配的字节数,因此,第 0 代集合更少。