内存管理很复杂, 即便在像 .NET 这样的托管框架中. 分析和理解内存问题也很具挑战性.git
最近 一个用户在 ASP.NET Core 主存储库中 提交了一个问题 指出垃圾回收器(GC) "未运行垃圾回收", 那它就失去了存在的意义. 症状如提交者描述那样, 内存在请求后不断增加, 这让他们认为问题出在 GC.github
咱们试图得到有关此问题的更多信息, 了解问题出在 GC 仍是应用程序自己, 但咱们获得的是贡献者提交的一系列相似行为报告: 内存不断增加. 有了必定的线索后,咱们决定把它分红多个问题,并独立跟进. 最后,大多数问题均可以解释为对.NET中内存消耗的工做原理存在误解, 但也存在如何测量的问题.web
为了帮助 .NET 开发人员更好地了解他们的应用程序,咱们须要了解内存管理在 ASP.NET Core 中的工做方式、如何检测内存相关问题以及如何防止常见错误.数据库
GC按段分配,其中每一个段是连续的内存范围. 放在其中的对象分为三代 0, 1, 2. 代决定了GC 尝试在应用程序再也不引用的托管对象上释放内存的频率 - 数字越小频率越高.json
对象根据其生存期从一代移动到另外一代. 随着对象存在周期的延长,它们会被移动到更高的代中, 并减小回收检查次数. 生存期较短的对象 (如Web请求生命周期期间引用的对象)将始终保留在第 0 代中. 而应用程序级别的单例对象极可能移动到第1代,并最终移动到第2代.数组
当 ASP.NET Core 应用启动时, GC将为初始堆段保留一些内存, 并在加载运行时提交其中的一小部分. 这样作是出于性能缘由,所以堆段能够位于连续内存中.缓存
重要: ASP.NET Core 进程在启动时将会预先分配大量内存.服务器
手动调用GC执行 GC.Collect()
. 将触发第2代和全部较低代回收. 这一般仅在调查内存泄漏时使用, 确保在测量前GC移除内存中全部悬空对象.网络
注意: 应用程序不该直接调用
GC.Collect()
.并发
专用工具可帮助分析内存使用状况:
然而为了简单起见,本文不会使用这些,而是呈现一些应用内实时图表.
要深刻分析,请阅读这些文章 其中演示如何使用 Visual Studio .NET:
大多数时候,任务管理 中显示的内存度量值用于了解ASP.NET应用程序内存量. 此值表示计算机进程使用的内存量, ASP.NET应用程序的生存对象和其余内存使用者,如本机内存使用状况.
此值表示ASP.NET的进程的内存使用量, 其中包括应用程序的活动对象和其余内存使用者(如本机内存)
看到此值无限增长是代码中某处存在内存泄漏的线索,但它没法解释它是什么. 下一节将向您介绍特定的内存使用模式并对其进行解释.
完整的源代码在 GitHub 上提供 https://github.com/sebastienros/memoryleak
一旦应用程序启动,应用程序显示一些内存和GC统计信息,页面每隔一秒钟刷新一次. 特定的API接口执行特定的内存分配模式.
测试此应用程序, 只需启动它. 您能够看到分配的内存不断增长, 由于显示这些统计信息就是在分配自定义对象. GC 最终运行并收集它们.
此页显示一个包含分配内存和GC集合的图. 图例还显示 CPU 使用率和吞吐量(以请求数/秒表示).
图表显示内存使用状况的两个值:
如下 API 建立一个 10KB String
实例并返回到客户端. 每一个请求在内存中分配一个新对象,并在响应上写入.
注意: .NET中字符串以UTF-16编码存储,所以每一个字符在内存中须要两个字节.
[HttpGet("bigstring")] public ActionResult<string> GetBigString() { return new String('x', 10 * 1024); }
下图以相对较小的5K RPS负载生成,以便了解内存分配如何受到GC的影响.
在此示例中, 当分配达到略高于300MB 的阈值时,GC大约每两秒钟收集一次0代实例. 工做集稳定在 500 MB 左右, CPU使用率低.
此图显示的是,在相对较低的请求吞吐量时,内存消耗很是稳定,达到 GC 选择的量.
一旦负载增长到机器能够处理的最大吞吐量,将绘制如下图表.
有一些值得注意的点:
咱们看到的是,只要CPU没有被过分利用, 垃圾回收能够处理大量的分配.
.NET 垃圾收集器能够在两种不一样的模式下工做, 分别为 Workstation GC 和 Server GC. 正如名字所述, 它们针对不一样的工做负载进行了优化. ASP.NET 应用默认使用Server GC 模式, 而桌面应用使用 Workstation GC 模式.
区分两种模式的影响, 咱们能够经过修改项目文件(.csproj
)中ServerGarbageCollection
参数,强制Web应用使用 Workstation GC. 这须要从新生成应用程序.
<ServerGarbageCollection>false</ServerGarbageCollection>
也能够经过在已发布的应用程序的文件 runtimeconfig.json
设置 System.GC.Server
属性来完成.
如下是5K RPS使用Workstation GC下的内存使用状况.
差别是巨大的:
在典型的 Web 服务器环境中,CPU资源比内存更重要, 所以使用Server GC更合适. 然而, 某些服务器可能更适合使用Workstation GC, 例如当一个服务器托管了多个Web应用程序时,内存资源更加宝贵.
注意: 在单核心机器上,GC的模式老是 Workstation.
即便垃圾回收器在防止内存增加方面作得很好, 若是对象由用户代码持续持有, GC就无法释放它. 若是此类对象使用的内存量不断增长, 这叫作托管内存泄漏.
如下 API 建立一个 10KB String
实例并返回到客户端. 不一样于第一个例子的是,此实例由静态成员引用, 这意味着它不会被回收.
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
异常致使进程崩溃.
经过此图表上能够看到,一旦开始在这个终结点上发起请求工做集再也不稳定,且不断增长. 在此期间,随着内存增长GC会尝试调用第2代垃圾回收释放内存, 这成功并释放了一些, 但这并无阻止工做集增加.
某些方案须要无限期地保留对象引用, 在这种状况下,缓解此问题的一种方法是使用WeakReference
类,以便在内存压力下仍能够回收对象上保留引用. 这是在ASP.NET Core中 IMemoryCache
的默认实现.
内存泄漏不必定是由对托管对象的持久引用形成的. 有些.NET对象依赖本机内存来运行. GC没法收集此内存,.NET对象须要使用本机代码释放它.
幸运的是 .NET 提供了 IDisposable
接口让开发人员主动释放本机内存. 即便 Dispose()
未及时调用, 类一般在终结器运行时自动执行... 除非类未正确实现.
让咱们看一下这个代码:
[HttpGet("fileprovider")] public void GetFileProvider() { var fp = new PhysicalFileProvider(TempPath); fp.Watch("*.*"); }
PhysicaFileProvider
是托管类, 所以全部实例将会在请求结束后回收.
下面是连续调用此 API 时生成的内存分析.
这个图表显示了这个类实现的一个明显问题, 它不断增长内存使用量. 这是一个已知问题,正在这里跟踪 https://github.com/aspnet/Home/issues/3110
一样的问题很容易在用户代码中发生, 不正确地释放类或忘记调用须要释放对象的 Dispose()
方法.
随着内存的连续分配和释放, 内存中可能发生碎片. 这是由于对象必须分配在连续的内存块中所致使. 为了缓解此问题, 每当垃圾回收器释放一些内存, 将尝试进行碎片整理. 这个过程叫作 压缩.
压缩面临的问题是,对象越大, 移动速度越慢. 当到达必定大小后,移动它所花费的时间使移动它再也不那么有效. 所以,GC 为这些大型对象建立一个特殊的内存区域, 成为 大型对象堆 (LOH). 大于 85,000 bytes (非 85 KB)的对象被放置在那里, 不压缩, 并且仅在2代回收时释放. 可是当LOH满的时候, 将会自动触发2代垃圾回收, 这本质上是较慢的, 由于它触发了全部其余代的回收.
下面是一个 API,它说明了此行为:
[HttpGet("loh/{size=85000}")] public int GetLOH1(int size) { return new byte[size].Length; }
下图显示了在最大负载下,调用使用84,975
字节数组终结点的内存分析
当调用同一个终结点,但只多了一个字节时, i.e. 84,976
bytes (byte[]结构在实际字节序列化的基础上有一些开销).
在这两种状况下,工做集大体相同, 稳定 450 MB. 但须要咱们注意的是,并不是回收了第0代, 咱们回收了第2代, 这须要更多的CPU时间,直接影响吞吐量 从 35K 到 18K RPS, 几乎减半.
这代表应避免很是大的对象. 例如ASP.NET Core Response Caching 中间件,将缓存项拆分为小于85,000字节的块以处理此状况.
下面是处理此行为的特定实现的一些连接
不是具体到内存泄漏问题,更多的是资源泄漏问题, 但这在用户代码中已经出现了不少次,值得在这里说起.
有经验的 .NET 开发者实现 IDisposable
接口释放对象或其余本机资源,如数据库链接和文件处理程序, 不这样作可能会致使内存泄漏 (参见前面的示例).
HttpClient
例外, 即便它实现 IDisposable
, 应该重用它,而不是在每次使用后释放.
这是一个API终结点,它在每次请求中都建立新的实例然后释放.
[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; }
当应用程序中止时,此实例最终将被释放.
这代表,可释放的资源也不意味着须要当即释放
注意: 从ASP.NET Core 2.1开始有个更好的方式处理
HttpClient
实例的生命周期 https://blogs.msdn.microsoft.com/webdev/2018/02/28/asp-net-core-2-1-preview1-introducing-httpclient-factory/
在上一个例子中咱们看到 咱们看到了如何使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; }
在一些负载下,咱们看到第0代回收每秒都在进行.
优化这些代码咱们,可使用ArrayPool<>
,将字节数组放入对象池中. 静态实例在请求之间重复使用.
此方案的特殊部分是,咱们从 API 返回一个池对象, 这意味着只要咱们从方法返回,就失去了对它的控制, 且没法释放它. 为了解决这个问题,咱们须要将数组池封装在可释放对象中, 而后将此对象注册到 HttpContext.Response.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代的回收也更少.
理解垃圾回收如何与ASP.NET Core协同工做,有助于调查内存压力问题,最终影响应用程序的性能.
应用本文中解释的实践应该能够防止应用程序出现内存泄漏的迹象.
进一步了解内存管理在 .NET 中的工做原理, 这里有一些推荐的文章.