非同步方法的重點,不是「把工作變魔法般更快」,而是把等待的時間讓出來,避免呼叫端整段卡住。

在 .NET 裡,這套模型已經從早期 callback 風格,逐步演化成以 Taskasyncawait 為核心的 task-based asynchronous pattern(TAP)。這篇把它整理成一條比較容易理解的脈絡。

同步等待與非同步等待

同步程式最直觀,但一碰到 I/O 就很容易浪費執行緒:

  • 呼叫網路、磁碟、資料庫時,真正耗時的常常是等待外部資源
  • 如果直接 Wait() 或堵住 UI 執行緒,應用程式就會失去回應
  • 如果每個等待都占住一條執行緒,伺服器吞吐也會很差

非同步等待的目標很單純:

  • 工作還沒完成時,先把控制權還給呼叫端
  • 等工作完成後,再從原本的邏輯位置繼續往下跑

下面這張圖,適合先把整體心智模型建立起來。

TAP:以 Task 表示未完成的工作

TAP 的核心觀念是:非同步方法不直接把最終結果交給你,而是先回傳一個代表「未來結果」的物件。

這個物件通常是:

  • Task
  • Task<T>

可以把它理解成一張收據,告訴呼叫端:

  • 工作已經開始或即將開始
  • 之後可以等待它完成
  • 它可能成功、失敗,或被取消

因此一個典型非同步流程會長這樣:

  1. 先做一部分同步工作
  2. 啟動非同步操作
  3. 回傳 TaskTask<T>
  4. 等待操作完成後,再恢復後半段邏輯

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 本質上不是阻塞,而是對一個可等待物件做三件事:

  1. 先確認它是否已完成
  2. 若尚未完成,註冊後續 continuation
  3. 完成後再取出結果或重新拋出錯誤

這也是為什麼 await 最常搭配 Task<T>

Task<string> task = client.GetStringAsync(url);
string pageText = await task;

如果工作已完成,await 幾乎就像直接取值;如果尚未完成,方法就會先返回,等結果好了再回來。

為什麼非同步方法通常回傳 Task

非同步方法常見的回傳型別有三種:

  • Task
  • Task<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 / catchawait,而不是優先使用 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 不能是 async
  • await 不能出現在 catchfinally

這些都屬於舊版 C# 的限制。現代 C# 已支援 static async Task Main(...),也允許在 catch / finally 中使用 await。整理舊筆記時,這類版本差異需要特別標明,否則文章會過時。

仍然不適合搭配 lock 橫跨 await

你不能在 lock 區塊內做 await,因為鎖的取得與釋放假設是同步結構;而 await 會把方法拆開,這和 monitor-based lock 的模型不相容。

實務上該記住什麼

  • 非同步的核心收益,是釋放等待時間,不是保證更高 CPU 速度
  • Task 表示工作,await 表示非阻塞等待
  • 錯誤處理優先用 try / catchawait
  • 取消是狀態與協議,不是硬殺執行緒
  • 理解 state machine 後,很多語法限制與執行行為都會變得合理

參考資料

  • 《CLR via C#》第四版,Jeffrey Richter
  • 《深入理解 C#》第四版,Jon Skeet