當前位置: 妍妍網 > 碼農

使用 ValueTask 異步操作的節省記憶體

2024-03-27碼農

概述: 在現代 C# 編程中,了解異步構造(如 和 )之間的細微差別對於最佳化記憶體使用率和應用程式效能至關重要。這個故事深入探討了一個真實世界的場景,在這個場景中,使用可以節省大量記憶體。透過實際用例和基準分析,我們探討了如何有效地處理異步操作,同時最大限度地減少記憶體分配。ValueTaskTaskValueTaskValueTask大多數時候可能不會擊中。await大多數情況下,您將立即可用的結果,因此該方法將同步完成。await實際使用案例在下面的範例中,我們將研究第一個用例,我們將在其中說明什麽是以及我們如何從中受益。這個用例是大多數開發人員現在可能在他們的程式碼中擁有的東西,並且套用該構造,我們將節

在現代 C# 編程中,了解異步構造(如 和 )之間的細微差別對於最佳化記憶體使用率和應用程式效能至關重要。這個故事深入探討了一個真實世界的場景,在這個場景中,使用可以節省大量記憶體。透過實際用例和基準分析,我們探討了如何有效地處理異步操作,同時最大限度地減少記憶體分配。ValueTaskTaskValueTaskValueTask

  1. 大多數時候可能不會擊中。await

  2. 大多數情況下,您將立即可用的結果,因此該方法將同步完成。await

實際使用案例

在下面的範例中,我們將研究第一個用例,我們將在其中說明什麽是以及我們如何從中受益。這個用例是大多數開發人員現在可能在他們的程式碼中擁有的東西,並且套用該構造,我們將節省相當大的記憶體分配塊。那是因為工作方式與 .讓我們嘗試透過檢視程式碼範例來解釋它們的不同之處。ValueTaskValueTaskValueTaskTask

假設我們有一個名為 .這是一項服務,您可以在其中傳入 GitHub 使用者名稱,並呼叫公共 GitHub API 以獲取有關該特定使用者的資訊。一旦它進行呼叫以獲取使用者資訊,它就會將其緩存在記憶體中一個小時,就像我們在下面的程式碼範例中看到的那樣:GitHubService

public classGitHubService
{
privatereadonlyIMemoryCache _cachedGitHubUserInfo = newMemoryCache(newMemoryCacheOptions());
privatestaticreadonlyHttpClient HttpClient = new()
{
BaseAddress = newUri("https://api.github.com/"),
};
staticGitHubService()
{
HttpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/vnd.github.v3+json");
HttpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, $"Medium-Story-{Environment.MachineName}");
}
publicasyncTask<GitHubUserInfo?>GetGitHubUserInfoAsyncTask(string username)
{
var cacheKey = ("github-", username);
var gitHubUserInfo = _cachedGitHubUserInfo.Get\<GitHubUserInfo>(cacheKey);
if (gitHubUserInfo isnull)
{
var response = await HttpClient.GetAsync($"/users/{username}");
if (response.StatusCode == HttpStatusCode.OK)
{
gitHubUserInfo = await response.Content.ReadFromJsonAsync<GitHubUserInfo>();
_cachedGitHubUserInfo.Set(cacheKey, gitHubUserInfo, TimeSpan.FromHours(1));
}
}
return gitHubUserInfo;
}
}





最後,它返回一個物件,其中包含 GitHub 使用者的 、 、 和 的 :usernameprofileUrlnamecompanyrecordGitHubUserInfo

publicrecordGitHubUserInfo([property: JsonPropertyName("login")] string Username,
[property: JsonPropertyName("html_url")] string ProfileUrl,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("company")] string Company);

假設這是一個更大的CRM系統的一部份,您可以在其中保存有關客戶的資訊,在我們的案例中,這些客戶恰好是開發人員。

在許多用例中,您可以有效地從外部第三方 API 或其他服務或資料庫獲取某些內容,將其緩存在記憶體中,然後在應用程式中對其進行某些操作。而且,與往常一樣,我們應該嘗試使用一種方法 ,因為呼叫 API 是一個 I/O 操作,我們不希望在這裏對我們的執行緒進行阻塞操作

這裏的問題是,我們返回一個 ,而 a 是將在堆上分配的參照型別。如果我們更仔細地考慮一下,在我們的範例中,我們只需要每小時返回一次特定的 GitHub 使用者名稱,而其他所有時間我們只需要返回我們保存在記憶體緩存中的使用者資訊。TaskTaskTask

ValueTask 來救援

這就是發揮作用的地方。此範例是目前在程式碼中用於節省記憶體的最簡單方法之一。實際上是一種有區別的聯集,可以是以下兩種情況之一:a(即某種型別)或 .這意味著我們可以將返回型別替換為 a 並獲得一些記憶體分配,因為只有當我們的方法實際需要返回 .ValueTaskValueTaskValueTaskTTask<T>Task<T>ValueTask<T>TaskTask

在所有其他時候,我們從記憶體緩存中取回使用者資訊,我們不需要分配任何寶貴的記憶體。因此,如果我們想配置相同的方法來返回 a 而不是 ,我們可以透過引入一個名為 inside 的新方法來編寫它,正如我們在下面的程式碼片段中看到的那樣:GetGitHubUserInfoAsyncTaskValueTask<GitHubUserInfo>Task<GitHubUserInfo>GetGitHubUserInfoAsyncValueTaskGitHubService

publicasyncValueTask<GitHubUserInfo?>GetGitHubUserInfoAsyncValueTask(string username)
{
var cacheKey = ("github-", username);
var gitHubUserInfo = _cachedGitHubUserInfo.Get<GitHubUserInfo>(cacheKey);
if (gitHubUserInfo isnull)
{
var response = await HttpClient.GetAsync($"/users/{username}");
if (response.StatusCode == HttpStatusCode.OK)
{
gitHubUserInfo = await response.Content.ReadFromJsonAsync\<GitHubUserInfo>();
_cachedGitHubUserInfo.Set(cacheKey, gitHubUserInfo, TimeSpan.FromHours(1));
}
}
return gitHubUserInfo;
}



基準分析:與 ValueTask 效能評估Task

讓我們嘗試透過執行基準測試來了解 和 返回型別之間的區別。為此,我們將使用 BenchmarkDotNet 庫。首先,我們建立一個名為的類,並用內容裝飾它。這意味著,每當我們執行這些基準測試時,我們都會得到實際為它們分配的記憶體量:Task<T>ValueTask<T>GitHubServiceBenchmarksMemoryDiagnoser

[MemoryDiagnoser]
public classGitHubServiceBenchmarks
{
privatestaticreadonlyGitHubService GitHubService = new();
[Benchmark]
publicasyncTask<GitHubUserInfo?>GetGitHubUserInfoAsyncTask()
{
returnawait GitHubService.GetGitHubUserInfoAsyncTask("ormikopo1988");
}
}

在我們的第一個基準測試中(上面的細節),我們有一個任務,我們只返回某種型別的,在本例中為 。TaskGitHubUserInfo

註意:請記住在執行基準測試之前將控制台套用的 Configuration Manager 更改為「釋出」模式。

在第一次執行期間,對於特定請求的 GitHub 使用者名稱,我們的緩存將為空,因此我們將呼叫 GitHub API。在得到響應後,我們立即將其緩存 1 小時,遵循所謂的緩存端模式。之後,在接下來的 1 小時內,對同一 GitHub 使用者名稱的所有其他呼叫都將從記憶體中獲取物件,這幾乎是我們在典型系統中可能遇到的情況,只要此資訊不經常更改並且我們希望將其緩存在某個地方。GitHubUserInfo

var cacheKey = ("github-", username);
var gitHubUserInfo = _cachedGitHubUserInfo.Get<GitHubUserInfo>(cacheKey);
if (gitHubUserInfo isnull)
{
var response = await HttpClient.GetAsync($"/users/{username}");
if (response.StatusCode == HttpStatusCode.OK)
{
gitHubUserInfo = await response.Content.ReadFromJsonAsync<GitHubUserInfo>();
_cachedGitHubUserInfo.Set(cacheKey, gitHubUserInfo, TimeSpan.FromHours(1));
}
}
return gitHubUserInfo;



現在讓我們添加第二個基準測試,在這個基準測試中,我們將呼叫該方法,而不是 ,返回 :GetGitHubUserInfoAsyncValueTaskTaskValueTaskGitHubUserInfo

[Benchmark]
publicasyncTask<GitHubUserInfo?>GetGitHubUserInfoAsyncValueTask()
{
returnawait GitHubService.GetGitHubUserInfoAsyncValueTask("ormikopo1988");
}

首先,讓我們試著明確表示,我們不希望看到速度上的任何差異。這裏重要的是記憶體,我們投入垃圾回收器的工作量以及我們在堆上分配的記憶體。

如果我們執行這個基準測試並等到它完成,看看結果是什麽樣子的,我們可能會看到它更快一些,但如果我們再次執行測試,可能會更快一些。因此,為了簡單起見,就速度而言,我們將假設兩個範例是相同的:TaskValueTask

基準測試結果

需要註意的有趣事情是,垃圾回收和分配的記憶體少了 72 個字節。但是你可能會問,72 字節真的重要嗎?好吧,如果我們考慮一下,它不僅總共有 72 個字節。每次我們呼叫該特定方法時,以及每次有該方法的任何其他呼叫方也返回 .Gen0Task

但這究竟意味著什麽?因為使用 ,所以在第二個基準測試中,即使該方法實際上返回 .這意味著我們可能沒有充分利用這裏的優勢,因為基準測試方法也可以使用這種效能改進。ValueTaskTaskTaskGetGitHubUserInfoAsyncValueTaskGitHubServiceValueTaskValueTask

實際上,讓我們建立第三個基準測試方法,該方法也將返回 a 而不是 ,並且仍然從以下位置呼叫相同的方法:GetGitHubUserInfoAsyncValueTaskTimesTwoValueTaskTaskGetGitHubUserInfoAsyncValueTaskGitHubService

[Benchmark]
publicasyncValueTask<GitHubUserInfo?>GetGitHubUserInfoAsyncValueTaskTimesTwo()
{
returnawait GitHubService.GetGitHubUserInfoAsyncValueTask("ormikopo1988");
}

請註意,我們將在這裏看到的結果僅適用於父方法也可以從使用 中受益的情況,這意味著它不會呼叫任何與 I/O 相關的內容。如果確實如此,那麽記憶體會更低,因為我們也將保存第二個分配,這會產生連鎖效應。我們只想切斷鏈條,然後我們可以繼續使用一個。ValueTaskTaskTask

非常重要的一點是,一旦我們等待一個,我們就不想再次附加它。這與 A 的工作方式完全不同 。我們也不想有一個 像 or 這樣的結構 ,並以任何身份以大規模的方式等待它們。基本上,一旦我們用一個做某事,我們需要保持原樣。ValueTaskTaskValueTaskTask.WhenAllTask.WaitAllValueTask

回到我們的例子,正如我們在第三個基準測試中看到的那樣,我們又節省了 72 個字節,因為我們也節省了第二個分配:

基準測試結果

因此,如果我們每秒有 1000 個請求,並且我們設法將這種技術用於其中的 50 或 100 個,我們將節省相當多的記憶體。我們研究的用例很常見,也是對該型別的最安全的介紹。還有一些不那麽簡單的地方,我們也可以利用它來發揮我們的優勢,但這超出了本文的範圍。

最後,請始終記住,正如我們上面已經提到的,有一些註意事項。例如,如果我們有一個 ,並且我們等待它,那麽我們真的不應該重新等待它。我們不能將其保留為記憶體並繼續等待它(例如,出於初始化目的)。如果我們小心這些事情,那麽我們應該沒事。

那麽我們應該到處使用嗎?好吧,根據經驗,如果我們有一些程式碼,就像我們之前檢查過的程式碼一樣,我們有某種緩存或某種機制,可以防止我們的程式碼大多數時候進入方法的 I/O 部份,那麽我們可以使用並獲得分配中的這些效能改進。這一點很重要,尤其是在高度可延伸和高吞吐量的應用程式中,每個額外的記憶體分配都非常重要。ValueTaskValueTask

但是,一般來說,我們不希望在程式碼中從一開始就使用 a,除非我們真的知道自己在做什麽。基本上,我們應該始終以 .如果我們開始發現這些效能下降或效能瓶頸,並且我們想在記憶體分配方面最佳化我們的應用程式,並且我們處於一個範例的上下文中,就像我們在當前故事中展示的那樣,那麽這是一個很好且安全的起點,始終牢記註意事項。

透過實際範例和基準分析,這個故事展示了過渡到如何顯著減少記憶體分配,尤其是在緩存結果普遍存在的情況下。雖然提供了令人信服的好處,但考慮到它的細微差別,明智地使用它至關重要。透過有選擇地整合,開發人員可以釋放效能提升,尤其是在高吞吐量套用中。