Asynchronous Programming - 第三章:非同步中的等待藝術
小遙家裡有一台很貴的全自動咖啡機。只要放入咖啡豆,按下按鈕,五分鐘後,一杯完美的拿鐵便會端坐在托盤上,溫度與比例都恰到好處。
但她還是習慣站在一旁等。
她不是完全相信機器;在咖啡流出前的幾十秒,她總會打開上蓋,觀察豆槽有沒有卡住,再輕拍一下;聽見磨豆的聲音時,也會試著微調參數、瞄一下壓力表。她甚至曾試著在機器運作時,手動幫忙攪拌牛奶。
這一切看起來像是「更專業的沖煮」,但其實只是「更不安的等待」。直到有一天,她打翻了一杯還沒加糖的咖啡,才驚覺:其實什麼都不做,才是真的在等那杯咖啡完成。
🎵 Task.Run() 背後到底做了什麼?
Task.Run(…) 的本質是這樣
1 | public static Task Run(Action action) |
它會從 Thread Pool 取出一條背景執行緒,執行你提供的委派(Action),並且 TaskScheduler.Default 會指向 ThreadPoolTaskScheduler。所以 Task.Run() 背後的邏輯,就是把你的工作丟到 .NET 的 Thread Pool。
這條背景 Thread 執行完,就會把結果封裝進 Task 裡面回傳。
你寫的程式仍然可以 await 它,因為它回傳的是一個 Task,但這不是 真正的 async I/O,而是把同步的工作「搬去背景執行緒執行」,讓主執行緒不要被卡住,但那條背景執行緒還是會被同步程式碼整個佔著不放。!
🎵 等待的方式,決定了資源的命運
假設現在有一個 CPU-bound 的工作,例如壓縮檔案:
1 | public async Task CompressFileAsync(string path) |
這段程式的關鍵在於:誰在等?在哪裡等?怎麼等?
當你使用 Task.Run(),這段同步的壓縮邏輯會被丟到 .NET 的 Thread Pool 中,由另一條背景執行緒來完成。呼叫端(例如 UI 執行緒或 ASP.NET 的 Request Thread)一旦執行到 await,便會「暫停後續的邏輯」,釋放當前的執行緒,讓它去忙別的事,直到 PerformCompression(path) 執行結束,才繼續執行剩下的程式碼。
這樣的設計允許主流程保持流暢,並避免 CPU-bound 的重工作卡住使用者介面或阻礙伺服器的併發處理能力。換句話說,我們不是「不等待」,而是「交給更適合等待的人去等」。
但如果今天是 I/O-bound 的任務,例如:
- HttpClient.GetAsync(…)
- 資料庫查詢
- 檔案存取
這些操作本質上是透過作業系統的事件通知機制(如 IO Completion Ports)來完成的。它們不需要任何 Thread 去等待,只要事件一完成,系統自然會喚醒原先的邏輯繼續執行。
此時,若在本身有非同步 API 的前提下,硬用 Task.Run() 包裝這種 async 方法:
1 | await Task.Run(() => _httpClient.GetAsync(url)); |
你其實是「為了一個本來不需人顧的工作,多派一個 Thread 去旁邊乾等」。不但沒比較快,還白白浪費了 Thread Pool 的資源,甚至可能造成伺服器在高併發下因為 Thread Pool 耗盡而雪崩。
Task.Run() 的本質
- 它會從 Thread Pool 抽出一條執行緒,執行你提供的同步委派(Action)
- 它適合用來封裝 CPU-bound 的同步邏輯
- 它不適合用來包裝原本就非同步的 I/O-bound 方法
🎵 學會等待,也是一種技術
小遙終於不再站在咖啡機旁指手畫腳,她學會把杯子放好,按下按鈕,然後去做其他事。她知道那杯拿鐵會在適當的時機完成,不早、不晚,不需要她盯著、催促、干預。
程式也是如此。
當我們理解 async/await 的真正精神,我們會知道 —— 等待,不一定要佔據,也不該總是干涉。
有些任務該交給 Thread 去辛苦完成,有些任務則該交給系統與事件驅動的機制處理。我們所要做的,是選擇適合的等待方式,讓整體的節奏流動得更順、更節省資源,也更優雅。
在非同步的世界裡,「不做什麼」有時候才是最好的選擇。