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


內存管理很復雜‎, 即使在像 .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:

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

在 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM