8

ASP.NET Core IAsyncEnumerable 與 yield return 實測

 2 years ago
source link: https://blog.darkthread.net/blog/iasyncenumerable-in-mvc/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

ASP.NET Core IAsyncEnumerable 與 yield return 實測-黑暗執行緒

前幾天提到 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 秒。

Fig1_637983755233369450.png

接著,我們寫個 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,伺服器回應延遲明顯變小:

Fig2_637983755237155111.png

實務上如果要善用此優勢,呼叫端也要配合改寫。這也是 .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 筆。

Fig3_637983755241465104.gif

節省記憶體

最後,我們另設計一個大量資料實驗來驗證 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 中介層實作方式有關。

Fig4_637983755243394695.png

把數量提到到 500 萬筆差異更明顯,ToArray() 用掉 1GB 的記憶體,但 yield return 維持在 100MB 左右。

Fig5_637983755245288619.png

數量再增加到 1000 萬筆,ToArray() 版用了兩倍記憶體,達到 2GB,但 yield return 的數字蠻有趣,出現大幅波動,從 100M、41M 到 2M 都有,我的不專業解讀是:記憶體壓力過高可能會觸發某些機制進一步釋放記憶體,推測正確性尚待證實,但 yield return 耗用記憶體較少應無庸置疑。

Fig6_637983755247178403.png

實驗完畢。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK