內存管理很復雜, 即使在像 .NET 這樣的托管框架中. 分析和理解內存問題也很具挑戰性.
最近 一個用戶在 ASP.NET Core 主存儲庫中 提交了一個問題 指出垃圾回收器(GC) "未運行垃圾回收", 那它就失去了存在的意義. 症狀如提交者描述那樣, 內存在請求后不斷增長, 這讓他們認為問題出在 GC.
我們試圖獲得有關此問題的更多信息, 了解問題出在 GC 還是應用程序本身, 但我們得到的是貢獻者提交的一系列類似行為報告: 內存不斷增長. 有了一定的線索后,我們決定把它分成多個問題,並獨立跟進. 最后,大多數問題都可以解釋為對.NET中內存消耗的工作原理存在誤解, 但也存在如何測量的問題.
為了幫助 .NET 開發人員更好地了解他們的應用程序,我們需要了解內存管理在 ASP.NET Core 中的工作方式、如何檢測內存相關問題以及如何防止常見錯誤.
垃圾回收在 ASP.NET Core 中如何工作
GC按段分配,其中每個段是連續的內存范圍. 放在其中的對象分為三代 0, 1, 2. 代決定了GC 嘗試在應用程序不再引用的托管對象上釋放內存的頻率 - 數字越小頻率越高.
對象根據其生存期從一代移動到另一代. 隨着對象存在周期的延長,它們會被移動到更高的代中, 並減少回收檢查次數. 生存期較短的對象 (如Web請求生命周期期間引用的對象)將始終保留在第 0 代中. 而應用程序級別的單例對象很可能移動到第1代,並最終移動到第2代.
當 ASP.NET Core 應用啟動時, GC將為初始堆段保留一些內存, 並在加載運行時提交其中的一小部分. 這樣做是出於性能原因,因此堆段可以位於連續內存中.
重要: ASP.NET Core 進程在啟動時將會預先分配大量內存.
顯式調用GC
手動調用GC執行 GC.Collect()
. 將觸發第2代和所有較低代回收. 這通常僅在調查內存泄漏時使用, 確保在測量前GC移除內存中所有懸空對象.
注意: 應用程序不應直接調用
GC.Collect()
.
分析應用程序的內存使用情況
專用工具可幫助分析內存使用情況:
- 對象引用數量
- 測量 GC 對 CPU 的影響
- 測量每一代使用的空間
然而為了簡單起見,本文不會使用這些,而是呈現一些應用內實時圖表.
要深入分析,請閱讀這些文章 其中演示如何使用 Visual Studio .NET:
檢測內存問題
大多數時候,任務管理 中顯示的內存度量值用於了解ASP.NET應用程序內存量. 此值表示計算機進程使用的內存量, ASP.NET應用程序的生存對象和其他內存使用者,如本機內存使用情況.
此值表示ASP.NET的進程的內存使用量, 其中包括應用程序的活動對象和其他內存使用者(如本機內存)
看到此值無限增加是代碼中某處存在內存泄漏的線索,但它無法解釋它是什么. 下一節將向您介紹特定的內存使用模式並對其進行解釋.
運行應用程序
完整的源代碼在 GitHub 上提供 https://github.com/sebastienros/memoryleak
一旦應用程序啟動,應用程序顯示一些內存和GC統計信息,頁面每隔一秒鍾刷新一次. 特定的API接口執行特定的內存分配模式.
測試此應用程序, 只需啟動它. 您可以看到分配的內存不斷增加, 因為顯示這些統計信息就是在分配自定義對象. GC 最終運行並收集它們.
此頁顯示一個包含分配內存和GC集合的圖. 圖例還顯示 CPU 使用率和吞吐量(以請求數/秒表示).
圖表顯示內存使用情況的兩個值:
- Allocated(分配): 托管對象占用的內存量
- Working Set(工作集): 進程使用的總物理內存(RAM) (如任務管理器中顯示的)
瞬態對象
以下 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沒有被過度利用, 垃圾回收可以處理大量的分配.
Workstation GC vs. Server GC
.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下的內存使用情況.
差異是巨大的:
- 工作集從 500MB 到 70MB
- GC每秒執行多次0代回收,而不是每兩秒執行一次
- GC 閾值從 300MB 到 10MB
在典型的 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字節的塊以處理此情況.
下面是處理此行為的特定實現的一些鏈接
- https://github.com/aspnet/ResponseCaching/blob/c1cb7576a0b86e32aec990c22df29c780af29ca5/src/Microsoft.AspNetCore.ResponseCaching/Streams/StreamUtilities.cs#L16
- https://github.com/aspnet/ResponseCaching/blob/c1cb7576a0b86e32aec990c22df29c780af29ca5/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs#L55
HttpClient
不是具體到內存泄漏問題,更多的是資源泄漏問題, 但這在用戶代碼中已經出現了很多次,值得在這里提及.
有經驗的 .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 中的工作原理, 這里有一些推薦的文章.