ASP.NET Core IAsyncEnumerable 與 yield return 實測
0 |
前幾天提到 yield return 具有即時性高、省 RAM 省 CPU 的優點,更是串接出生產線模式的重要技術。我想起在 .NET 6 亮點快速巡覽提到 System.Text.Json 新增搭配 IAsyncEnumerable 應用的非同步串流解析功能。IAsyncEnumerable 不是新東西,.NET Core 3 時代就有了,應用在 ASP.NET MVC 能以非同步方式查詢及回傳資料,減少佔用 ThreadPool 提高產能(Throughput)並降低回應延遲。換言之,我們在 WebAPI 也可加入 yield return 達到省時省 CPU 省 RAM 效果。
光說不練不踏實,照慣例要寫個程式實測玩玩看。
用 dotnet new webapi -o AsyncStreamDemo
建立 WebAPI 專案,借用其中模擬回傳天氣預報 API:
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
修改程式,加入參數控制資料筆數及資料產生延遲,好調整突顯效能差異。
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get(int delay = 20, int count = 100)
{
Func<int> getRandomTemperature = () =>
{
Thread.Sleep(delay);
return Random.Shared.Next(-20, 55);
};
return Enumerable.Range(1, count).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = getRandomTemperature(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
即時性測試
產生一筆資料預設耗時為 20ms,故產生 100 筆會超過 2 秒,因此,資料傳到客戶端的時間估計會在 2 秒之後;使用 F12 開發者工具觀察,TTFB (Time To First Byte,瀏覽器送出 Request 到收到第一個 Byte 的時間)高達 3.5 秒。
接著,我們寫個 IAsyncEnumerable + yield return 版本。用昨天提到的 ApiController 多 HttpGet 並存技巧 新增名為 IAsyncEnumGet 的 Action,傳回型別為 IAsyncEnumerable<WeatherForecast> 並宣告為 async。為配合 async,延遲改用 Task.Delay() 搭配 await,原本全部做完回傳整個陣列改為每產生一筆就 yield return:
[HttpGet("[action]")]
public async IAsyncEnumerable<WeatherForecast> IAsyncEnumGet(int delay = 20, int count = 100)
{
Func<Task<int>> getRandomTemperature = async () =>
{
await Task.Delay(delay);
return Random.Shared.Next(-20, 55);
};
for (var i = 0; i < count; i++)
{
yield return new WeatherForecast
{
Date = DateTime.Now.AddDays(i),
TemperatureC = await getRandomTemperature(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
};
}
}
實測結果符合預期,總執行時間 3.85 秒沒什麼大改變,但 TTFB 已縮短到 475ms,伺服器回應延遲明顯變小:
實務上如果要善用此優勢,呼叫端也要配合改寫。這也是 .NET 6 System.Text.Json 新增 API - DeserializeAsyncEnumerable 的最大意義,不需等 JSON 傳完,採取邊接收邊解析的做法,實作細節可參考這篇:ASP.NET Core 6 and IAsyncEnumerable - Async Streamed JSON vs NDJSON。
半途中止省資源
對於執行費時且花資源的查詢,改用 yield return 還有個好處 - 遇到客戶端放棄查詢時可即時中止,不要再浪費資源處理剩下的部分。例如:客戶端發出一次結果筆數很多的查詢但中途放棄關閉連線,MVC 端偵測到連線中斷就停止後續資料處理。
微調程式,在迴圈加入 RequestAborted.IsCancellationRequested 判斷,當偵測到客戶端已中斷連線就 break 停止迴圈,為方便觀察,程式加入 _logger.LogInformation 回報執行狀態。
[HttpGet("[action]")]
public async IAsyncEnumerable<WeatherForecast> IAsyncEnumGet(int delay = 20, int count = 100)
{
Func<Task<int>> getRandomTemperature = async () =>
{
await Task.Delay(delay);
return Random.Shared.Next(-20, 55);
};
for (var i = 0; i < count; i++)
{
if (ControllerContext.HttpContext.RequestAborted.IsCancellationRequested)
{
_logger.LogInformation("Request Aborted");
break;
}
_logger.LogInformation("IAsyncEnumGet: {i}", i);
yield return new WeatherForecast
{
Date = DateTime.Now.AddDays(i),
TemperatureC = await getRandomTemperature(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
};
}
}
如以下展示,以瀏覽器執行 10 筆每次延遲兩秒的查詢,在第二筆顯示後按「停止」鈕,MVC 端偵測到立即中止,不浪費資源跑第 3 到 10 筆。
節省記憶體
最後,我們另設計一個大量資料實驗來驗證 yield return 節省記憶體效果。程式用 GC.GetTotalMemory(false) 查詢目前程序使用的記憶體,在每次測試前及測量後加入 GC.Collect() 回收記憶體。 (註:回收憶體的工作一般建議都交給 .NET Runtime 管理以達最佳化,不要自己呼叫 GC.Collect(),這裡是為了觀察數據,平常開發不需要)
static long startMemSize = 0;
static void ResetStartMemSz()
{
GC.Collect(2, GCCollectionMode.Forced, true, false);
startMemSize = GC.GetTotalMemory(false);
}
[HttpGet("[action]")]
public string GetMemorySize()
{
var memory = GC.GetTotalMemory(false) - startMemSize;
GC.Collect(2, GCCollectionMode.Forced, true, false);
return $"{memory / 1024 / 1024} MB";
}
[HttpGet("[action]")]
public IEnumerable<string> IEnumLargeData(int count)
{
ResetStartMemSz();
return Enumerable.Range(1, count)
.Select(o => Guid.NewGuid().ToString()).ToArray();
}
[HttpGet("[action]")]
public async IAsyncEnumerable<string> IAsyncEnumLargeData(int count)
{
ResetStartMemSz();
for (int i = 0; i< count; i++)
{
// 不另外設計非同步資料產生函式,隨便加個 await 滿足 async 要求
await Task.Delay(0);
yield return Guid.NewGuid().ToString();
}
}
寫了一段 PowerShell 做測試,分別呼叫 IEnumerable<string> (ToArry()) 及 IAsyncEnumerable<string> (yield return),觀察 ASP.NET Core 程式記憶體變化。
param([int]$count = 1000000)
Write-Host "資料筆數:$($count.ToString('n0'))" -Foreground Green
(1..3) | ForEach-Object {
Write-Host "IEnumerable" -Foreground Yellow
curl.exe "https://localhost:7155/WeatherForecast/IEnumLargeData?count=$count" > result.json
Write-Host (Invoke-WebRequest -Uri "https://localhost:7155/WeatherForecast/GetMemorySize").Content -Foreground Yellow
Write-Host "IAsyncEnumerable" -Foreground Cyan
curl.exe "https://localhost:7155/WeatherForecast/IAsyncEnumLargeData?count=$count" > result.json
Write-Host (Invoke-WebRequest -Uri "https://localhost:7155/WeatherForecast/GetMemorySize").Content -Foreground Cyan
}
測試 100 萬筆,ToArray() 用了 228MB,yield return 約 92MB。yield return 耗用量比想像多一些,推測與 IAsyncEnumerable 與 MVC 中介層實作方式有關。
把數量提到到 500 萬筆差異更明顯,ToArray() 用掉 1GB 的記憶體,但 yield return 維持在 100MB 左右。
數量再增加到 1000 萬筆,ToArray() 版用了兩倍記憶體,達到 2GB,但 yield return 的數字蠻有趣,出現大幅波動,從 100M、41M 到 2M 都有,我的不專業解讀是:記憶體壓力過高可能會觸發某些機制進一步釋放記憶體,推測正確性尚待證實,但 yield return 耗用記憶體較少應無庸置疑。
實驗完畢。
Experiments of using IAsyncEnumerable in ASP.NET Core WebAPI to provider better response time and saving CPU and memory.
Comments
Be the first to post a comment