更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(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)
目錄
- 先講結論(一句話)
- 先用一頁整理
- 2.1. 全貌
- 2.2. 先看的判斷表
- 本文使用的詞彙
- 3.1. UI 執行緒與訊息迴圈
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- 典型模式
- 4.1. 在 UI 事件處理常式中使用 plain
await - 4.2. 只把吃重的 CPU 計算交給
Task.Run - 4.3.
ConfigureAwait(false)不是「保證不回來」,而是「不強制回來」 - 4.4.
.Result/.Wait()/.GetAwaiter().GetResult()會卡住的原因
- 4.1. 在 UI 事件處理常式中使用 plain
- 什麼時候該用
Dispatcher/Invoke - 常見的反模式
- 審查時的檢查清單
- 大致的取捨
- 總結
- 參考資料
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 26 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
1. 先講結論(一句話)
- 在 WPF / WinForms 的 UI 事件處理常式 中使用 plain
await時,await之後的接續可以當成 基本上會回到 UI 執行緒 Task.Run是 把 CPU 計算移出 UI 執行緒的工具,不是拿來包 I/O 等待的- 就算在 UI 處理常式裡
await Task.Run(...),只要那個await是 plainawait,接續通常也 回到 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 中
- 現在跑在哪一條執行緒上
await的接續會回到哪裡- 由誰負責把處理送回 UI
先掌握這三件事,全貌就會清楚很多。
flowchart TB
accTitle: 讓全貌更清楚的三個問題
accDescr: 先掌握現在跑在哪一條執行緒、await的接續會回到哪裡、由誰負責把處理送回UI這三件事,WPF與WinForms的非同步程式碼就更容易看清全貌的圖。
q1["現在跑在哪一條執行緒"] --> goal["非同步UI程式碼的全貌"]
q2["await的接續會回到哪裡"] --> goal
q3["由誰負責把處理送回UI"] --> goal
圖 1: 迷路時就回來問的三個問題。把執行緒、回歸處、送回的責任分開來想。
2. 先用一頁整理
2.1. 全貌
先用這張圖抓住全貌會比較快。
flowchart LR
A["UI事件處理常式<br/>(WPF / WinForms)"] --> B["plain await<br/>I/O API"]
B --> C["捕捉 UI SynchronizationContext"]
C --> D["await後在 UI 執行緒恢復"]
D --> E["UI更新可以直接寫"]
A --> F["await Task.Run(...)<br/>吃重的CPU處理"]
F --> G["計算本體在 ThreadPool"]
G --> H["await後在 UI 執行緒恢復"]
H --> E
A --> I["await SomeAsync().ConfigureAwait(false)"]
I --> J["不強制回到 UI"]
J --> K["接續在任意執行緒"]
K --> L["直接更新 UI 很危險<br/>需要 Dispatcher / Invoke"]
A --> M["SomeAsync().Result / Wait()<br/>GetAwaiter().GetResult()"]
M --> N["阻塞UI執行緒"]
N --> O["接續回不到 UI"]
O --> P["停止回應 / 死結 / 至少也會凍結"]
圖 2: 從 UI 處理常式看到的四種模式全貌。plain await 與 Task.Run 會回到 UI,ConfigureAwait(false) 不強制回歸,.Result / .Wait() 則把 UI 執行緒佔住。
實務上會看到的,大致就是這 4 種模式。
- 在 UI 事件處理常式中使用 plain
await - 在 UI 事件處理常式中用
Task.Run把 CPU 移出去 - 用
ConfigureAwait(false)把回歸處拿掉 - 用
.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 章)。
flowchart TB
accTitle: 敵人不是await而是同步阻塞
accDescr: 說明plain await在UI程式碼裡反而是友軍,真正的敵人是把UI執行緒同步地佔住的圖。
pa["plain await"] --> friend["在UI程式碼裡反而是友軍"]
blk["把UI執行緒同步地佔住"] --> enemy["凍結與死結的來源"]
圖 3: 判斷表核心的整理。敵人不是 await 本身,而是把 UI 執行緒同步地佔住。
3. 本文使用的詞彙
3.1. UI 執行緒與訊息迴圈
WPF / WinForms 的 UI 基本上是 只有一條 UI 執行緒,由它來處理輸入、繪製與事件 的形式。
這條 UI 執行緒的職責,大致如下。
- 處理按鈕按下、鍵盤輸入、重繪等訊息
- 成為唯一可以安全碰觸控制項與 UI 物件的執行緒
- 一旦在上面塞太多處理,畫面更新與輸入回應就會停住
這裡的關鍵是:UI 執行緒的工作就是「轉得快」。 在這裡長時間阻塞,滑鼠、鍵盤、重繪就全部卡住,在使用者眼中就是「當掉了」。
這個畫面先用圖記在腦中,比較不容易混亂。
flowchart LR
A["使用者輸入 / 重繪要求"] --> B["UI執行緒的訊息迴圈"]
B --> C["執行事件處理常式"]
C --> D["畫面更新"]
D --> B
C --> E["長時間的同步處理"]
E --> F["訊息迴圈轉不動"]
F --> G["畫面看起來凍結"]
圖 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 有在作用。
flowchart TB
accTitle: await接續位置的決定方式
accDescr: 預設的await先捕捉SynchronizationContext.Current,若為null就看TaskScheduler.Current,不是預設值就送回那個TaskScheduler,兩者都不成立就在ThreadPool上執行接續的圖。
a["預設的await"] --> sc{"有SynchronizationContext嗎?"}
sc -->|"有"| toSc["送回那個執行內容"]
sc -->|"null"| ts{"TaskScheduler是預設值嗎?"}
ts -->|"不是預設值"| toTs["送回那個TaskScheduler"]
ts -->|"是預設值"| pool["在ThreadPool上執行接續"]
toSc -.-> ui["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。
實務上,抽象層與實體的關係記到這個程度,就不容易混在一起。
flowchart TD
A["目前的程式碼"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.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 等)終究只是最後一道網。
flowchart TB
accTitle: async void的例外會去哪裡
accDescr: async Task的例外會載到傳回值的Task上讓呼叫端用await收到,但async void漏到外面的例外會被重新拋到開始時的SynchronizationContext也就是UI執行緒,若在整體接住點不處理應用程式就會當掉的圖。
ex["漏到處理常式外面的例外"] --> kind{"是async Task還是async void"}
kind -->|"async Task"| task["載到傳回值的Task上"]
task --> caller["由await的呼叫端收到"]
kind -->|"async void"| ctx["被重新拋到UI執行緒"]
ctx --> global["出現在整體接住點"]
global --> crash["不處理應用程式就當掉"]
圖 7: async void 沒有可以載例外的 Task,所以基本上要在處理常式裡面把例外攔下來。
在 WinForms 的看法也一樣。
只要是在 Click 處理常式裡做 plain await,接續基本上就回到 UI 這一側。
畫成圖是這樣的流程。
sequenceDiagram
participant UI as UI執行緒
participant IO as 非同步I/O
participant Ctx as UI SynchronizationContext
UI->>UI: 開始 Click 處理常式
UI->>IO: await ReadAllTextAsync
UI-->>Ctx: 預約把接續送回 UI
Note over UI: 等待期間回到訊息迴圈
IO-->>Ctx: I/O 完成
Ctx-->>UI: 接續在 UI 執行緒恢復
UI->>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;
}
}
這段程式碼裡發生的事,大致如下。
- 事件處理常式在 UI 執行緒上開始
File.ReadAllBytesAsync的 I/O 等待以非同步的方式流過去- 只有吃重的雜湊計算,用
Task.Run丟到 ThreadPool await Task.Run(...)的接續因為是 plainawait,所以回到 UI 執行緒ResultText.Text = hash;可以直接寫
也就是說,只有 Task.Run 裡面是別的執行緒。
並不是 await 之後就永久搬到「已經不是 UI 的地方」。
這裡用一張圖看,就不容易誤會。
sequenceDiagram
participant UI as UI執行緒
participant IO as 非同步I/O
participant Pool as ThreadPool
UI->>IO: await ReadAllBytesAsync
IO-->>UI: 因為是 plain await 所以在 UI 恢復
UI->>Pool: 用 Task.Run 丟出吃重的CPU處理
Pool-->>UI: 傳回計算結果
Note over UI: await Task.Run(...) 的接續在 UI 恢復
UI->>UI: 把結果更新到畫面上
圖 9: 只有 Task.Run 裡面跑在 ThreadPool 上,await 的接續回到 UI 執行緒,所以更新畫面可以直接寫。
這裡要注意的有兩點。
- 不要用
Task.Run包 I/O 等待 - 把
Task.Run想成是造出「CPU 的去處」,而不是「把處理非同步化」
Task.Run(async () => await File.ReadAllTextAsync(...)) 這種寫法,只是白白把 I/O 等待再丟回 ThreadPool 一次,沒什麼好處。
flowchart TB
accTitle: Task.Run的使用時機
accDescr: 把吃重的CPU計算移到ThreadPool才是Task.Run的職責,用它包住I/O等待只是白白把等待再丟回ThreadPool一次而沒有好處的圖。
q{"想移出去的是什麼"}
q -->|"吃重的CPU計算"| ok["用Task.Run移出去"]
q -->|"I/O等待"| ng["不要用Task.Run包住"]
ng --> why["只是把等待再丟一次而沒有好處"]
圖 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
可以做到這樣的分離。
flowchart TB
accTitle: 函式庫與UI的分離
accDescr: 通用函式庫內部用ConfigureAwait(false)不要求回到UI執行內容,而UI處理常式用plain await呼叫它時呼叫端的接續會回到UI,呈現這種分離的圖。
lib["函式庫內部的await"] --> nof["ConfigureAwait〔false〕"]
nof --> stay["不要求回到UI"]
uih["UI處理常式的plain await"] --> back["呼叫端的接續回到UI"]
nof -.-> note["內側的指定不會影響外側"]
圖 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 執行內容,僅此而已。
用圖來看是這樣。
flowchart LR
A["在UI處理常式中 await"] --> B{"要加 ConfigureAwait(false) 嗎?"}
B -- 不加 --> C["接續基本上在 UI 執行緒"]
C --> D["容易直接更新 UI"]
B -- 要加 --> E["接續不固定在 UI"]
E --> F["可能在任意執行緒恢復"]
F --> G["更新 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 執行緒上做這件事很危險。
把流程畫成圖是這樣。
sequenceDiagram
participant UI as UI執行緒
participant IO as 非同步I/O
participant Ctx as UI SynchronizationContext
UI->>UI: 開始 LoadButton_Click
UI->>IO: 呼叫 LoadTextAsync()
IO-->>UI: 傳回尚未完成的 Task
UI->>UI: 用 .Result 等待而阻塞
IO-->>Ctx: I/O 完成,想把接續送回 UI
Ctx-->>UI: 想執行接續
Note over UI: 但 UI 被 .Result 佔住了
Note over UI, Ctx: 接續轉不動所以無法完成
圖 13: 接續回不到被 .Result 佔住的 UI 執行緒,Task 就一直無法完成的流程。
把發生的事說成文字是這樣。
- UI 執行緒呼叫
LoadTextAsync() LoadTextAsync()裡面的await捕捉了 UI 執行內容- UI 執行緒用
.Result等下去 - I/O 結束
LoadTextAsync()的接續想回到 UI 執行緒- 但 UI 執行緒被
.Result佔住了 - 接續跑不動,所以
LoadTextAsync()無法完成 .Result永遠不會結束
也就是說,UI 說「等你結束為止我都等」,非同步這一側說「能回到 UI 我才能結束」,兩邊互相等待。 實在讓人不舒服。
flowchart TB
accTitle: 互相等待的結構
accDescr: UI執行緒用.Result等待非同步處理完成,非同步側的接續又要等UI執行緒空出來,兩邊互相等待對方而動不了的圖。
ui["UI執行緒用.Result等待"] --> need["需要非同步側完成"]
cont["接續必須回到UI"] --> free["需要UI執行緒空出來"]
need --> cycle["互相等待而前進不了"]
free --> cycle
圖 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! 讀起來很順。
flowchart TB
accTitle: 同步等待InvokeAsync的危險
accDescr: Dispatcher.InvokeAsync只是把委派排入佇列,實際執行是在UI執行緒去轉那個佇列的時候,所以UI執行緒被Wait停住時佇列不會轉,Task也就永遠不會完成的圖。
post["用InvokeAsync排入佇列"] --> run["UI執行緒轉佇列時才執行"]
wait["UI執行緒被Wait停住"] --> norun["佇列不會轉"]
norun --> never["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 流程中,基本上 不阻塞的那一邊 咬合得比較好。
flowchart TB
accTitle: Invoke與BeginInvoke的差異
accDescr: Invoke是同步送出會讓呼叫端等待,BeginInvoke投遞後立刻返回,所以在async流程中不阻塞的那一邊咬合得比較好的圖。
inv["Invoke〔同步送出〕"] --> waitc["讓呼叫端等待"]
bi["BeginInvoke〔投遞〕"] --> ret["立刻返回"]
ret --> fit["與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之類的做法
flowchart TB
accTitle: 取消與執行爭奪權利
accDescr: 取消側與執行側用Interlocked.Exchange爭奪一次份的權利,先搶到的一方才往下走,搶不到的一方什麼都不做就返回,藉此防止舊委派改寫畫面的競態的圖。
race["一次份的權利"] --> c["取消側先搶到"]
race --> e["執行側先搶到"]
c --> c2["把Task收成已取消"]
c --> c3["舊委派什麼都不做就返回"]
e --> e2["執行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
這樣就能減少不少事故。
迷惘的時候,用這種程度的判斷圖就夠了。
flowchart TD
A["要寫這段接續的地方是 UI 執行緒嗎?"] --> B{"是嗎?"}
B -- 是 --> C["維持 plain await 就可以更新 UI"]
B -- 不是 --> D{"想碰 UI 嗎?"}
D -- 不想 --> E["就這樣繼續處理"]
D -- 想 --> F["WPF: Dispatcher.InvokeAsync"]
D -- 想 --> G["WinForms: 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 個。
- 在 UI 執行緒上用
.Result/.Wait() - 機械式地把
ConfigureAwait(false)加到 UI 程式碼 - 函式庫與 UI 的職責混在一起,
Dispatcher侵入到深處
光是把這 3 個拿掉,程式碼就會安靜許多。
flowchart TB
accTitle: 遭遇率特別高的三個
accDescr: 在UI執行緒上使用.Result或.Wait、機械式地把ConfigureAwait(false)加到UI程式碼、職責混在一起讓Dispatcher侵入函式庫深處這三個,光是拿掉程式碼就會安靜下來的圖。
a1["在UI執行緒上用.Result或.Wait"] --> fix["把這三個拿掉"]
a2["機械式的ConfigureAwait"] --> fix
a3["Dispatcher侵入到深處"] --> fix
fix --> calm["程式碼會安靜許多"]
圖 19: 反模式當中遭遇率高的就是這 3 個,光是拿掉效果就很大。
7. 審查時的檢查清單
內容和 2.2 的判斷表以及第 6 章的反模式相同,但這裡整理成 打開程式碼時可以依序確認的問題。
- UI 事件處理常式或 UI 初始化路徑中,還有沒有殘留
.Result/.Wait()/.GetAwaiter().GetResult() Task.Run是不是只用在 CPU 計算 上。有沒有拿去包 I/OConfigureAwait(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
分開來想。
先採用的規則,只要守住這些就足以應戰。
- UI 的最外側用 plain
await - 只把吃重的 CPU 交給
Task.Run - 通用函式庫考慮
ConfigureAwait(false) - 只在必須送回 UI 時才用
Dispatcher/BeginInvoke/InvokeAsync - 在 UI 執行緒上不使用
.Result/.Wait()/.GetAwaiter().GetResult()
async / await 本身並不是那麼難搞的機制。
只是 不以 UI 執行緒為中心來看就直接拿來用的話,會突然變成一片泥沼。
反過來說,
- 分開 UI 的外側與內側
- 意識到回歸處
- 不要把阻塞帶進來
只要守住這 3 點,WPF / WinForms 的非同步程式碼就會安靜許多。 會凍結的程式碼,大多不是「非同步不好」,而是 對 UI 執行緒借債的方式太隨手。
flowchart TB
accTitle: 安靜的非同步UI程式碼三原則
accDescr: 分開UI的外側與內側、意識到await的回歸處、不要把阻塞帶進來,只要守住這三點WPF與WinForms的非同步程式碼就會安靜下來的圖。
r1["分開UI的外側與內側"] --> calm["非同步程式碼會安靜下來"]
r2["意識到回歸處"] --> calm
r3["不要把阻塞帶進來"] --> calm
圖 20: 總結的三原則。分開、意識回歸處、不阻塞,畫面就不會再凍結。
10. 參考資料
- 本文的整套範例程式碼(不依賴 UI 的函式庫、WPF / WinForms 範例、單元測試) - komurasoft-blog-samples (GitHub)
- 相關文章: C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait
- Threading Model - WPF
- DispatcherSynchronizationContext Class
- How to handle cross-thread operations with controls - Windows Forms
- WindowsFormsSynchronizationContext Class
- Events Overview - Windows Forms
- TaskScheduler.FromCurrentSynchronizationContext Method
- ConfigureAwait FAQ
- How Async/Await Really Works in C#
- Await, and UI, and deadlocks! Oh my!
- Threading model for WebView2 apps
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
WinForms / WPF 應用程式的 CI/CD 實務 ── 用 GitHub Actions 把從建置到簽章・發布全部自動化
本文整理用 GitHub Actions 為 WinForms / WPF 應用程式建置 CI/CD 的實務指南,內容涵蓋在 windows-latest 上進行建置+測試的最小 YAML、以標籤驅動的版本編號、透過 signtool 整合簽章,以及依 MSI/MSIX/C...
Windows 應用程式的工作列通知區常駐與 Toast 通知 —— NotifyIcon 的陷阱與 AppNotification 的選型
本文整理將業務用 Windows 應用程式常駐於工作列通知區,並透過 Toast 通知告知使用者的實作要點。內容涵蓋 NotifyIcon 的正確用法與「關閉後仍留在通知區」的設計、檔案總管重新啟動時的重新註冊、三種 Toast API(Windows App SDK Ap...
WinForms/WPF 應用程式的多語言化 ── resx、附屬組件與文化特性切換的實務
本文從實務角度整理 Windows 桌面應用程式的多語言化,包括 CurrentCulture 與 CurrentUICulture 的差異、resx 與附屬組件(Satellite Assembly)所構成的資源機制、WinForms 的 Localizable 屬性、W...
在 WinForms/WPF 應用程式中導入 Entra ID 驗證 ── MSAL.NET 與 WAM 代理的實務架構
從實務角度整理在 WinForms/WPF 桌面應用程式中導入 Entra ID(原 Azure AD)驗證的做法。內容涵蓋公用客戶端的概念、ROPC 廢除現況、應用程式註冊、MSAL.NET 的 AcquireTokenSilent 模式、WAM 代理、權杖快取持久化,以...
Windows 桌面應用程式的 UI 自動測試 ── UI Automation 的原理與用 FlaUI 打造不易損壞的測試
本文從 Windows UI Automation 的原理(樹狀結構、AutomationId、控制項模式)整理 WinForms/WPF 應用程式的 UI 自動測試。內容涵蓋以 FlaUI 實作的最小範例、WinAppDriver 的現況、透過 AutomationId ...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
UI 執行緒 & 計時器
整理 WPF / WinForms UI 執行緒、非同步流程、Dispatcher 使用、計時器判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
WPF / WinForms 的 UI 執行緒與 async/await,是 Windows 應用程式開發在實作時最容易卡住的論點之一。
技術諮詢 & 設計審查
如果現在的階段是想梳理 UI 與背景處理的職責,以及 Dispatcher 的取捨,可以用技術諮詢與設計審查的形式重新檢視。
常見問題
整理諮詢這個主題時常見的問題。
- 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 執行緒,所以把結果更新到畫面上可以直接寫。