非同步方法的重點,不是「把工作變魔法般更快」,而是把等待的時間讓出來,避免呼叫端整段卡住。
在 .NET 裡,這套模型已經從早期 callback 風格,逐步演化成以 Task、async、await 為核心的 task-based asynchronous pattern(TAP)。這篇把它整理成一條比較容易理解的脈絡。
同步等待與非同步等待
同步程式最直觀,但一碰到 I/O 就很容易浪費執行緒:
- 呼叫網路、磁碟、資料庫時,真正耗時的常常是等待外部資源
- 如果直接
Wait()或堵住 UI 執行緒,應用程式就會失去回應 - 如果每個等待都占住一條執行緒,伺服器吞吐也會很差
非同步等待的目標很單純:
- 工作還沒完成時,先把控制權還給呼叫端
- 等工作完成後,再從原本的邏輯位置繼續往下跑
下面這張圖,適合先把整體心智模型建立起來。

TAP:以 Task 表示未完成的工作
TAP 的核心觀念是:非同步方法不直接把最終結果交給你,而是先回傳一個代表「未來結果」的物件。
這個物件通常是:
TaskTask<T>
可以把它理解成一張收據,告訴呼叫端:
- 工作已經開始或即將開始
- 之後可以等待它完成
- 它可能成功、失敗,或被取消
因此一個典型非同步流程會長這樣:
- 先做一部分同步工作
- 啟動非同步操作
- 回傳
Task或Task<T> - 等待操作完成後,再恢復後半段邏輯
async / await 讓控制流程回到可讀狀態
沒有 async / await 的年代,非同步程式往往要靠 callback 或 ContinueWith 串起來。功能做得到,但流程很碎,錯誤處理也難看。
有了 await 後,程式可以維持接近同步寫法的可讀性:
static async Task<string> TestReqAsync(string msg)
{
await using var pipe = new MemoryStream();
byte[] req = Encoding.UTF8.GetBytes(msg);
await pipe.WriteAsync(req, 0, req.Length);
byte[] rsp = new byte[1000];
int bytesRead = await pipe.ReadAsync(rsp, 0, rsp.Length);
return Encoding.UTF8.GetString(rsp, 0, bytesRead);
}
這段程式最重要的事,不是語法本身,而是它的行為:
- 方法遇到第一個尚未完成的
await時,會先把控制權交出去 - 相關工作完成後,再從
await之後那一行繼續執行 - 呼叫端拿到的是
Task<string>,而不是立刻拿到字串
下面這張圖可以把「方法中斷、稍後恢復」的概念看得更具體。

await 到底做了什麼
await 本質上不是阻塞,而是對一個可等待物件做三件事:
- 先確認它是否已完成
- 若尚未完成,註冊後續 continuation
- 完成後再取出結果或重新拋出錯誤
這也是為什麼 await 最常搭配 Task<T>:
Task<string> task = client.GetStringAsync(url);
string pageText = await task;
如果工作已完成,await 幾乎就像直接取值;如果尚未完成,方法就會先返回,等結果好了再回來。
為什麼非同步方法通常回傳 Task
非同步方法常見的回傳型別有三種:
TaskTask<T>void
一般規則很明確:
- 有結果就回傳
Task<T> - 沒結果就回傳
Task - 只有事件處理器才應該使用
async void
原因是 Task 才能讓呼叫端:
- 等待完成
- 接住例外
- 串接後續流程
- 與取消機制整合
async void 一旦用在一般業務邏輯上,錯誤傳播與生命週期都會很難控。
非同步方法如何「返回」
表面上看起來,一個 async Task<int> 方法裡面 return 的是 int,但真正回到呼叫端的,其實是由編譯器建立好的 Task<int>。
public static async Task<int> GetPageLengthAsync(string url)
{
using HttpClient client = new HttpClient();
string text = await client.GetStringAsync(url);
return text.Length;
}
這裡有兩層返回:
- 對方法本體來說,
return text.Length返回的是業務結果 - 對呼叫端來說,方法立即返回的是包住最終結果的
Task<int>
這種包裝,正是 async 能把「現在先回去,之後再完成」這件事表達清楚的關鍵。
下面這張圖描述的是呼叫端如何在等待期間把控制權收回、之後再被 continuation 喚回。

例外與取消在非同步方法中的表現
例外
非同步方法裡拋出的例外,通常不會在呼叫那一刻同步炸出來,而是先被記錄到返回的 Task 上。
static async Task<int> ComputeLengthAsync(string text)
{
if (text is null)
{
throw new ArgumentNullException(nameof(text));
}
await Task.Delay(500);
return text.Length;
}
呼叫端如果用 await:
int length = await ComputeLengthAsync("hello");
那麼錯誤會在 await 那一刻重新拋出。這也是為什麼現代非同步程式錯誤處理通常直接寫 try / catch 包 await,而不是優先使用 AggregateException 心智模型。
取消
取消則沿用前一篇提到的協作式取消模式:
static async Task<int> CountAsync(int countTo, CancellationToken token)
{
for (int i = 0; i < countTo; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100, token);
}
return countTo;
}
如果 OperationCanceledException 與對應 token 正確關聯,返回的 task 會進入 Canceled 狀態,而不是 Faulted。
編譯器其實幫你生成了狀態機
async / await 可讀性很好,但不是免費語法糖。編譯器會把整個方法改寫成一個狀態機(state machine)。
高層理解就夠了:
- 原本的方法體會被拆成多個「狀態」
- 每遇到一個
await,就多了一個可能暫停與恢復的位置 - 相關區域變數、參數與 awaiter 會被保存到狀態機物件裡
- 狀態機最後透過 builder 控制
Task的完成、失敗與取消
這張圖展示了編譯器改寫後的方法外殼。

而狀態機本身會攜帶一些必要欄位,例如目前狀態、builder、暫存的 awaiter、被 lift 上去的區域變數等。

MoveNext() 才是非同步方法真正運作的地方
編譯器生成的狀態機通常會實作 IAsyncStateMachine,核心方法就是 MoveNext()。
可以粗略把它理解成:
- 第一次呼叫時,執行方法前半段
- 如果遇到未完成的 await,就記錄狀態並退出
- 等待的工作完成後,再次呼叫
MoveNext() - 依據先前狀態跳回對應位置繼續跑
- 若成功則
SetResult(...) - 若失敗則
SetException(...)
下面兩張圖是整個狀態轉移與 MoveNext() 思路的視覺化版本。


理解到這一層之後,很多現象就不再神祕,例如:
- 為什麼方法中的區域變數在
await後還「活著」 - 為什麼
await前後不保證在同一條執行緒 - 為什麼某些 ref-like 限制會出現
幾個常見限制與實務注意事項
不要把 Task 當成「一定開新執行緒」
非同步方法常常只是在等待 I/O。它完全可能沒有為你額外建立新執行緒,只是把等待期間的控制權讓出去。
await 前後不保證同一條執行緒
這件事在 UI、ASP.NET、library code 都很重要。你如果在 await 前拿了某個與 thread affinity 強綁定的資源,後面就不能想當然耳地假設還在原執行緒上。
async void 只留給事件處理器
一般方法若回傳 void,呼叫端就失去等待與接錯能力。
舊限制與新版本差異要分清楚
一些早期資料會提到:
Main不能是asyncawait不能出現在catch或finally
這些都屬於舊版 C# 的限制。現代 C# 已支援 static async Task Main(...),也允許在 catch / finally 中使用 await。整理舊筆記時,這類版本差異需要特別標明,否則文章會過時。
仍然不適合搭配 lock 橫跨 await
你不能在 lock 區塊內做 await,因為鎖的取得與釋放假設是同步結構;而 await 會把方法拆開,這和 monitor-based lock 的模型不相容。
實務上該記住什麼
- 非同步的核心收益,是釋放等待時間,不是保證更高 CPU 速度
Task表示工作,await表示非阻塞等待- 錯誤處理優先用
try/catch包await - 取消是狀態與協議,不是硬殺執行緒
- 理解 state machine 後,很多語法限制與執行行為都會變得合理
參考資料
- 《CLR via C#》第四版,Jeffrey Richter
- 《深入理解 C#》第四版,Jon Skeet