ASP.NET Core 中的内存管理和垃圾回收(GC)Memory management and garbage collection (GC) in ASP.NET Core

本文内容

作者: Sébastien RosRick Anderson

内存管理是很复杂的,即使在 .NET 等托管框架中也是如此。分析和了解内存问题可能非常困难。本文:

  • 有很多内存泄漏GC 不起作用的问题。其中的大多数问题都是由不了解 .NET Core 中的内存使用情况或不了解其测量方式导致的。
  • 演示内存使用情况,并提出替代方法。

如何在 .NET Core 中使用垃圾回收(GC)How garbage collection (GC) works in .NET Core

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

根据对象的生存期,将对象从一代移到另一代。随着对象的运行时间较长,它们会移到较高的代中。如前所述,较高的版本是不太常见的垃圾回收。短期生存期的对象始终保留在第0代中。例如,在 web 请求过程中引用的对象的生存期很短。应用程序级别单一实例通常迁移到第2代。

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

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

出于性能方面的原因,上述内存分配已完成。性能优势来自连续内存中的堆段。

调用 GC.CollectCall GC.Collect

调用GC。显式收集

  • 应由生产 ASP.NET Core 应用完成。
  • 调查内存泄漏时非常有用。
  • 调查时,验证 GC 是否已从内存中删除所有无关联的对象,以便可以测量内存。

分析应用的内存使用情况Analyzing the memory usage of an app

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

  • 计算对象引用数
  • 度量 GC 对 CPU 使用的影响程度
  • 测量每代使用的内存空间

使用以下工具分析内存使用量:

检测内存问题Detecting memory issues

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

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

如果任务管理器内存值无限增加且从未平展,则应用程序的内存泄漏。以下部分演示并解释了几种内存使用模式。

示例显示内存使用情况应用Sample display memory usage app

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

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

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

上图

此图表显示内存使用量的两个值:

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

暂时性对象Transient objects

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

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

下面的关系图是使用相对较小的负载生成的,用于显示 GC 如何影响内存分配。

上图

上面的图表显示:

  • 4K RPS (每秒请求数)。
  • 第0代垃圾回收大约每两秒发生一次。
  • 工作集的大小约为 500 MB。
  • CPU 为12%。
  • 内存消耗和发布(通过 GC)是稳定的。

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

上图

上面的图表显示:

  • 22K RPS
  • 第0代垃圾回收每秒发生多次。
  • 由于每秒分配的内存量明显增加,因此将触发第1代回收。
  • 工作集的大小约为 500 MB。
  • CPU 为33%。
  • 内存消耗和发布(通过 GC)是稳定的。
  • CPU (33%)不会过度使用,因此垃圾回收可以跟上大量分配。

工作站 GC 与服务器 GCWorkstation GC vs. Server GC

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

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

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

  1. <PropertyGroup>
  2. <ServerGarbageCollection>true</ServerGarbageCollection>
  3. </PropertyGroup>

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

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

下图显示了使用工作站 GC 的占用大量 RPS 的内存配置文件。

上图

此图表与服务器版本之间的区别非常重要:

  • 工作集从 500 MB 降到 70 MB。
  • GC 每秒生成0次(而不是每隔两秒)回收一次。
  • GC 从 300 MB 降到 10 MB。

在典型的 web 服务器环境中,CPU 使用率比内存更重要,因此服务器 GC 更好。如果内存使用率很高且 CPU 使用率相对较低,则工作站 GC 可能会更高的性能。例如,在内存不足的情况下承载几个 web 应用的高密度。

持久性对象引用Persistent object references

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

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

  1. private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();
  2. [HttpGet("staticstring")]
  3. public ActionResult<string> GetStaticString()
  4. {
  5. var bigString = new String('x', 10 * 1024);
  6. _staticStrings.Add(bigString);
  7. return bigString;
  8. }

前面的代码:

  • 典型内存泄漏的示例。
  • 如果频繁调用,会导致应用内存增加,直到进程因 OutOfMemory 异常而崩溃。

上图

在上图中:

  • 负载测试 /api/staticstring 终结点会导致内存线性增加。
  • GC 在内存压力增加时,通过调用第2代回收来尝试释放内存。
  • GC 无法释放泄漏的内存。已分配和工作集增加了时间。

某些方案(如缓存)需要保留对象引用,直到内存压力强制释放它们。WeakReference 类可用于这种类型的缓存代码。内存压力下将收集一个 WeakReference 对象。IMemoryCache 的默认实现使用 WeakReference

本机内存Native memory

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

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

考虑下列代码:

  1. [HttpGet("fileprovider")]
  2. public void GetFileProvider()
  3. {
  4. var fp = new PhysicalFileProvider(TempPath);
  5. fp.Watch("*.*");
  6. }

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

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

上图

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

可以通过以下方式之一在用户代码中发生相同的泄漏:

  • 不能正确释放类。
  • 忘记调用应释放的依赖对象的 Dispose方法。

大型对象堆Large objects heap

频繁的内存分配/空闲周期可以分段内存,尤其是在分配大块内存时。对象在连续内存块中分配。为了缓解碎片,当 GC 释放内存时,它会 trys 对内存进行碎片整理。此过程称为压缩压缩涉及移动对象。移动大型对象会对性能产生负面影响。出于此原因,GC 将为大型对象(称为大型对象堆(LOH))创建特殊的内存区域。大于85000字节(大约 83 KB)的对象为:

  • 放置在 LOH 上。
  • 未压缩。
  • 在第2代 Gc 期间收集。

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

  • 的速度非常慢。
  • 此外,还会产生在所有其他代上触发集合的成本。

以下代码会立即压缩 LOH:

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

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

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

以下 API 演示了此行为:

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

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

上图

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

上图

注意: byte[] 结构具有开销字节。这就是84976字节触发85000限制的原因。

比较上述两个图表:

  • 对于这两种方案(约 450 MB),工作集都是类似的。
  • LOH 请求(84975字节)下面显示第0代回收。
  • Over LOH 请求生成常量第2代回收。第2代回收成本高昂。需要更多 CPU,吞吐量几乎会下降到50%。

临时大型对象尤其有问题,因为它们会导致 gen2 Gc。

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

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

有关详细信息,请参阅:

HttpClientHttpClient

使用 HttpClient 错误可能会导致资源泄漏。系统资源,如数据库连接、套接字、文件句柄等:

  • 比内存更稀有。
  • 泄漏内存时,问题更多。

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

HttpClient 实现 IDisposable,但应在每次调用时都将其释放。相反,应重用 HttpClient

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

  1. [HttpGet("httpclient1")]
  2. public async Task<int> GetHttpClient1(string url)
  3. {
  4. using (var httpClient = new HttpClient())
  5. {
  6. var result = await httpClient.GetAsync(url);
  7. return (int)result.StatusCode;
  8. }
  9. }

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

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

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

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

  1. private static readonly HttpClient _httpClient = new HttpClient();
  2. [HttpGet("httpclient2")]
  3. public async Task<int> GetHttpClient2(string url)
  4. {
  5. var result = await _httpClient.GetAsync(url);
  6. return (int)result.StatusCode;
  7. }

当应用程序停止时,将释放 HttpClient 的实例。此示例说明,每次使用后都不应释放每个可释放资源。

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

对象池Object pooling

前面的示例演示了如何将 HttpClient 实例设为静态的,并由所有请求重复使用。重复使用会阻止资源耗尽。

对象池:

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

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

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

以下 API 终结点将实例化一个 byte 缓冲区,该缓冲区填充了每个请求的随机数字:

  1. [HttpGet("array/{size}")]
  2. public byte[] GetArray(int size)
  3. {
  4. var random = new Random();
  5. var array = new byte[size];
  6. random.NextBytes(array);
  7. return array;
  8. }

以下图表显示了如何通过中等负载调用前面的 API:

上图

在上图中,第0代回收大约每秒发生一次。

可以通过使用ArrayPool<t >,将 byte 缓冲区进行合并,从而优化前面的代码。静态实例可跨请求重复使用。

此方法的不同之处在于,将从 API 返回一个共用对象。这意味着:

  • 从方法返回后,将立即从控件中排除对象。
  • 不能释放对象。

设置对象的释放:

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

  1. private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();
  2. private class PooledArray : IDisposable
  3. {
  4. public byte[] Array { get; private set; }
  5. public PooledArray(int size)
  6. {
  7. Array = _arrayPool.Rent(size);
  8. }
  9. public void Dispose()
  10. {
  11. _arrayPool.Return(Array);
  12. }
  13. }
  14. [HttpGet("pooledarray/{size}")]
  15. public byte[] GetPooledArray(int size)
  16. {
  17. var pooledArray = new PooledArray(size);
  18. var random = new Random();
  19. random.NextBytes(pooledArray.Array);
  20. HttpContext.Response.RegisterForDispose(pooledArray);
  21. return pooledArray.Array;
  22. }

应用与非池版本相同的负载会导致以下图表:

上图

主要区别是分配的字节数,因此产生的第0代回收量更少。

其他资源Additional resources