1. 介紹
在 C# 裡,非同步是個很有力的工具。但有時候會遇到需要從同步呼叫非同步程式碼(或反過來)的情況。理論上好像應該「魔法般」地運作,但實際上可能出現怪異的停頓(deadlock)、效能下降,甚至意外…把使用者介面弄壞。通常這類 Bug 只會在真實資料或真實環境下出現。原因往往是同步與非同步程式碼互動不當,或是對 .NET 任務排程器那些不明顯細節不了解。
而且現在很多函式庫和框架都大量使用非同步方法,你需要知道如何把非同步程式碼「正確地接」到現有的同步呼叫鏈,或反過來,如何從非同步方法正確地呼叫同步程式碼。
簡短複習:當你用 await 時發生了什麼
當你寫下:
await SomeAsyncMethod();
程式碼會被「拆成」兩段:await 之前和之後。第一段會在遇到第一個等待的非同步動作前執行(例如發出一個網路請求),而續作會在該動作完成後執行。問題是:續作會在哪裡執行?在同一個執行緒上?還是另一個?如果我們寫的是桌面應用(例如 WPF 或 WinForms),情況會如何?如果是主控台應用呢?答案──視情況而定。這時候就會牽涉到像 ConfigureAwait 這類東西。
什麼是 SynchronizationContext?
SynchronizationContext 是 .NET 提供的一個機制,讓程式碼可以「記住」非同步操作的續作應該在哪裡、用什麼方式被呼叫。
- 在經典的 WinForms/WPF 應用中,SynchronizationContext 保證在 await 之後方法剩下的部分會在同一個 UI 執行緒上繼續執行,這樣就不會出現「從非 UI 執行緒操作控制項」的錯誤。
- 在舊版的 ASP.NET(非 Core)裡,SynchronizationContext 可以恢復 HttpContext,讓你可以在續作中繼續處理那個 HTTP 請求。
- 在主控台應用和 ASP.NET Core 應用中,通常 SynchronizationContext 不存在(等於 null),續作會在 thread pool 上執行。
什麼是 TaskScheduler?
TaskScheduler 是更底層的機制。大多數情況你會用到 TaskScheduler.Default,它使用 .NET 的 thread pool。它負責決定哪些任務何時在哪裡執行。
2. 在混用 await 與 Result/.Wait() 時的 Deadlock
這是 C# 裡最著名的陷阱之一:
// 在某處的 UI 程式碼
var result = SomeAsyncMethod().Result;
或者
SomeAsyncMethod().Wait();
結果:應用程式「掛住」了。為什麼?
這是怎麼發生的?
- 你呼叫了一個非同步方法,然後立刻要求 .Result 或 .Wait() ——也就是把當前執行緒「阻塞」,等著非同步任務結束。
- SomeAsyncMethod 在內部做了 await,並計畫把續作安排回同一個執行緒(透過 SynchronizationContext),但該執行緒已經被阻塞在等 Result/.Wait()。
- 因為執行緒一直在等著任務完成,它沒辦法去執行那個續作(continuation)。
- 結果就是 deadlock:執行緒在等自己完成。
這種情況在 UI 應用中特別容易重現,因為很多東西都在 UI 執行緒上跑,續作都想回到那個執行緒。在主控台應用和 ASP.NET Core(沒同步上下文)中,這種 deadlock 就比較少見。
生活小笑話:如果你成功觸發了一個帶 .Result 的 deadlock——恭喜,你離 Senior 又近了幾步 :D
3. 怎麼把非同步程式碼正確嵌入到同步程式碼裡?
建議一:從上到下的非同步設計
如果你有一個非同步方法,最好把 async/await 的模式向上推到呼叫堆疊頂端,直到 UI 或程式進入點為止。不要在呼叫鏈中間把非同步「一半丟掉」。
錯誤做法(會阻塞執行緒):
// 同步方法透過 .Result 呼叫非同步
public void DoStuff()
{
var data = GetDataAsync().Result;
// ...
}
正確做法(完整非同步):
public async Task DoStuffAsync()
{
var data = await GetDataAsync();
// ...
}
如果可以的話——盡量使用 await,不要用 .Result / .Wait()。
4. 但有時候:需要從 sync 呼叫 async
把整個上層呼叫樹改成 async(最佳選擇,如果你可以這麼做)。
使用特定模式:例如把任務丟到另一個執行緒去跑,透過 Task.Run,然後在那個執行緒內呼叫 async 方法。
public void DoStuff()
{
var result = Task.Run(() => SomeAsyncMethod()).Result;
}
但:在 UI 應用裡這種做法也有同步化的細節要注意,所以沒有必要的話最好別這樣用。
5. ConfigureAwait(false) 做了什麼?
有時候你並不需要在 await 之後把續作恢復到原來的執行緒(例如伺服器端程式或做為函式庫時,並不依賴於 SynchronizationContext)。反而不綁定到某個執行緒會更好——能提升效能!
語法與原理
await SomeAsyncMethod().ConfigureAwait(false);
- ConfigureAwait(false) 表示:「我不需要原本的 SynchronizationContext,續作在哪裡執行都可以。」
- ConfigureAwait(true)(預設)表示:「儘量在呼叫 await 的地方繼續執行,最好是在同一個 SynchronizationContext。」
示意圖
┌─────────────────────────────────────────────────────┐
│ SynchronizationContext │
└─────────────────────────────────────────────────────┘
↑ ↑
(UI 執行緒) await SomeAsyncMethod() Continuation (await 之後)
──────────────────────────────> (同一個執行緒 — 如果 ConfigureAwait(true))
↓
(任何執行緒 — 如果 ConfigureAwait(false))
在「函式庫」程式碼中使用 ConfigureAwait(false) 的範例
想像你在寫一個函式庫,會被各種應用使用:WinForms、WPF、ASP.NET、主控台……
你不該依賴使用者的執行緒模型。所以在你的函式庫的非同步方法中,建議對所有 await 使用 ConfigureAwait(false):
public async Task<string> LoadDataFromUrlAsync(string url)
{
using var client = new HttpClient();
string content = await client.GetStringAsync(url).ConfigureAwait(false);
return content;
}
這樣你的方法就不會「要求」在某個特定的 SynchronizationContext 上執行。比較安全也比較有效率(較少的執行緒切換)。
示例:沒有使用 ConfigureAwait 時 await 發生了什麼
看一個 WPF 應用:
private async void Button_Click(object sender, RoutedEventArgs e)
{
Button1.Content = "載入中...";
await Task.Delay(2000); // 模擬長時間操作
Button1.Content = "完成!";
}
Task.Delay 在內部做了一個 "await"。預設情況下,await 之後會把控制權回到 UI 執行緒,這樣你可以安全地繼續更新控制項。
如果你在長時間操作中使用了 ConfigureAwait(false):
await Task.Delay(2000).ConfigureAwait(false);
Button1.Content = "完成!"; // 錯誤!
會拋出例外: InvalidOperationException: "The calling thread cannot access this object because a different thread owns it."
原因是續作現在在別的執行緒上執行,不能存取 UI 元件。
結論:只在不需要存取 UI/上下文的地方使用 ConfigureAwait(false)。
6. 有用的小細節
在哪裡、何時使用 ConfigureAwait
| 情境 | 應該使用 .ConfigureAwait(false)? | 為什麼? |
|---|---|---|
| 函式庫程式碼 | 是 | 會在任何上下文被呼叫,通常不需要 UI |
| 在 ASP.NET Core 內的程式碼 | 是(上下文幾乎沒有) | 可以提升效能 |
| 在 WinForms/WPF 中,且要操作 UI | 否 | 需要把控制權還給 UI 執行緒 |
| 同步方法 | 沒意義 | 沒有同步化上下文 |
| 主控台應用 | 可以,但看不出差別 | 沒有上下文 |
怎麼知道是否有 SynchronizationContext?
可以在程式裡直接檢查:
Console.WriteLine(SynchronizationContext.Current == null
? "沒有上下文"
: "存在上下文");
- 在主控台和 ASP.NET Core 應用會顯示「沒有上下文」。
- 在 WinForms/WPF 則會顯示「存在上下文」。
執行緒切換的示意
sequenceDiagram
participant MainThread as 主要執行緒 (UI/Console)
participant ThreadPool as 來自執行緒池的執行緒
MainThread->>SomeAsyncMethod: 呼叫方法
SomeAsyncMethod->>MainThread: await 沒有 ConfigureAwait
Note right of MainThread: await 之後回到相同執行緒
SomeAsyncMethod->>ThreadPool: await 並 ConfigureAwait(false)
Note right of ThreadPool: await 之後可以在任意執行緒繼續
使用上的簡短規則
- 不要在有非同步方法的 UI 程式碼中使用 .Result 或 .Wait()。
- 在函式庫程式碼中,對所有 await 使用 ConfigureAwait(false)。
- 在 UI 應用中只有在不會操作介面的函式裡使用 ConfigureAwait(false)。
- 盡量不要在非必要情況下混用同步與非同步程式碼。
- 如果要從同步呼叫 async——再三思考:是否能把整個呼叫鏈改為 async?
7. 新手在處理非同步時常犯的錯誤
錯誤一:到處用 .Result 或 .Wait()。
很多開發者(尤其看了幾篇文章之後)會到處加上 .Result 或 .Wait(),想把非同步呼叫「同步化」。表面上看起來方便,但實際上這是在 UI 應用中通往 deadlock 的捷徑。特別是當非同步方法內沒有使用 ConfigureAwait(false) 時,UI 執行緒會被阻塞,無法執行續作。
錯誤二:誤用或濫用 ConfigureAwait(false)。
一些新手會在所有地方都加上 ConfigureAwait(false),甚至在會操作 UI 的程式碼中也這樣做。在 UI 應用中這會導致操作控制項時拋出 InvalidOperationException,因為續作跑在非 UI 執行緒上。
錯誤三:忘了 ConfigureAwait 只對 awaitable 物件有用。
很多人以為可以對任何方法用 ConfigureAwait(false),但它只對回傳 Task、Task<T>、ValueTask 或其他 awaitable 類型有效。對同步方法用 ConfigureAwait 沒有意義,等待同步方法不會變成非同步。
錯誤四:不必要地混用同步與非同步程式碼。
嘗試在同步程式碼中呼叫非同步方法而沒有良好的策略(像是用 .Result、.Wait() 或 Task.Run)常會引發 subtle 的 bug、效能下降以及難以診斷的 deadlock。即使某方法看起來短小且安全,這類呼叫仍可能破壞整個應用的運作。
錯誤五:低估 SynchronizationContext 的影響。
新手常忘記續作在沒使用 ConfigureAwait(false) 時可能會回到同一個執行緒(UI),這會在混合 UI 與函式庫程式碼時導致不可預期的行為,特別是當等待與續作互相交錯時。
GO TO FULL VERSION