CodeGym /課程 /C# SELF /非同步與同步程式碼的互動

非同步與同步程式碼的互動

C# SELF
等級 62 , 課堂 4
開放

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. 在混用 awaitResult/.Wait() 時的 Deadlock

這是 C# 裡最著名的陷阱之一:

// 在某處的 UI 程式碼
var result = SomeAsyncMethod().Result;

或者

SomeAsyncMethod().Wait();

結果:應用程式「掛住」了。為什麼?

這是怎麼發生的?

  1. 你呼叫了一個非同步方法,然後立刻要求 .Result.Wait() ——也就是把當前執行緒「阻塞」,等著非同步任務結束。
  2. SomeAsyncMethod 在內部做了 await,並計畫把續作安排回同一個執行緒(透過 SynchronizationContext),但該執行緒已經被阻塞在等 Result/.Wait()
  3. 因為執行緒一直在等著任務完成,它沒辦法去執行那個續作(continuation)。
  4. 結果就是 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 上執行。比較安全也比較有效率(較少的執行緒切換)。

示例:沒有使用 ConfigureAwaitawait 發生了什麼

看一個 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),但它只對回傳 TaskTask<T>ValueTask 或其他 awaitable 類型有效。對同步方法用 ConfigureAwait 沒有意義,等待同步方法不會變成非同步。

錯誤四:不必要地混用同步與非同步程式碼。
嘗試在同步程式碼中呼叫非同步方法而沒有良好的策略(像是用 .Result.Wait()Task.Run)常會引發 subtle 的 bug、效能下降以及難以診斷的 deadlock。即使某方法看起來短小且安全,這類呼叫仍可能破壞整個應用的運作。

錯誤五:低估 SynchronizationContext 的影響。
新手常忘記續作在沒使用 ConfigureAwait(false) 時可能會回到同一個執行緒(UI),這會在混合 UI 與函式庫程式碼時導致不可預期的行為,特別是當等待與續作互相交錯時。

1
問卷/小測驗
非同步資料流,等級 62,課堂 4
未開放
非同步資料流
深入了解非同步
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION