陸續看過 .NET ThreadPool 執行緒數量增減模式,也見識了 async/await 提高 Thread 使用率的神奇效果,將焦點放在 ASP.NET 網站,實際測試 async/await 是否能提高 ASP.NET 效能?ThreadPool.SetMinThreads() 是否能幫助網站應付暴增流量?

先說明實驗環境。

沿用先前 K6 壓測用的靶機 ASP.NET Core WebAPI 專案,加入兩個 API,透過 JSON 接入參數跟回傳結果,但過程分別用 Thread.Sleep() 及 await Task.Delay() 等待 100ms:

[HttpPost]
[Route(nameof(ThreadSleep))]
public IActionResult ThreadSleep(RegisterRequest req)
{
    Thread.Sleep(100);
    return Ok(new RegisterResponse
    {
        LotteryUid = req.LotteryUid,
        RespSign = "OK"
    });
}

[HttpPost]
[Route(nameof(TaskDelay))]
public async Task<IActionResult> TaskDelay(RegisterRequest req)
{
    await Task.Delay(100);
    return Ok(new RegisterResponse
    {
        LotteryUid = req.LotteryUid,
        RespSign = "OK"
    });
}

K6 有所謂的 尖峰測試/Spike Test,模擬短時間湧入超大流量但隨後消失的情境,常見於演唱會門票銷售、PS5/iPhone 開賣、1111 限量搶購.. 等現實世界的熱門活動。

借用先前壓測同時蒐集 CPU 使用率的測試腳本,調整為流量階段,設定前 20 秒,Arrival Rate 由 0 提高到 600 RPS(每秒發出 600 個 Request),10 秒後再降回 0,模擬出 30 秒短暫流量尖峰:

const stages = [
  { duration: '20s', target: 600 },
  { duration: '10s', target: 0 }        
];
export const options = {
    systemTags: ['status','error'],
    scenarios: {
      stress: {
        executor: 'ramping-arrival-rate',
        preAllocatedVUs: 30000,
        timeUnit: "1s",
        stages: stages
      },
      monitor: {
        executor: 'constant-arrival-rate',
        preAllocatedVUs: 1,
        rate: 1,
        duration: '60s',
        timeUnit: '1s',
        exec: 'monitor' 
      }
    }
};

測試主機為 16 核,ThreadPool 設定採預設值,故超始工作 Thread 數為 16。當流量進來,.NET 預計會逐步增加 Thread 數消化工作(依經驗,30 秒可增加到 50 條左右)。我另外在 ASP.NET Core Program.cs 加一段程式,每秒一次記錄 ThreadPool 目前的 Thread 數、已完成工作數、Queue 待處理工作數:(寫法簡單粗暴,無窮迴圈加 Thread.Sleep(1000) 不考慮中止,刻意用 Thread.Sleep() 而非 await Task.Delay(),是為避免 await 完可能搶不到 Thread)

var logPath = Path.Combine("D:\\Logs", $"{DateTime.Now:HHmmss}.log");
Task.Run(() => {
    while (true) {
        var line = 
            DateTime.Now.ToString("HH:mm:ss.fff") + "," +
            ThreadPool.ThreadCount + "," + 
            ThreadPool.CompletedWorkItemCount + "," +
            ThreadPool.PendingWorkItemCount + "\n";
        File.AppendAllText(logPath, line);
        Thread.Sleep(1000);
    }
});

測試部分,先前一氣喝成自動產生壓測報表小工具派上用場,我寫了批次檔一次測完 Thread.Sleep() 及 Task.Delay():(每次測試前及最後用 SysInternals PsExec 工具 重啟靶機站台的 AppPool))

psexec \\10.8.0.6 C:\Windows\System32\inetsrv\appcmd recycle apppool /apppool.name:"SUT"
k6 run -o json=result.json -e APIPATH=Registration/ThreadSleep spike.js
node k6-stress-test-chart.js -t "Thread.Sleep / 16 Min Worker Theads" -f sleep-16.html
psexec \\10.8.0.6 C:\Windows\System32\inetsrv\appcmd recycle apppool /apppool.name:"SUT"
k6 run -o json=result.json -e APIPATH=Registration/TaskDelay spike.js
node k6-stress-test-chart.js -t "Task.Delay / 16 Min Worker Theads" -f delay-16.html
psexec \\10.8.0.6 C:\Windows\System32\inetsrv\appcmd recycle apppool /apppool.name:"SUT"

萬事備齊,油門催下去。實測結果如下,上方為 Thread.Sleep 版,下方為 await Task.Delay:

由圖表可知最少工作 Thread 數 16 顯然在 Thread.Slepp 版無法應付 20 秒內升壓到 600 RPS 的尖峰流量,20 秒後沒多久便出現 503,錯誤發生率最高點出現在 22 秒,達到 174 RPS。而累積在 Queue 的請求到 50 秒才消化完,平圴等待時間最高到 24s。而 Throughput 的平均值最早為 100 RPS,呈鋸齒狀上升,最高接近 300 RPS。

Task.Delay 版表現好很多,沒有出現任何 503,只花了 35 秒就消化全部的請求,Throughput 順利升到 300 RPS,之後出現震盪,平均執行時間則最高不到 10s。CPU% 在 12 秒左右達到 100%,推測 300 RPS 已這個 API 寫法在 16 核的極限 Throughput。

再來觀察 ThreadPool 的 Thread 數、完成數及等待數,下圖左邊是 Thread.Slepp 版、右邊是 Task.Delay 版。Thread.Sleep 版的 Thread 數由 16 開始一路增加到 51 條 Thread (Thread 數增加也反映在呈鋸齒狀上升的 Succ RPS 綠線,Thread 數足夠才達最高 300 RPS)、等待工作數最多近 5000 件;而 Task.Delay,只增加到 30 條 Thread 就夠用,等待工作數最多為 2500 多一點。

由實驗結果可證明,await Task.Delay() 比 Thread.Sleep() 更能善用 Thread,在相同資源下能處理更多請求。

另外再做了一個實驗,如果一開始就把 ThreadPool Thread 數開足,是否能改善 Thread.Sleep 版的表現。這次我沒用 ThreadPool.SetMinThreads() 改設定,而是用不必改程式的做法,修改 <aspnetcore-assembly-name>.runtimeconfig.json System.Threading.ThreadPool.MinThreads將最少工作 Thread 數加到 64:

最少工作 Thread 數提高到 64 後,Throughput 曲線(Succ RPS 綠線)平穩上升到 300 RPS 且幾乎沒什麼震盪,比 Task.Delay 的線形更漂亮,CPU 使用率在 12 秒左右達 100%,平均執行時間最高 7.5s,也比 Task.Delay 版更低。

同樣是最少工作 Thread 數設 64,Thread.Sleep 版明顯比 Task.Delay 版好,Throughput 曲線平穩,平均執行時間少了 2.5s,這是為什麼?

從 ThreadPool 統計可推敲一二:(左邊是 Thread.Sleep,左邊是 Task.Delay)

兩邊在 30 秒內處理掉的 Request 數差不多,都是 9000 多一點,但 Task.Delay 版 ThreadPool 完成的工作數高達 18395,是 Thread.Sleep 的兩倍,推測是因為 await Task.Delay() 將工作拆成兩段,前後段都要丟給 ThreadPool 執行。在 CPU 滿載的狀況下,ThreadPool 處理更多工作的及 Thread 切換更顯吃力,而 async/await 背後有一套狀態機邏輯,執行的程式碼比 Thread.Sleep 版複雜,這些額外成本對效能造成負面影響。為驗證前述假設,我小改程式將 await Task.Delay(100) 拆成兩個 await Task.Delay(50) 再測一次:

[HttpPost]
[Route(nameof(TaskDelay))]
public async Task<IActionResult> TaskDelay(RegisterRequest req)
{
    await Task.Delay(50);
    await Task.Delay(50);
    return Ok(new RegisterResponse
    {
        LotteryUid = req.LotteryUid,
        RespSign = "OK"
    });
}

測試結果如下,一樣處理九千多筆請求,平均等待時間惡化到 13s、ThreadPool 處理的工作數一也如預期是九千的三倍(因為有兩次 await),達到 27200,證實了推測。

【結論】

  1. 提高 ThreadPool 最少工作 Thread 數,有助改善網站突然湧入大流量來不及增加 Thread 的狀況
  2. async/await 可有效消除 Thread 空等 I/O 或其他資源的閒置狀況,用較少 Thread 達到相同的 Throughput,將 CPU、記憶體等資源發揮最大效益
  3. await 並非全無成本,每次 await 後的執行需依賴 ThreadPool 處理(註:桌面程式與 ASP.NET 不同,會由 UI Thread 執行,依 SynchronizationContext 決定。參考),在 CPU 滿載時,async/await 背後的狀態機邏輯、ThreadPool 管理及 Thread 切換等額外負擔會讓效能變差

聽起來好像有點為難,CPU 滿載後,多用 await 反而雪上加霜?

我是這樣想,async/await 的思路是消除閒置,充分利用資源;當 CPU 忙到喘不過氣,await 自然無用武之地。而實務上當系統出現 CPU 滿載,網站會開始噴 503 或各式錯誤(網友分享過噴 404 的案例)、線上等待時間不斷拉長,服務已進入失控狀態。此時該做的事是設法調架構、改程式、加 CPU 或加機器,避免 CPU 滿載,應該會比糾結於如何優化 CPU 100% 下的表現更實際。

A K6 spike test on ASP.NET Core web to observe the effect of async/await and how ThreadPool.SetMinThreads() improve the performance?


Comments

# by 雷夢

好文讚讚 想敲碗一下把所有 "async Task" 改為 "async ValueTask" 的版本測試數據 據官方說法應該能減少一些生成 Task 狀態機的成本

# by Rico

https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/ 幫樓上補說明,也強調一下ValueTask並非萬解,千萬要注意它的限制

Post a comment