ASP.NET Core 中的内存管理和垃圾回收(GC)Memory management and garbage collection (GC) in ASP.NET Core
本文内容
作者: Sébastien Ros和Rick 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个字节的内存。
[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
return new String('x', 10 * 1024);
}
下面的关系图是使用相对较小的负载生成的,用于显示 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
:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</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 的字符串实例,并将其返回给客户端。与上一示例的不同之处在于,此实例由静态成员引用,这意味着它不能用于收集。
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 在内存压力增加时,通过调用第2代回收来尝试释放内存。
- GC 无法释放泄漏的内存。已分配和工作集增加了时间。
某些方案(如缓存)需要保留对象引用,直到内存压力强制释放它们。WeakReference 类可用于这种类型的缓存代码。内存压力下将收集一个 WeakReference
对象。IMemoryCache 的默认实现使用 WeakReference
。
本机内存Native memory
某些 .NET Core 对象依赖本机内存。GC无法收集本机内存。使用本机内存的 .NET 对象必须使用本机代码释放它。
.NET 提供了 IDisposable 界面,使开发人员能够释放本机内存。即使未调用 Dispose,正确实现的类也会在终结器运行时调用 Dispose
。
考虑下列代码:
[HttpGet("fileprovider")]
public void GetFileProvider()
{
var fp = new PhysicalFileProvider(TempPath);
fp.Watch("*.*");
}
PhysicalFileProvider是托管类,因此将在请求结束时收集任何实例。
下图显示了连续调用 fileprovider
API 时的内存配置文件。
上面的图表显示了此类的实现的一个明显问题,因为它会不断增加内存使用量。这是此问题中正在跟踪的已知问题。
可以通过以下方式之一在用户代码中发生相同的泄漏:
- 不能正确释放类。
- 忘记调用应释放的依赖对象的
Dispose
方法。
大型对象堆Large objects heap
频繁的内存分配/空闲周期可以分段内存,尤其是在分配大块内存时。对象在连续内存块中分配。为了缓解碎片,当 GC 释放内存时,它会 trys 对内存进行碎片整理。此过程称为压缩。压缩涉及移动对象。移动大型对象会对性能产生负面影响。出于此原因,GC 将为大型对象(称为大型对象堆(LOH))创建特殊的内存区域。大于85000字节(大约 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
终结点的内存配置文件:
下图显示了调用 /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
实例:
[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
实例的生存期:
对象池Object pooling
前面的示例演示了如何将 HttpClient
实例设为静态的,并由所有请求重复使用。重复使用会阻止资源耗尽。
对象池:
- 使用重复使用模式。
- 适用于创建成本很高的对象。
池是预初始化对象的集合,这些对象可以在线程之间保留和释放。池可以定义分配规则,例如限制、预定义大小或增长速率。
NuGet 包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 返回一个共用对象。这意味着:
- 从方法返回后,将立即从控件中排除对象。
- 不能释放对象。
设置对象的释放:
- 将池数组封装到可释放对象中。
- 将此池对象注册为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;
}
应用与非池版本相同的负载会导致以下图表:
主要区别是分配的字节数,因此产生的第0代回收量更少。