ASP.NET Core 中的記憶體管理與記憶體回收行程 (GC)

作者:Sébastien RosRick Anderson

記憶體管理向來複雜,即使在 .NET 等受控架構中也是如此, 而分析並了解記憶體問題將是一大挑戰。 本文:

  • 旨在說明記憶體流失GC 無法正常運作的眾多問題。 之所以會發生這類問題,大多是因為不了解 .NET Core 中記憶體使用量的運作或測量方式。
  • 示範錯誤的記憶體使用方式,並建議替代方法。

.NET Core 中記憶體回收 (GC) 的運作方式

GC 會配置堆積區段,每個區段都是連續的記憶體空間。 放置於堆積中的物件則分類為 3 個層代,包括 0、1 與 2。 針對應用程式不再參考的受控物件,層代會決定使用 GC 釋放記憶體的頻率, 數字越小的層代將會更頻繁地進行 GC。

物件會根據存留期改變層代。 隨著物件保留越久,便會移至更高的層代。 如先前所述,較高層代的 GC 頻率較低, 短期存留物件則始終保留在層代 0 中。 例如,Web 要求期間參考的物件存留期較短, 單一應用程式層級則通常會移轉至層代 2。

若啟動 ASP.NET Core 應用程式,GC 會:

  • 為初始堆積區段保留部分記憶體。
  • 載入執行階段時提交少部分記憶體。

進行上述記憶體配置的目的是獲得更佳效能, 效能優勢則來自連續記憶體中的堆積區段。

GC.Collect 的注意事項

一般而言,在生產環境中,ASP.NET Core 應用程式不應直接使用 GC.Collect。 若不是在最適合的時機進行記憶體回收,可能會大幅降低效能。

GC.Collect 在調查記憶體流失情形時則大有幫助。 呼叫 GC.Collect() 會觸發封鎖性記憶體回收週期,並嘗試回收所有受控程式碼無法存取的物件。 藉此可了解堆積中有多少可存取的即時物件,並追蹤記憶體大小隨時間成長的狀況。

分析應用程式的記憶體使用量

可以使用專用工具協助分析記憶體使用量,包括:

  • 計算物件參考
  • 測量 GC 對 CPU 使用量的影響程度
  • 測量各層代使用的記憶體空間

分析記憶體使用量時,可使用下列工具:

偵測記憶體問題

在工作管理員中,可了解 ASP.NET 應用程式目前使用多少記憶體。 工作管理員中的記憶體數值:

  • 代表 ASP.NET 處理程序使用的記憶體數量。
  • 包含應用程式中處活動狀態的物件,以及其他記憶體取用者,例如原生記憶體使用量。

如果工作管理員記憶體數值持續無上限增加,且此趨勢從未趨緩,應用程式便會發生記憶體流失。 以下的章節將示範並說明數種記憶體使用模式。

記憶體使用量應用程式範例說明

GitHub 上提供了 MemoryLeak 範例應用程式。 MemoryLeak 應用程式:

  • 包含診斷控制器,可蒐集應用程式即時記憶體與 GC 資料。
  • 包含可顯示記憶體和 GC 資料的索引頁面, 並每秒重新整理一次。
  • 包含 API 控制器,可提供各種記憶體負載模式。
  • 雖然不是受支援的工具,但可用於顯示 ASP.NET Core 應用程式的記憶體使用量模式。

執行 MemoryLeak。 配置的記憶體會慢慢增加,直到進行 GC, 而記憶體持續增加的原因,是由於工具會配置自訂物件以擷取資料。 下圖為層代 0 進行 GC 時的 MemoryLeak 索引頁面。 由於沒有呼叫任何 API 控制器中的 API 端點,圖表顯示為 0 RPS (每秒要求數)。

Chart showing 0 Requests Per Second (RPS)

圖表會顯示記憶體使用量的兩個值:

  • 已配置:受控物件佔用的記憶體數量
  • 工作集:處理程序虛擬位址空間中目前位於實體記憶體的頁面集, 此值會與顯示於工作管理員的值相同。

暫時性物件

下列 API 會建立一個 10 KB 字串的執行個體,並傳回用戶端。 在每次要求時,都會在記憶體中配置新的物件,並寫入回應。 字串則會以 UTF-16 字元的形式儲存於 .NET,因此每個字元在記憶體中都會佔去 2 位元組。

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

若負載相對較小則會如下圖所示,可觀察到 GC 如何影響記憶體配置。

Graph showing memory allocations for a relatively small load

上圖顯示:

  • 4K RPS (每秒要求數)。
  • 層代 0 的 GC 回收約每兩秒發生一次。
  • 工作集的常數約為 500 MB。
  • CPU 為 12%。
  • 記憶體使用量和 (透過 GC 的) 釋放情形相當穩定。

在機器處理最大輸送量時,則會產生下圖。

Chart showing max throughput

上圖顯示:

  • 22K RPS
  • 層代 0 的 GC 回收每秒會發生數次。
  • 由於應用程式每秒配置更多記憶體,因此會觸發層代 1 回收。
  • 工作集的常數約為 500 MB。
  • CPU 為 33%。
  • 記憶體使用量和 (透過 GC 的) 釋放情形相當穩定。
  • CPU (33%) 未過度使用,因此記憶體回收可跟上大量配置的速度。

工作站 GC 與伺服器 GC

.NET 記憶體回收行程有兩種不同模式:

  • 工作站 GC:已針對桌面裝置進行最佳化。
  • 伺服器 GC: ASP.NET Core 應用程式預設的 GC 模式, 已針對伺服器進行最佳化。

可以在專案檔或已發佈之應用程式的 runtimeconfig.json 檔案中設定 GC 模式。 下列標記會顯示專案檔中 ServerGarbageCollection 的設定情形:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

若想在專案檔中變更 ServerGarbageCollection,則須重建應用程式。

注意:伺服器記憶體回收不適用於單核心機器。 如需詳細資訊,請參閱IsServerGC

下圖為在 5K RPS 下,使用工作站 GC 之記憶體設定檔的狀態。

Chart showing memory profile for a Workstation GC

此圖與伺服器 GC 的圖表相當不同:

  • 工作集會從 500 MB 降至 70 MB。
  • GC 每秒會進行多次層代 0 回收,而非每兩秒一次。
  • GC 從 300 MB 降至 10 MB。

在典型 Web 伺服器環境中,CPU 使用量比記憶體更重要,因此更適合使用伺服器 GC。 然而,如果記憶體使用率很高,而 CPU 使用量相對較低,則工作站 GC 可能會有較佳的效能表現。 例如,在缺乏記憶體的情況下,高密度裝載多個 Web 應用程式。

使用 Docker 和小型容器的 GC

在一部電腦上執行多個容器化應用程式時,工作站 GC 的效能表現可能會優於伺服器 GC。 如需詳細資訊,請參閱在小型容器中執行伺服器 GC,以及在小型容器中執行伺服器 GC,案例第 1 部分 – GC 堆積的硬限制

持續性物件參考

GC 無法釋放參考物件, 而不再需要的參考物件會導致記憶體流失。 如果應用程式經常配置物件,但在不需要後並無進行釋放,記憶體使用量將隨時間持續增加。

下列 API 會建立一個 10 KB 字串的執行個體,並傳回用戶端。 與上一個範例的差異在於,靜態成員會參考此執行個體,因此永遠無法回收。

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 例外狀況。

Chart showing a memory leak

在上圖中:

  • /api/staticstring 端點進行負載測試會導致記憶體使用量呈線性增加。
  • 在記憶體壓力增加時,GC 會呼叫層代 2 回收以釋放記憶體。
  • GC 無法釋放流失的記憶體, 已配置的記憶體和工作集會隨著時間增加。

在快取等部分情境中,必須保留物件參考,直到記憶體壓力過大而不得不釋放。 WeakReference 類別可用於此類的快取程式碼。 WeakReference 物件會因為記憶體壓力進行回收。 IMemoryCache 的預設執行會使用 WeakReference

原生記憶體

部分 .NET Core 物件依賴原生記憶體, 然而 GC 無法回收原生記憶體。 使用原生記憶體的 .NET 物件必須利用機器碼進行釋放。

開發人員可利用 .NET 提供的 IDisposable 介面釋放原生記憶體。 即使未呼叫 Dispose,正確實施的類別也會在完成項執行時呼叫 Dispose

請考慮下列程式碼:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider 是一項受控類別,因此要求結束後會回收所有執行個體。

下圖為持續叫用 fileprovider API 時,記憶體設定檔的狀態。

Chart showing a native memory leak

從上圖可看出,此類別執行的主要問題在於會持續增加記憶體使用量, 而這種狀況並非首次出現,在此問題中已進行追蹤。

若出現下列情況,使用者程式碼中也可能發生同樣的流失問題:

  • 未正確釋放類別。
  • 忘記叫用 Dispose 方法以處置相依物件。

大型物件堆積

若頻繁配置記憶體/釋放週期,可能會導致記憶體破碎,在配置大型記憶體時此問題尤其嚴重。 物件須配置在連續的記憶體區塊中, 為了盡可能保持完整,當 GC 釋放記憶體時,記憶體會嘗試進行重組, 此程序即稱為「壓縮」。 壓縮時必須移動物件, 然而移動大型物件會降低效能。 因此,GC 會為大型物件建立特殊的記憶體區域,稱為大型物件堆積 (LOH)。 若物件大於 85,000 位元組 (約 83 KB),將會:

  • 放置於 LOH。
  • 不進行壓縮。
  • 在層代 2 GC 期間進行回收。

若 LOH 已滿,GC 便會觸發層代 2 回收。 層代 2 回收:

  • 過程較耗時。
  • 此外,會產生觸發其他層代回收的成本。

若想立即壓縮 LOH,可利用下列程式碼:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

如需壓縮 LOH 的資訊,請參閱 LargeObjectHeapCompactionMode

在使用 .NET Core 3.0 和更高版本的容器中,LOH 會自動壓縮。

下列 API 將說明這個行為:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

下圖為最大負載下呼叫 /api/loh/84975 端點時,記憶體設定檔的狀態:

Chart showing memory profile of allocating bytes

下圖為在僅配置一個位元組的情況下,呼叫 /api/loh/84976 端點時記憶體設定檔的狀態:

Chart showing memory profile of allocating one more byte

注意:byte[] 結構具有額外負荷位元組, 這也是為何 84,976 位元組會觸發 85,000 位元組的限制。

比較以上兩個圖表,可發現:

  • 這兩種情境下的工作集都大約為 450 MB。
  • 若低於 LOH 要求 (84,975 位元組),大多數時間會進行層代 0 回收。
  • 若高於 LOH 要求,則會穩定執行層代 2 回收。 層代 2 回收的成本高昂, 必須耗費更多 CPU 資源,而輸送量下降的幅度幾乎達 50%。

由於大型物件會導致層代 2 GC,想暫存更是困難重重。

為了達到最佳效能,應盡可能避免使用大型物件。 若可行,請分割大型物件。 例如,ASP.NET Core 中的回應快取中介軟體會將快取項目分割為小於 85,000 位元組的區塊。

若想了解如何利用 ASP.NET Core 方法,在 LOH 限制下保留物件,請參閱以下連結:

如需詳細資訊,請參閱

HttpClient

若未正確使用 HttpClient 可能會導致資源流失。 系統資源,例如資料庫連線、通訊端、檔案控制代碼等:

  • 數量較記憶更少。
  • 流失時會造成比記憶體流失更嚴重的問題。

經驗豐富的 .NET 開發人員會知道要針對執行 IDisposable 的物件呼叫 Dispose。 若未處置執行 IDisposable 的物件,通常會導致記憶體或系統資源流失。

HttpClient 會執行 IDisposable,但不應在每次叫用時都進行處置, 而是應重複使用 HttpClient

在每次要求時,下列端點會建立並處置新的 HttpClient 執行個體:

[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;
}

當應用程式停止,便會釋放 HttpClient 實例。 從範例中可看出,並非所有可處置資源在使用後都須進行處置。

若想了解如何更妥善地處理 HttpClient 執行個體存留期,請參閱以下內容:

物件集區

前一個範例示範如何將 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;
        }

下圖為在中等負載下,呼叫上述 API 的情況:

Chart showing calls to API with moderate load

在上圖中,層代 0 回收約每秒發生一次。

可使用 ArrayPool<T> 共用 byte 緩衝區,以最佳化上述程式碼, 靜態執行個體則會在要求之間重複使用。

此方法的特殊之處,在於 API 會傳回集區物件。 這表示:

  • 傳回後,物件會立即脫離控制。
  • 無法釋放物件。

若要設定物件的處置方式,請執行以下操作:

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;
}

若套用與非集區版本相同的負載,結果將如下圖所示:

Chart showing fewer allocations

主要差異在於配置的位元組,也因此層代 0 回收數較少。

其他資源