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

Post a comment