ASP.NET Core 中的記憶體管理與記憶體回收行程 (GC)
作者:Sébastien Ros 與 Rick 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 (每秒要求數)。
圖表會顯示記憶體使用量的兩個值:
- 已配置:受控物件佔用的記憶體數量
- 工作集:處理程序虛擬位址空間中目前位於實體記憶體的頁面集, 此值會與顯示於工作管理員的值相同。
暫時性物件
下列 API 會建立 20 KB 的 String 實例,並將它傳回給用戶端。 在每次要求時,都會在記憶體中配置新的物件,並寫入回應。 字串則會以 UTF-16 字元的形式儲存於 .NET,因此每個字元在記憶體中都會佔去 2 位元組。
[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
return new String('x', 10 * 1024);
}
若負載相對較小則會如下圖所示,可觀察到 GC 如何影響記憶體配置。
上圖顯示:
- 4K RPS (每秒要求數)。
- 層代 0 的 GC 回收約每兩秒發生一次。
- 工作集的常數約為 500 MB。
- CPU 為 12%。
- 記憶體使用量和 (透過 GC 的) 釋放情形相當穩定。
在機器處理最大輸送量時,則會產生下圖。
上圖顯示:
- 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 之記憶體設定檔的狀態。
此圖與伺服器 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 會建立 20 KB 的 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
例外狀況。
在上圖中:
- 對
/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 時,記憶體設定檔的狀態。
從上圖可看出,此類別執行的主要問題在於會持續增加記憶體使用量, 而這種狀況並非首次出現,在此問題中已進行追蹤。
若出現下列情況,使用者程式碼中也可能發生同樣的流失問題:
- 未正確釋放類別。
- 忘記叫用
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
端點時,記憶體設定檔的狀態:
下圖為在僅配置一個位元組的情況下,呼叫 /api/loh/84976
端點時記憶體設定檔的狀態:
注意: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 的情況:
在上圖中,層代 0 回收約每秒發生一次。
可使用 ArrayPool<T> 共用 byte
緩衝區,以最佳化上述程式碼, 靜態執行個體則會在要求之間重複使用。
此方法的特殊之處,在於 API 會傳回集區物件。 這表示:
- 傳回後,物件會立即脫離控制。
- 無法釋放物件。
若要設定物件的處置方式,請執行以下操作:
- 將集區陣列封裝在可處置物件中。
- 使用 HttpCoNtext.Response.RegisterForDispose 註冊集區物件。
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 回收數較少。