以一頁整理 WPF / WinForms 的 async 與 UI 執行緒

· 更新日期: · · C#, async/await, .NET, WPF, WinForms, UI, 執行緒

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279341)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616260)
初次發布
引用本文(DOI: 10.5281/zenodo.21616259)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈以一頁整理 WPF / WinForms 的 async 與 UI 執行緒〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616259 https://comcomponent.com/zh-TW/blog/2026/03/12/000-wpf-winforms-ui-thread-async-await-one-sheet/

DOI(最新版本)
10.5281/zenodo.21616259
DOI(此版本)
10.5281/zenodo.22297087

在 WPF / WinForms 使用 async / await 時,最容易迷惘的是 await 之後會回到哪一條執行緒,以及 什麼時候才可以碰 UI。 尤其當 Dispatcher、BeginInvoke、ConfigureAwait(false)、.Result / .Wait() 混在一起時,畫面凍結與跨執行緒例外的原因就變得很難看清楚。

本文只談 WPF / WinForms 的 UI 執行緒與 async / await 之間的關係。 async / await 的整體判斷準則,與 C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait 是相連的。

實務上真正會見血的,大致就是這一帶。

  • await 之後,不知道接續會在哪裡執行
  • 夾了一層 Task.Run 之後,不知道可不可以碰 UI
  • 不知道 ConfigureAwait(false) 該加在哪裡
  • .Result / .Wait() / .GetAwaiter().GetResult() 害畫面凍結
  • WPF 的 Dispatcher 和 WinForms 的 Invoke / BeginInvoke / InvokeAsync 在腦中混成一團

WPF / WinForms 兩者都是 以 UI 執行緒為中心的模型。 所以要梳理 async / await,最派得上用場的不是「非同步是什麼」這種帶點哲學味的討論,而是把 它對 UI 執行緒和訊息迴圈做了什麼 講清楚。

本文以 .NET 6 以後的 WPF / WinForms 應用程式 為主要前提, 依實務上好用的順序,依序追查 await 之後的回歸處、Dispatcher、ConfigureAwait(false)、.Result / .Wait() 卡住的原因。

另外,WinForms 的 Control.InvokeAsync 是 .NET 9 以後 才有的。 更早的 WinForms 基本上要用 BeginInvoke / Invoke。

還有,本文出現的程式碼已經整理成可以建置、可以執行的一整套範例(不依賴 UI 的函式庫、WPF / WinForms 範例,以及重現 await 回歸處與死結的單元測試),公開在 GitHub 上。

wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)

目錄

  1. 先講結論(一句話)
  2. 先用一頁整理
    • 2.1. 全貌
    • 2.2. 先看的判斷表
  3. 本文使用的詞彙
    • 3.1. UI 執行緒與訊息迴圈
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. 典型模式
    • 4.1. 在 UI 事件處理常式中使用 plain await
    • 4.2. 只把吃重的 CPU 計算交給 Task.Run
    • 4.3. ConfigureAwait(false) 不是「保證不回來」,而是「不強制回來」
    • 4.4. .Result / .Wait() / .GetAwaiter().GetResult() 會卡住的原因
  5. 什麼時候該用 Dispatcher / Invoke
  6. 常見的反模式
  7. 審查時的檢查清單
  8. 大致的取捨
  9. 總結
  10. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 26 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

1. 先講結論(一句話)

  • 在 WPF / WinForms 的 UI 事件處理常式 中使用 plain await 時,await 之後的接續可以當成 基本上會回到 UI 執行緒
  • Task.Run 是 把 CPU 計算移出 UI 執行緒的工具,不是拿來包 I/O 等待的
  • 就算在 UI 處理常式裡 await Task.Run(...),只要那個 await 是 plain await,接續通常也 回到 UI 執行緒
  • ConfigureAwait(false) 的意思是 不強制那個 await 的接續回到所捕捉的 UI 執行內容。加了之後,在接續裡直接碰 UI 很危險
  • .Result / .Wait() / .GetAwaiter().GetResult() 會 把 UI 執行緒佔住。只要 await 的接續需要回到 UI,就相當容易卡住
  • WPF 要明確送回 UI,用 Dispatcher.InvokeAsync
  • WinForms 要明確送回 UI,舊式是 BeginInvoke,.NET 9 以後的 InvokeAsync 和 async 流程比較合得來
  • 先採用的方針是:UI 的最外側用 plain await,通用函式庫考慮 ConfigureAwait(false),只在必要的地方明確把處理送回 UI

換句話說,在 WPF / WinForms 中

  1. 現在跑在哪一條執行緒上
  2. await 的接續會回到哪裡
  3. 由誰負責把處理送回 UI

先掌握這三件事,全貌就會清楚很多。

讓全貌更清楚的三個問題先掌握現在跑在哪一條執行緒、await的接續會回到哪裡、由誰負責把處理送回UI這三件事,WPF與WinForms的非同步程式碼就更容易看清全貌的圖。現在跑在哪一條執行緒非同步UI程式碼的全貌await的接續會回到哪裡由誰負責把處理送回UI

圖 1: 迷路時就回來問的三個問題。把執行緒、回歸處、送回的責任分開來想。

2. 先用一頁整理

2.1. 全貌

先用這張圖抓住全貌會比較快。

UI事件處理常式(WPF / WinForms)plain awaitI/O API捕捉 UI SynchronizationContextawait後在 UI 執行緒恢復UI更新可以直接寫await Task.Run(...)吃重的CPU處理計算本體在 ThreadPoolawait後在 UI 執行緒恢復await SomeAsync().ConfigureAwait(false)不強制回到 UI接續在任意執行緒直接更新 UI 很危險需要 Dispatcher / InvokeSomeAsync().Result / Wait()GetAwaiter().GetResult()阻塞UI執行緒接續回不到 UI停止回應 / 死結 / 至少也會凍結

圖 2: 從 UI 處理常式看到的四種模式全貌。plain await 與 Task.Run 會回到 UI,ConfigureAwait(false) 不強制回歸,.Result / .Wait() 則把 UI 執行緒佔住。

實務上會看到的,大致就是這 4 種模式。

  1. 在 UI 事件處理常式中使用 plain await
  2. 在 UI 事件處理常式中用 Task.Run 把 CPU 移出去
  3. 用 ConfigureAwait(false) 把回歸處拿掉
  4. 用 .Result / .Wait() 把 UI 執行緒佔住

2.2. 先看的判斷表

狀況 等待期間哪裡在動 await 之後的接續 可以直接碰 UI 嗎 先這樣選
在 UI 處理常式中 await SomeIoAsync() 等待 I/O 完成。UI 執行緒本身可以回到訊息迴圈 基本上是 UI 執行緒 可以 plain await
在 UI 處理常式中 await Task.Run(...) 吃重的 CPU 在 ThreadPool 基本上是 UI 執行緒 可以 只把 CPU 交給 Task.Run
在 UI 處理常式中 await x.ConfigureAwait(false) 不把回歸處固定在 UI 任意執行緒 不可以 UI 程式碼中基本上避開
在 UI 執行緒上 x.Result / x.Wait() UI 執行緒因等待而被佔住 接續根本就轉不動 不可以 不要用
想在背景執行緒或 ConfigureAwait(false) 之後更新 UI 跑在與 UI 不同的執行緒上 就這樣下去並不在 UI 上 不可以 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
撰寫不依賴 UI 的通用函式庫 不受呼叫端的狀況左右 不強制回到 UI 設計成完全不碰 考慮 ConfigureAwait(false)
想從建構函式或同步屬性呼叫 async UI 執行緒很容易進入等待 啟動路徑很容易卡住 不可以 挪到 Loaded / Shown / InitializeAsync

這張表最重要的一點是:plain await 在 UI 程式碼裡反而是友軍。 敵人不是 await 本身,而是 把 UI 執行緒同步地佔住。

選擇本身已經集中到這張表裡。之後各章的分工是:這張表為什麼會長成這樣(第 3 章與第 4 章)、怎麼挑把處理送回 UI 的工具(第 5 章)、在審查時怎麼找出來(第 6 章與第 7 章)。

敵人不是await而是同步阻塞說明plain await在UI程式碼裡反而是友軍,真正的敵人是把UI執行緒同步地佔住的圖。plain await在UI程式碼裡反而是友軍把UI執行緒同步地佔住凍結與死結的來源

圖 3: 判斷表核心的整理。敵人不是 await 本身,而是把 UI 執行緒同步地佔住。

3. 本文使用的詞彙

3.1. UI 執行緒與訊息迴圈

WPF / WinForms 的 UI 基本上是 只有一條 UI 執行緒,由它來處理輸入、繪製與事件 的形式。

這條 UI 執行緒的職責,大致如下。

  • 處理按鈕按下、鍵盤輸入、重繪等訊息
  • 成為唯一可以安全碰觸控制項與 UI 物件的執行緒
  • 一旦在上面塞太多處理,畫面更新與輸入回應就會停住

這裡的關鍵是:UI 執行緒的工作就是「轉得快」。 在這裡長時間阻塞,滑鼠、鍵盤、重繪就全部卡住,在使用者眼中就是「當掉了」。

這個畫面先用圖記在腦中,比較不容易混亂。

使用者輸入 / 重繪要求UI執行緒的訊息迴圈執行事件處理常式畫面更新長時間的同步處理訊息迴圈轉不動畫面看起來凍結

圖 4: UI 執行緒的工作是把訊息迴圈轉得快,一旦夾進長時間的同步處理,迴圈就停住,畫面看起來就凍結了。

3.2. SynchronizationContext / Dispatcher / Invoke

這裡常出現的詞彙,用實務的角度分開來看是這樣。

詞彙 這裡的意思
UI 執行緒 建立 UI 物件的那條執行緒。基本上只有它能安全地碰 UI
訊息迴圈 UI 執行緒依序處理訊息的機制
SynchronizationContext 「把處理送回那個執行位置」的抽象層
Dispatcher WPF 給 UI 執行緒用的佇列
Invoke / BeginInvoke / InvokeAsync 把處理丟給 UI 執行緒的 API

把接續的決定方式寫得更精確一點是這樣。await(相當於預設的 ConfigureAwait(true))捕捉的,首先是 SynchronizationContext.Current。只有在它是 null 時才會去看 TaskScheduler.Current,而且只有在它 不是 TaskScheduler.Default 時,才把接續送回那個 TaskScheduler。兩者都不成立,也就是 SynchronizationContext.Current 為 null 且 TaskScheduler.Current 是預設值時,接續就在 ThreadPool 上執行。 在 WPF / WinForms 的 UI 執行緒上是前者,也就是 UI 的 SynchronizationContext 已經立起來了,所以實務上可以直接當成 UI 的 SynchronizationContext 有在作用。

await接續位置的決定方式預設的await先捕捉SynchronizationContext.Current,若為null就看TaskScheduler.Current,不是預設值就送回那個TaskScheduler,兩者都不成立就在ThreadPool上執行接續的圖。有null不是預設值是預設值預設的await有SynchronizationContext嗎?送回那個執行內容TaskScheduler是預設值嗎?送回那個TaskScheduler在ThreadPool上執行接續UI執行緒上就是UI的執行內容

圖 5: 接續位置的決定方式。UI 執行緒上立著 UI 的 SynchronizationContext,所以接續會回到 UI。

各框架的對照關係,做成表比較好懂。

框架 UI 側的執行內容 明確送回 UI 的代表性 API
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync

WPF 以 Dispatcher 為中心。 WinForms 則以控制項的控制代碼與訊息迴圈為中心,浮上檯面的是 BeginInvoke / Invoke。

實務上,抽象層與實體的關係記到這個程度,就不容易混在一起。

目前的程式碼SynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync(.NET 9+)

圖 6: SynchronizationContext 這層抽象的實體,在 WPF 連到 Dispatcher 系列,在 WinForms 連到 Control 的 Invoke 系列 API。

4. 典型模式

4.1. 在 UI 事件處理常式中使用 plain await

這是最直接了當的形式。

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    LoadButton.IsEnabled = false;
    StatusText.Text = "讀取中...";

    try
    {
        string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
        PreviewTextBox.Text = text;
        StatusText.Text = "完成";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        LoadButton.IsEnabled = true;
    }
}

在這段程式碼中,LoadButton_Click 是在 UI 執行緒上開始的。 而 await File.ReadAllTextAsync(...) 是 plain await,所以通常會 捕捉當下的 UI 執行內容。

因此就變成,

  • 等待檔案 I/O 的期間不會佔用 UI 執行緒
  • 讀取完成後的接續,基本上回到 UI 執行緒
  • PreviewTextBox.Text = text; 可以直接寫

這樣的形式。

這裡不需要多餘的 Dispatcher。 只要是在 UI 處理常式中做 plain await,一般來說就可以直接碰 UI。

這個處理常式之所以寫成 async void,是因為 UI 事件處理常式的簽章要求 void,這是例外被允許的 async void。也正因如此,把 try / catch 放在裡面才有明確的理由。如果是 async Task,例外會載到傳回值的 Task 上,呼叫端 await 的當下就能收到。async void 沒有那個 Task,所以漏到外面的例外會 被重新拋到那個處理常式開始時的 SynchronizationContext,也就是 UI 執行緒上。UI 執行緒上的未處理例外,在 WPF 會出現在 Application.DispatcherUnhandledException,在 WinForms 會出現在 Application.ThreadException,若在那裡不處理,應用程式就會當掉。

也就是說,在 async void 的處理常式中 基本上要在處理常式裡面把例外攔下來,像上面的例子那樣「把失敗轉成狀態顯示,並在 finally 裡把按鈕恢復」,就能以 UI 的形式自然收尾。整個應用程式的接住點(DispatcherUnhandledException 等)終究只是最後一道網。

async void的例外會去哪裡async Task的例外會載到傳回值的Task上讓呼叫端用await收到,但async void漏到外面的例外會被重新拋到開始時的SynchronizationContext也就是UI執行緒,若在整體接住點不處理應用程式就會當掉的圖。async Taskasync void漏到處理常式外面的例外是async Task還是async void載到傳回值的Task上由await的呼叫端收到被重新拋到UI執行緒出現在整體接住點不處理應用程式就當掉

圖 7: async void 沒有可以載例外的 Task,所以基本上要在處理常式裡面把例外攔下來。

在 WinForms 的看法也一樣。 只要是在 Click 處理常式裡做 plain await,接續基本上就回到 UI 這一側。

畫成圖是這樣的流程。

UI SynchronizationContext非同步I/OUI執行緒UI SynchronizationContext非同步I/OUI執行緒等待期間回到訊息迴圈開始 Click 處理常式await ReadAllTextAsync預約把接續送回 UII/O 完成接續在 UI 執行緒恢復更新 TextBox / Label

圖 8: plain await 等待期間 UI 執行緒回到訊息迴圈,I/O 完成後的接續在 UI 執行緒恢復。

4.2. 只把吃重的 CPU 計算交給 Task.Run

Task.Run 派得上用場的場合,是 想把吃重的 CPU 計算移出 UI 執行緒的時候。

private async void HashButton_Click(object sender, RoutedEventArgs e)
{
    HashButton.IsEnabled = false;
    ResultText.Text = "計算中...";

    try
    {
        byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);

        string hash = await Task.Run(() =>
        {
            using SHA256 sha256 = SHA256.Create();
            byte[] digest = sha256.ComputeHash(data);
            return Convert.ToHexString(digest);
        });

        ResultText.Text = hash;
    }
    catch (Exception ex)
    {
        ResultText.Text = ex.Message;
    }
    finally
    {
        HashButton.IsEnabled = true;
    }
}

這段程式碼裡發生的事,大致如下。

  1. 事件處理常式在 UI 執行緒上開始
  2. File.ReadAllBytesAsync 的 I/O 等待以非同步的方式流過去
  3. 只有吃重的雜湊計算,用 Task.Run 丟到 ThreadPool
  4. await Task.Run(...) 的接續因為是 plain await,所以回到 UI 執行緒
  5. ResultText.Text = hash; 可以直接寫

也就是說,只有 Task.Run 裡面是別的執行緒。 並不是 await 之後就永久搬到「已經不是 UI 的地方」。

這裡用一張圖看,就不容易誤會。

ThreadPool非同步I/OUI執行緒ThreadPool非同步I/OUI執行緒await Task.Run(...) 的接續在 UI 恢復await ReadAllBytesAsync因為是 plain await 所以在 UI 恢復用 Task.Run 丟出吃重的CPU處理傳回計算結果把結果更新到畫面上

圖 9: 只有 Task.Run 裡面跑在 ThreadPool 上,await 的接續回到 UI 執行緒,所以更新畫面可以直接寫。

這裡要注意的有兩點。

  • 不要用 Task.Run 包 I/O 等待
  • 把 Task.Run 想成是造出「CPU 的去處」,而不是「把處理非同步化」

Task.Run(async () => await File.ReadAllTextAsync(...)) 這種寫法,只是白白把 I/O 等待再丟回 ThreadPool 一次,沒什麼好處。

Task.Run的使用時機把吃重的CPU計算移到ThreadPool才是Task.Run的職責,用它包住I/O等待只是白白把等待再丟回ThreadPool一次而沒有好處的圖。吃重的CPU計算I/O等待想移出去的是什麼用Task.Run移出去不要用Task.Run包住只是把等待再丟一次而沒有好處

圖 10: Task.Run 不是「非同步化」的工具,而是造出 CPU 去處的工具。

4.3. ConfigureAwait(false) 不是「保證不回來」,而是「不強制回來」

這裡是最容易被誤解的地方。

首先,ConfigureAwait(false) 適合的是 不依賴 UI 或特定應用程式模型的通用函式庫程式碼。

public sealed class DocumentRepository
{
    public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
    {
        string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
        return text.Replace("\r\n", "\n", StringComparison.Ordinal);
    }
}

這個方法不碰 UI。 不管是 WPF、WinForms、ASP.NET Core 還是 worker 都能用。 這樣的程式碼,加上 ConfigureAwait(false) 很自然。

而 UI 這一側的呼叫,用 plain await 就好。

private readonly DocumentRepository _repository = new();

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    OpenButton.IsEnabled = false;
    StatusText.Text = "讀取中...";

    try
    {
        string text = await _repository.LoadNormalizedTextAsync(
            PathTextBox.Text,
            CancellationToken.None);

        PreviewTextBox.Text = text;
        StatusText.Text = "完成";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        OpenButton.IsEnabled = true;
    }
}

這裡重要的是:函式庫內部的 ConfigureAwait(false),並不會把呼叫端的 await 也強制變成 false。

也就是說,

  • 函式庫內部不回到 UI
  • UI 處理常式用 plain await 呼叫它時,呼叫端的接續會回到 UI

可以做到這樣的分離。

函式庫與UI的分離通用函式庫內部用ConfigureAwait(false)不要求回到UI執行內容,而UI處理常式用plain await呼叫它時呼叫端的接續會回到UI,呈現這種分離的圖。函式庫內部的awaitConfigureAwait〔false〕不要求回到UIUI處理常式的plain await呼叫端的接續回到UI內側的指定不會影響外側

圖 11: 函式庫內的 ConfigureAwait(false),不會把呼叫端的 await 也強制變成 false。

反過來,在 UI 處理常式自己這樣寫就很危險。

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    string text = await _repository.LoadNormalizedTextAsync(
        PathTextBox.Text,
        CancellationToken.None).ConfigureAwait(false);

    PreviewTextBox.Text = text;
}

這種情況下,OpenButton_Click 中 那個 await 的接續 不會被強制送回 UI。 因此 PreviewTextBox.Text = text; 就可能變成 跨執行緒存取。

還有一點雖然不起眼卻很重要。 就算加了 ConfigureAwait(false),也不見得一定會移到 ThreadPool;當那個 await 沒有等待就立即完成時,接續有可能就這樣在目前的執行緒上繼續流動。 如果讀成「一定會跑到別的執行緒」「從這裡開始就再也不是 UI 了」就會出事,它的意思終究只是 不強制把那個 await 的接續送回原本的 UI 執行內容,僅此而已。

用圖來看是這樣。

不加要加在UI處理常式中 await要加 ConfigureAwait(false) 嗎?接續基本上在 UI 執行緒容易直接更新 UI接續不固定在 UI可能在任意執行緒恢復更新 UI 需要 Dispatcher / Invoke

圖 12: 加不加 ConfigureAwait(false),改變的只有「要不要強制把接續送回 UI」這一件事。

4.4. .Result / .Wait() / .GetAwaiter().GetResult() 會卡住的原因

這是最常見的事故。

private void LoadButton_Click(object sender, RoutedEventArgs e)
{
    string text = LoadTextAsync().Result;
    PreviewTextBox.Text = text;
}

private async Task<string> LoadTextAsync()
{
    string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
    return text.ToUpperInvariant();
}

乍看之下只是同步取得結果而已,但在 UI 執行緒上做這件事很危險。

把流程畫成圖是這樣。

UI SynchronizationContext非同步I/OUI執行緒UI SynchronizationContext非同步I/OUI執行緒但 UI 被 .Result 佔住了接續轉不動所以無法完成開始 LoadButton_Click呼叫 LoadTextAsync()傳回尚未完成的 Task用 .Result 等待而阻塞I/O 完成,想把接續送回 UI想執行接續

圖 13: 接續回不到被 .Result 佔住的 UI 執行緒,Task 就一直無法完成的流程。

把發生的事說成文字是這樣。

  1. UI 執行緒呼叫 LoadTextAsync()
  2. LoadTextAsync() 裡面的 await 捕捉了 UI 執行內容
  3. UI 執行緒用 .Result 等下去
  4. I/O 結束
  5. LoadTextAsync() 的接續想回到 UI 執行緒
  6. 但 UI 執行緒被 .Result 佔住了
  7. 接續跑不動,所以 LoadTextAsync() 無法完成
  8. .Result 永遠不會結束

也就是說,UI 說「等你結束為止我都等」,非同步這一側說「能回到 UI 我才能結束」,兩邊互相等待。 實在讓人不舒服。

互相等待的結構UI執行緒用.Result等待非同步處理完成,非同步側的接續又要等UI執行緒空出來,兩邊互相等待對方而動不了的圖。UI執行緒用.Result等待需要非同步側完成接續必須回到UI需要UI執行緒空出來互相等待而前進不了

圖 14: 「等你結束為止我都等」和「能回到 UI 我才能結束」撞在一起,兩邊互相等待。

這裡常見的誤解,是以為改成 GetAwaiter().GetResult() 就安全了。 但是 把 UI 執行緒佔住 的本質完全一樣。不同的主要是例外被包裝的方式。

所以在 UI 中,把這三個當成同一種味道來處理比較安全。

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

另外,把 WPF 的 Dispatcher.InvokeAsync(...) 所傳回的 DispatcherOperation 的 Task,從 UI 執行緒用 Task.Wait() 等待,也基於同樣的理由很危險。InvokeAsync 只是把傳入的委派排入 Dispatcher 的佇列,真正執行是在 UI 執行緒去轉那個佇列的時候。UI 執行緒被 Wait() 停住的話佇列就不會轉,那個 Task 也就永遠不會完成。 同樣的事情在 DispatcherOperation 這一側也有,DispatcherOperation.Wait() 明確寫著:等待在同一條執行緒上執行中的操作會拋出 InvalidOperationException。意思就是,阻塞著等待這條路徑本身就不在設計預期之內。 在 UI 的語境裡,「把丟出去的東西同步等回來」這個方向本身 就很容易卡住。關於卡住方式的詳細解說,Await, and UI, and deadlocks! Oh my! 讀起來很順。

同步等待InvokeAsync的危險Dispatcher.InvokeAsync只是把委派排入佇列,實際執行是在UI執行緒去轉那個佇列的時候,所以UI執行緒被Wait停住時佇列不會轉,Task也就永遠不會完成的圖。用InvokeAsync排入佇列UI執行緒轉佇列時才執行UI執行緒被Wait停住佇列不會轉Task永遠不會完成

圖 15: 如果由 UI 執行緒自己同步等待丟出去的東西,作為執行場所的佇列就不會轉,永遠不會結束。

要問「是不是一定會死結」,倒也未必。 如果剛好接續不回到 UI,也可能 不死結,只是單純讓 UI 凍結。 不過那也夠痛苦了,所以在 UI 中基本上不要這樣做。

5. 什麼時候該用 Dispatcher / Invoke

根據前面談到的內容,在 使用 plain await 的 UI 處理常式 中,平常並不需要明確的 Dispatcher / Invoke。

會需要的,例如是這些時候。

  • 想在 ConfigureAwait(false) 的接續裡碰 UI
  • 在 Task.Run 裡面,或即使在它外側,都做成不回到 UI 的結構
  • 通訊端接收、計時器、事件回呼等,通知一開始就來自不是 UI 執行緒的地方
  • 在刻意把 UI 與非 UI 分離的分層中,只想把最後的 UI 更新寫明確

WPF 的話,代表是 Dispatcher.InvokeAsync。

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await Dispatcher.InvokeAsync(() =>
    {
        PreviewTextBox.Text = text;
        StatusText.Text = "完成";
    });
}

WinForms 在 .NET 9 以後,InvokeAsync 與 async 流程能直接了當地咬合。

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await previewTextBox.InvokeAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "完成";
    });
}

WinForms 的舊式寫法要用 BeginInvoke。 Invoke 是同步送出,會讓呼叫端等待。BeginInvoke 投遞後立刻返回。 在 async 流程中,基本上 不阻塞的那一邊 咬合得比較好。

Invoke與BeginInvoke的差異Invoke是同步送出會讓呼叫端等待,BeginInvoke投遞後立刻返回,所以在async流程中不阻塞的那一邊咬合得比較好的圖。Invoke〔同步送出〕讓呼叫端等待BeginInvoke〔投遞〕立刻返回與async流程咬合

圖 16: 同樣是「丟給 UI」,會讓人等待的 Invoke 和立刻返回的 BeginInvoke 性質並不相同。

不過,Control.BeginInvoke 傳回的是 IAsyncResult,所以沒辦法直接 await。在沒有 Control.InvokeAsync 的環境(.NET Framework 4.8、.NET 6 / 8 等)想把它接上 async 流程,用 TaskCompletionSource 包成 Task 是最直接的做法。

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public static class ControlUiExtensions
{
    // 為了在 .NET Framework 4.8 上也能直接使用,這裡用的是泛型版的
    // TaskCompletionSource。.NET 5 以後也可以用非泛型版來寫。
    //
    // cancellationToken 沒有做成可省略。BeginInvoke 受理之後如果控制項被處置,
    // 已投遞的委派會在未執行的狀態下被丟掉,TaskCompletionSource 裡
    // 既不會放入結果也不會放入例外。不準備一個中斷的出口,
    // await 的那一側就會這樣永遠等下去
    public static Task InvokeOnUiAsync(
        this Control control, Action action, CancellationToken cancellationToken)
    {
        if (control is null)
        {
            throw new ArgumentNullException(nameof(control));
        }

        if (action is null)
        {
            throw new ArgumentNullException(nameof(action));
        }

        if (!control.IsHandleCreated)
        {
            throw new InvalidOperationException("視窗控制代碼尚未建立。");
        }

        if (!control.InvokeRequired)
        {
            action();
            return Task.CompletedTask;
        }

        // 為了避免 await 那一側的接續就這樣直接在 UI 執行緒上跑,
        // 這裡明確指定接續要以非同步的方式流動。
        var tcs = new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        // 讓取消與執行去爭奪同一份「一次份的權利」。
        // 用 Interlocked.Exchange 先寫入 1 的那一方才能繼續往下。
        // 如果寫成先看旗標再呼叫 action(),一旦在看完的下一瞬間被取消,
        // 就會留下「呼叫端已經收到取消並開始下一個操作,
        // 舊的委派卻在之後改寫畫面」的路徑
        int claimed = 0;   // 0 = 未確定 / 1 = 已被其中一方取得

        // 一旦被取消,即使委派沒有執行,Task 也會被收掉。
        // 註冊一定要在 Task 完成時解除(不解除的話,只要權杖還活著
        // 就會一直抓著 tcs)。CancellationTokenRegistration.Dispose
        // 是執行緒安全的,從哪一條執行緒呼叫都可以
        CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetCanceled(cancellationToken);
            }
        });

        tcs.Task.ContinueWith(
            _ => registration.Dispose(),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);

        try
        {
            control.BeginInvoke(new Action(() =>
            {
                // 從投遞出去到 UI 執行緒開始動作之間,有可能被取消。
                // 如果在這裡搶不到權利,就表示取消那一側先拿走了,
                // 所以完全不碰畫面直接返回
                if (Interlocked.Exchange(ref claimed, 1) != 0)
                {
                    return;
                }

                try
                {
                    action();
                    tcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }));
        }
        catch (Exception ex)
        {
            // BeginInvoke 自己也可能拋出例外(例如控制代碼已經不在)。
            // 不在這裡收掉的話,一樣會一直等下去。
            // 委派不會執行,所以這裡也是先取得權利再收掉
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetException(ex);
            }
        }

        return tcs.Task;
    }
}

呼叫端的形式,和 InvokeAsync 的例子幾乎一樣。請把權杖和表單的生命週期綁在一起。

// 表單的欄位。關閉時取消
private readonly CancellationTokenSource _formClosing = new();

protected override void OnFormClosed(FormClosedEventArgs e)
{
    // 就算已投遞的委派在未執行的狀態下被丟掉,
    // 也能在這裡把 await 那一側收掉
    _formClosing.Cancel();
    base.OnFormClosed(e);
}

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _formClosing.Token);

    string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);

    await previewTextBox.InvokeOnUiAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "完成";
    }, linked.Token);
}

用這種形式,UI 側發生的例外也能在 await 的位置用 try / catch 收到。要先掌握的有 4 點。

  • 取消與執行,光靠「先看旗標再動作」是不夠的。 在確認完「是不是已經取消了」之後,還沒呼叫 action() 之前,有可能發生取消。就在這一瞬間 tcs 變成已取消,await 的呼叫端往前推進並開始下一個操作。之後,還留在佇列裡的舊委派才動起來改寫畫面 ── 新的顯示被舊的顯示覆寫,這是一種很難重現的壞掉方式。上面的程式碼之所以用 Interlocked.Exchange 讓兩邊爭奪「一次份的權利」就是為了這個,而 搶不到的那一方什麼都不做就返回
  • 在控制代碼建立之前(Load 之前),或表單關閉之後呼叫 BeginInvoke 都會拋出例外。請注意呼叫端的生命週期
  • 投遞之後如果控制項被處置,委派有可能不會執行就被丟掉。這種情況下 TaskCompletionSource 裡既不會放入結果也不會放入例外,所以 await 的那一側會永遠等下去。請一定要像上面的例子那樣,傳入與表單結束綁在一起的權杖。收掉的結果會以 OperationCanceledException 的形式往上拋
  • File.ReadAllTextAsync 是 .NET Core 2.0 以後的 API。要在 .NET Framework 4.8 上寫成同樣的形式,請改用 StreamReader.ReadToEndAsync 之類的做法
取消與執行爭奪權利取消側與執行側用Interlocked.Exchange爭奪一次份的權利,先搶到的一方才往下走,搶不到的一方什麼都不做就返回,藉此防止舊委派改寫畫面的競態的圖。一次份的權利取消側先搶到執行側先搶到把Task收成已取消舊委派什麼都不做就返回執行action並放入結果

圖 17: 不是先看旗標再動作,而是讓兩邊爭奪一次份的權利,搶不到的一方就返回。

分辨的方式,做到這個程度的區分就夠了。

想做的事 WPF WinForms
同步送進 UI Dispatcher.Invoke Control.Invoke
非同步丟給 UI Dispatcher.InvokeAsync / Dispatcher.BeginInvoke Control.BeginInvoke / .NET 9+ Control.InvokeAsync
想與 async / await 直接了當地搭配 Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync,更早則是 BeginInvoke

實務上的感覺是,

  • 只在 UI 處理常式中做 plain await 的話就不需要
  • 想從 UI 以外的地方碰 UI 時才用
  • 不要在 async 流程中增加太多同步的 Invoke

這樣就能減少不少事故。

迷惘的時候,用這種程度的判斷圖就夠了。

是不是不想想想要寫這段接續的地方是 UI 執行緒嗎?是嗎?維持 plain await 就可以更新 UI想碰 UI 嗎?就這樣繼續處理WPF: Dispatcher.InvokeAsyncWinForms: BeginInvoke / InvokeAsync

圖 18: 依據寫接續的地方是不是 UI 執行緒,判斷維持 plain await 就好,還是需要 Dispatcher / Invoke 系列。

6. 常見的反模式

反模式 哪裡痛苦 先這樣替換
在 UI 處理常式中 LoadAsync().Result 把 UI 執行緒佔住。很容易死結 await LoadAsync()
在 UI 處理常式中 LoadAsync().Wait() 同上。訊息迴圈會停住 await LoadAsync()
在 UI 處理常式中 LoadAsync().GetAwaiter().GetResult() 只是例外看起來不同,阻塞完全一樣 await LoadAsync()
機械式地把 ConfigureAwait(false) 加到 UI 程式碼 await 之後的 UI 更新很容易壞掉 UI 的最外側用 plain await
Task.Run(async () => await IoAsync()) 白白把 I/O 再丟一次 await IoAsync()
函式庫程式碼直接握著 Dispatcher 或 Control UI 相依變深。難以重複使用 函式庫只傳回資料,由 UI 側做封送處理
在 async 流程中大量使用 Dispatcher.Invoke / Control.Invoke 很容易形成阻塞的環 考慮 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
在建構函式或屬性 getter 中把 async 同步化 是啟動時停止回應的溫床 挪到 Loaded / Shown / InitializeAsync

其中遭遇率特別高的有 3 個。

  1. 在 UI 執行緒上用 .Result / .Wait()
  2. 機械式地把 ConfigureAwait(false) 加到 UI 程式碼
  3. 函式庫與 UI 的職責混在一起,Dispatcher 侵入到深處

光是把這 3 個拿掉,程式碼就會安靜許多。

遭遇率特別高的三個在UI執行緒上使用.Result或.Wait、機械式地把ConfigureAwait(false)加到UI程式碼、職責混在一起讓Dispatcher侵入函式庫深處這三個,光是拿掉程式碼就會安靜下來的圖。在UI執行緒上用.Result或.Wait把這三個拿掉機械式的ConfigureAwaitDispatcher侵入到深處程式碼會安靜許多

圖 19: 反模式當中遭遇率高的就是這 3 個,光是拿掉效果就很大。

7. 審查時的檢查清單

內容和 2.2 的判斷表以及第 6 章的反模式相同,但這裡整理成 打開程式碼時可以依序確認的問題。

  • UI 事件處理常式或 UI 初始化路徑中,還有沒有殘留 .Result / .Wait() / .GetAwaiter().GetResult()
  • Task.Run 是不是只用在 CPU 計算 上。有沒有拿去包 I/O
  • ConfigureAwait(false) 是不是被機械式地放進 UI 程式碼
  • 反過來,通用函式庫裡有沒有拖著對 UI 執行內容的相依
  • await 之後直接碰 UI 的地方,真的可以說那裡是在 UI 執行內容上嗎
  • 在必須明確送回 UI 的地方,有沒有用 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
  • Dispatcher.Invoke / Control.Invoke 這類同步封送處理,有沒有不必要地變多
  • 有沒有從建構函式、同步屬性、同步事件硬把 async 同步化
  • 函式庫層有沒有直接參考 Window / Control / Dispatcher

這份檢查清單,也很適合用來在團隊裡統一「哪些是 UI 的職責」。

8. 大致的取捨

各種狀況下的選擇已經集中到 2.2 的判斷表,所以這裡只放可以帶走的記法。

  • UI 的最外側用 plain await。await 之後可以直接碰 UI,就是因為守住了這一點
  • Task.Run 是 CPU 的去處。不是拿來包 I/O 等待的工具
  • ConfigureAwait(false) 是通用函式庫的工具。不要機械式地加到 UI 程式碼上
  • Dispatcher / BeginInvoke / InvokeAsync,只在要從 UI 以外的地方碰 UI 時才用
  • 不要用在 UI 執行緒上等待的那三個(.Result / .Wait() / .GetAwaiter().GetResult())。想同步化的時候,就把呼叫端一起延伸成 async

理由的部分在第 4 章,Dispatcher / Invoke 的挑法在第 5 章,在實際的程式碼裡找出來的觀點在第 6 章與第 7 章。

9. 總結

WPF / WinForms 的 async / await 真正重要的, 不是「非同步很難」這種氛圍,而是把

  • 現在是從哪裡開始的
  • await 的接續會回到哪裡
  • 由誰負責把處理送回 UI

分開來想。

先採用的規則,只要守住這些就足以應戰。

  1. UI 的最外側用 plain await
  2. 只把吃重的 CPU 交給 Task.Run
  3. 通用函式庫考慮 ConfigureAwait(false)
  4. 只在必須送回 UI 時才用 Dispatcher / BeginInvoke / InvokeAsync
  5. 在 UI 執行緒上不使用 .Result / .Wait() / .GetAwaiter().GetResult()

async / await 本身並不是那麼難搞的機制。 只是 不以 UI 執行緒為中心來看就直接拿來用的話,會突然變成一片泥沼。

反過來說,

  • 分開 UI 的外側與內側
  • 意識到回歸處
  • 不要把阻塞帶進來

只要守住這 3 點,WPF / WinForms 的非同步程式碼就會安靜許多。 會凍結的程式碼,大多不是「非同步不好」,而是 對 UI 執行緒借債的方式太隨手。

安靜的非同步UI程式碼三原則分開UI的外側與內側、意識到await的回歸處、不要把阻塞帶進來,只要守住這三點WPF與WinForms的非同步程式碼就會安靜下來的圖。分開UI的外側與內側非同步程式碼會安靜下來意識到回歸處不要把阻塞帶進來

圖 20: 總結的三原則。分開、意識回歸處、不阻塞,畫面就不會再凍結。

10. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

await 之後會回到哪一條執行緒?
在 WPF / WinForms 的 UI 事件處理常式中使用 plain await(不加 ConfigureAwait)時,await 之後的接續基本上會回到 UI 執行緒。這是因為 await 會捕捉當下的 UI SynchronizationContext 並把接續送回去,所以 await 之後可以直接寫 TextBox 或 Label 的更新。await Task.Run(...) 也一樣,計算本體在 ThreadPool 上執行,但只要是 plain await,接續就會在 UI 執行緒上恢復。
在 UI 執行緒使用 .Result 或 .Wait() 為什麼會凍結?
UI 執行緒用 .Result 等待的期間,非同步處理的接續想回到捕捉到的 UI 執行內容,但 UI 執行緒被 .Result 佔住而無法執行接續,兩邊互相等待就形成死結。GetAwaiter().GetResult() 只是例外被包裝的方式不同,阻塞 UI 執行緒的本質完全一樣。在 UI 中 .Result、.Wait()、GetAwaiter().GetResult() 這三種都要避開,改用 await。
ConfigureAwait(false) 應該加在 UI 程式碼上嗎?
最好不要加。ConfigureAwait(false) 的意思是「不強制接續回到所捕捉的 UI 執行內容」,所以接續可能在任意執行緒上恢復,緊接著的 UI 更新就可能變成跨執行緒存取。它適合的是不依賴 UI 的通用函式庫程式碼;UI 的最外側維持 plain await 才是方針。
Task.Run 什麼時候該用?
只有想把吃重的 CPU 計算移出 UI 執行緒時才用。用 Task.Run 包住 I/O 等待,只是白白把等待再丟回 ThreadPool 一次,沒有好處。只有 Task.Run 裡面跑在別的執行緒上,await Task.Run(...) 的接續只要是 plain await 通常就回到 UI 執行緒,所以把結果更新到畫面上可以直接寫。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽