筆記一些個人的開發經驗及對 Async/Await 的理解。
"非同步"跟"平行處理"是二間不同的事情。使用 async 並不代表單一任務的執行速度會變快。
非同步:核心在於「不要阻塞執行緒」。主要目標是想提升系統的吞吐量(Throughput),讓執行緒在等待 I/O 回應時可以先把執行緒釋放出來去處理其他的請求。
平行處理:核心在於「平行運算」。讓多顆核心 CPU 可以同時執行多個任務,藉此來縮短處理的時間(因為是同時執行多個),但會消耗較多的 CPU 資源。
個人經驗:
要先區分到底目前程式的慢,瓶頸是卡在 "I/O Bound"(存取資料、網路請求) or "CPU Bound"(數據運算、演算法)。如果是 "I/O Bound",非同步理論上可以有幫助來多吃一些量;但如果是"CPU Bound"的話,需要的是平行處理,而不是非同步。
"I/O Bound"會卡住大多都是在等待 I/O 的回覆,這時候的等待,其實 thread 是根本沒在做事的。既然沒在做事,就趕快讓他去處理其他的請求,藉此來多吃一些量。等到 I/O 回覆說它那邊做好了, thread 再接著回來處理後續的工作。所以瓶頸點如果是"I/O Bound",但卻選擇用平行處理的方式來解決,那它的現象就是多了一些 thread 卡在那裡繼續等 I/O 的回覆,然後在等的同時也不去處理一些其他的請求。
"CPU Bound"卡住時,就可以考慮平行處理的方式了。雖然 thread 也是卡住的現象,但它是"真的"有在做事的卡住。這時候的設計就可以看要處理的工作內容 or 資料內容是否可以拆開來平行處理。假設可以,就能夠按照 server 上的 CPU 核心數,來分配合適的同時平行數量。為什麼說是合適的平行數量?因為太多基本上是沒用的,只是會產生很多的 context switch 而已。因為大家都想要搶 CPU 的資源來進行運算。
當我們使用 async/await 關鍵字時,其實是一種語法糖的表示方式。編譯器在編譯時會自動實作成 State Machine Pattern。這套 pattern 會將 async/await 這些關鍵字前後的程式分段拆開,並且用變數來記錄目前的執行狀態及位置。然後等待 I/O Completion Port 的事件被觸發時,CompletionPortThread(正常的話) 即會來接手後續的工作。
個人經驗:
如果是大量請求的程式,或是背景程式(service 類的)一起來的時候,就有大量的工作要很多條執行緒,可以在程式的最一開始起來的時候,就將 ThreadPool 的 WorkerThread & CompletionPortThread 來進行調整(通常是 WorkerThread 會調得較大)。不然等量進來,再讓 .net 的 ThreadPool 自己決定要不要開時,可能會太慢。照網路查到的資料跟以往經驗觀察到的,如果 thread 不夠時,一秒鐘應該只會新開1、2條新的 thread。這時候就很容易中大家常說的 thread starvation。
有些 endpoint 被打的頻率極高(hot path),雖然有搭配快取的實作,但想要再吃更多的量。這時候可考慮用 ValueTask 來快速回傳值。優點是少了 heap allocate & GC 的頻率。因為可以省掉 new Task 的這個動作。
個人經驗:
ValueTask 在某件條件成立的情境下,有機會再讓這種被打的很兇的 endpoint 再提過吃的量。某些條件就像是這個 endpoint 已經有實作快取的機制了,但如果要使用 ValueTask 來快速回傳值,前提是快取的命中率也要夠高。假設快取的命中率很低,每次都沒中,還是要再非同步去抓一次資料,那 ValueTask 其實效用的感受不明顯。
Root-cause:通常都是專案裡有些還不是 .net core,但同步的執行緒要呼叫"非同步"的方法,然後用 .Result 來取"非同步"的值。導致主 thread 一定是 hold 著 synchronization context,而 await 後面的程式要執行時就一直拿不到。在較新版的 .net core 已經不再使用 synchronization context 了,而是改把相關的資料移到 execution context,因此才沒有這種問題。
個人經驗:
總會維護到一些較舊的專案,這時候裡面的程式從一開始就不會有 async 這種東西出現,但卻又一定要去使用一些"非同步"的方法。這時候只能去分析這段程式到底是不是真的需要 synchronization context 裡的資料。假如不需要,直接用 ConfigureAwait(false) 來處理掉。假如又真的要,只能多開一個 Task 來等待執行的結果。
結論是怎麼寫都不太可能會漂亮,除非狠下心來,就是從頭開始一路 async 到底。但這在現實生活中,是幾乎不太可行的 solution。
在 task 開始處理後,設定多久時間內沒有回覆,就當是 timeout 處理。
var task = DoWorkAsync(ct);
var delayTask = Task.Delay(5000);
var r = await Task.WhenAny(task, delayTask);
if (r == delayTask)
throw new TimeoutException("time out");
var task = DoWorkAsync(ct);
var ts = TimeSpan.FromMilliseconds(500);
try
{
var r = await task.WaitAsync(ts);
}
catch(TimeoutException ex)
{
//handle timeout
}
參考資料
現在 C#:AI 時代的開發者修煉
Concurrency in C# Cookbook