【翻譯】- ASP.NET Core 中的內存管理和模式

內存管理很複雜‎, 即便在像 .NET 這樣的託管框架中. 分析和理解內存問題也很具挑戰性.git

最近 一個用戶在 ASP.NET Core 主存儲庫中 提交了一個問題 指出垃圾回收器(GC) "未運行垃圾回收", 那它就失去了存在的意義. 症狀如提交者描述那樣, 內存在請求後不斷增加, 這讓他們認爲問題出在 GC.github

‎咱們試圖得到有關此問題的更多信息‎, 瞭解問題出在 GC 仍是應用程序自己, 但咱們獲得的是貢獻者提交的一系列相似行爲報告: ‎內存不斷增加‎. 有了必定的線索後,咱們決定把它分紅多個問題,並獨立跟進. 最後,大多數問題均可以解釋爲對.NET中‎內存消耗的工做原理‎存在誤解, ‎但也存在如何測量的問題‎.web

爲了幫助 .NET 開發人員更好地瞭解他們的應用程序,咱們須要瞭解內存管理在 ASP.NET Core 中的工做方式、如何檢測內存相關問題以及如何防止常見錯誤.數據庫

垃圾回收在 ASP.NET Core 中如何工做

GC按段分配,其中每一個段是連續的內存範圍. 放在其中的對象分爲三代 0, 1, 2. 代決定了GC 嘗試在應用程序再也不引用的託管對象上釋放內存的頻率 - 數字越小頻率越高.json

對象根據其生存期從一代移動到另外一代. 隨着對象存在週期的延長,它們會被移動到更高的代中, 並減小回收檢查次數. 生存期較短的對象 (如Web請求生命週期期間引用的對象)將始終保留在第 0 代中. 而應用程序級別的單例對象極可能移動到第1代,並最終移動到第2代.數組

當 ASP.NET Core 應用啓動時, GC將爲初始堆段保留一些內存, 並在加載運行時提交其中的一小部分. 這樣作是出於性能緣由,所以堆段能夠位於連續內存中.緩存

重要: ASP.NET Core 進程在啓動時將會預先分配大量內存.服務器

顯式調用GC

‎手動調用GC執行‎ GC.Collect(). 將觸發第2代和全部較低代回收. ‎這一般僅在調查內存泄漏時使用‎, 確保在測量前GC移除內存中全部懸空對象.網絡

注意: 應用程序不該直接調用 GC.Collect().併發

‎分析應用程序的內存使用狀況‎

‎專用工具可幫助分析內存使用狀況‎:

  • 對象引用數量
  • ‎測量 GC 對 CPU 的影響‎
  • ‎測量每一代使用的空間‎

然而爲了簡單起見,本文不會使用這些,而是呈現一些應用內實時圖表.

要深刻分析,請閱讀這些文章 其中演示如何使用 Visual Studio .NET:

不使用調試器狀況下的內存使用狀況

在 Visual Studio 中衡量內存使用狀況

‎檢測內存問題‎

大多數時候,任務管理 中顯示的內存‎‎度‎‎量值用於瞭解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 GCServer 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字節的塊以處理此狀況.

下面是處理此行爲的特定實現的一些連接‎

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 中的工做原理‎, ‎這裏有一些推薦的文章‎.

垃圾回收

使用併發可視化工具瞭解不一樣的GC模式

GitHub地址 memoryleak

相關文章
相關標籤/搜索