內存管理很複雜, 即便在像 .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 中的工做原理, 這裏有一些推薦的文章.