C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait
· 更新日期: · Go Komura · C#, async/await, .NET, 設計
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616243)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616243 https://comcomponent.com/zh-TW/blog/2026/03/09/001-csharp-async-await-best-practices/
- DOI(最新版本)
- 10.5281/zenodo.21616243
- DOI(此版本)
- 10.5281/zenodo.22297076
C# 的 async / await 天天都在用,但實務上真正令人猶豫的不是語法本身,而是 在哪個場面該選哪種寫法。
搜尋量特別大的,都是判斷上的煩惱:Task.Run 什麼時候用、ConfigureAwait(false) 加在哪裡、fire-and-forget 可不可以允許。
- 明明是 I/O 等待,卻用
Task.Run包起來 - 明明是彼此獨立的處理,卻一件一件依序
await - 隨手放進
fire-and-forget,結果看不到例外,也抓不到結束的時機 - 不分場合,到處都加上
ConfigureAwait(false) - 只憑「看起來比較輕」就選了
ValueTask
這一塊與其一條一條硬記,不如 先分辨處理的種類,從這裡切入比較不會迷惘。
本文主要以 .NET 6 之後一般的 C# / .NET 應用程式開發 為前提,把 async / await 周邊的寫法,按照 容易判斷的順序 排出來。
設想的開發型態,例如下面幾種。
- WinForms / WPF 等桌面應用程式
- ASP.NET Core 的 Web 應用程式 / API
- worker / 背景服務
- 主控台應用程式
- 可重複使用的類別庫
另外,本文出現的程式碼,已經以可建置、可執行的完整範例(函式庫、主控台示範、驗證判斷表各模式的單元測試)發布在 GitHub 上。
csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)
本文的閱讀方式
文章有點長,先按目的放上各自的入口。
| 目的 | 要讀的地方 |
|---|---|
| 只想看判斷表 | 3.1 的表與圖。本文的重心就在這裡 |
| 想知道各模式怎麼寫 | 3.2 以後。與 3.1 表格的各列一對一對應 |
| 想重新檢視自己的程式碼 | 5. 的反模式表 |
| 想統一審查的觀點 | 6. 的檢查清單 |
| 總之只要結論 | 1. |
目錄
- 先講結論(一句話)
- 本文使用的詞彙
- 2.1. 首先要分清楚的詞
- 2.2. 經常出現的詞
- 先看的判斷表
- 3.1. 整體樣貌
- 3.2. I/O 等待就直接 await async API
- 3.3. CPU 負荷重時,要挑 Task.Run 的使用位置
- 3.4. 多個獨立的處理就用 Task.WhenAll
- 3.5. 只取最先完成的就用 Task.WhenAny
- 3.6. 件數多又想限制平行數,就用 Parallel.ForEachAsync 或 SemaphoreSlim
- 3.7. 想依序流過就用 Channel<T>
- 3.8. 想固定間隔執行就用 PeriodicTimer
- 3.9. 逐筆送達的資料就用 IAsyncEnumerable<T>
- 3.10. 想以非同步方式處置就用 await using
- 3.11. 跨越 await 的互斥就用 SemaphoreSlim
- 3.12. UI / 應用程式程式碼 / 函式庫要分開寫 await
- 寫法的基本規則
- 4.1. 傳回值先選 Task / Task<T>
- 4.2. async void 只用於事件處理常式
- 4.3. 接下 CancellationToken 並往下游傳
- 4.4. 非同步 API 要一路非同步接到底
- 4.5. 用 LINQ 建立工作時要以 ToArray / ToList 定案
- 常見的反模式
- 審查時的檢查清單
- 大致的取捨
- 總結
- 參考資料
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 26 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
1. 先講結論(一句話)
async/await是 為了在等待期間不佔住執行緒的寫法,不是什麼都自動加速,也不是會自己換到別條執行緒的機制- 先分清楚那個處理是 I/O 等待 還是 CPU 計算
- 如果是 I/O 等待,基本做法是 直接 await async API
- 如果是 CPU 計算,就想清楚 這段計算應該在哪裡執行。在 UI 上
Task.Run有時派得上用場,但在 ASP.NET Core 的請求處理裡,基本上要避免寫成Task.Run之後立刻 await - 彼此獨立的多個處理,比起依序 await,先考慮
Task.WhenAll - 件數多的時候,不要用
Task.WhenAll全部同時送出,而要決定 平行數的上限 fire-and-forget看起來簡單,管理起來卻很難。若真的要把生命週期和呼叫端拆開,移到 Channel 或 HostedService 這類受管理的位置 會比較穩定- 傳回值 先選
Task/Task<T>。ValueTask要先量測,看出必要性之後再選 ConfigureAwait(false)在 通用函式庫程式碼 裡很有力,但在 UI 或應用程式端的程式碼,先用一般的await就好async void除了事件處理常式以外都不要用
總之,async / await 這一塊最重要的,就是不要變成 「先丟 Task.Run 再說」「先 fire-and-forget 再說」「先用 ValueTask 再說」。
先問自己:
- 這個處理在等什麼
- 誰持有這個處理的生命週期
- 同時執行數要在哪裡控制
看清楚這三點,猶豫就會少掉很多。
flowchart TB
accTitle: 減少猶豫的三個問題
accDescr: 依序看清楚這個處理在等什麼、誰持有這個處理的生命週期、同時執行數要在哪裡控制這三點,就能減少 async/await 周邊寫法上的猶豫的圖。
q1["在等什麼"] --> q2["誰持有生命週期"]
q2 --> q3["同時執行數要在哪裡控制"]
q3 --> less["寫法上的猶豫大幅減少"]
q1 -.-> avoid["避免先丟 Task.Run 再說"]
圖 1: 不要「先做再說」,先看等待的種類、生命週期、同時執行數這三點。
2. 本文使用的詞彙
2.1. 首先要分清楚的詞
先把這兩個分開,之後就不容易混淆。
| 詞彙 | 本文的意思 |
|---|---|
| I/O-bound | HTTP、資料庫、檔案、通訊端等,以 等待外部完成 為主的處理 |
| CPU-bound | 壓縮、影像處理、雜湊計算、沉重的轉換等,以 CPU 計算本身 為主的處理 |
async / await 特別派得上用場的是 I/O 等待這一邊,等待期間可以把執行緒還給其他工作。另一邊的 CPU 計算不是「等待」,而是實際在計算的時間,所以主題會變成 要在哪條執行緒上執行 以及 平行數怎麼決定。
flowchart TB
accTitle: I/O-bound 與 CPU-bound 的差別
accDescr: 以等待外部完成為主的 I/O-bound 在 await 等待期間可以把執行緒還給其他工作,而以計算本身為主的 CPU-bound 主題則是要在哪條執行緒上執行以及平行數怎麼決定的區別的圖。
io["I/O-bound(等待外部完成)"] --> e1["等待期間可以把執行緒還出去"]
e1 --> fit["async/await 特別派得上用場"]
cpu["CPU-bound(計算本身)"] --> e2["主題是要在哪條執行緒上執行"]
e2 --> par["平行數怎麼決定也是主題"]
圖 2: 最先要分開的就是這兩個。以等待為主還是以計算為主,要想的主題就不一樣。
2.2. 經常出現的詞
| 詞彙 | 本文的意思 |
|---|---|
| 阻塞(blocking) | 在等待完成的期間,持續佔用那條執行緒 |
fire-and-forget |
呼叫端不等待完成的啟動方式 |
SynchronizationContext |
掌握「await 之後的接續要在哪裡執行」的機制。詳情參閱下方的補充 |
| backpressure | 灌入太快時,讓寫入端等待以避免累積過多的機制 |
IHostedService |
.NET 的 Generic Host 會在啟動時呼叫 StartAsync、停止時呼叫 StopAsync 的機制。它是配合應用程式生命週期常駐執行的處理的入口 |
BackgroundService |
實作 IHostedService 的抽象類別。只要 override 一個 ExecuteAsync(CancellationToken) 就能寫出常駐迴圈。用 AddHostedService<T>() 註冊(3.7) |
使用 Channel<T> 時,消費端(consumer)要放的位置就是這個 BackgroundService。3.7 會談「排入佇列,由專用的 consumer 依序處理」這種形式,而把那個 consumer 的生命週期配合應用程式的啟動與停止一起管理的,就是這裡。
SynchronizationContext 的補充
ConfigureAwait(false) 的討論(3.12),最後都收攏成對這一個詞的理解。
await在執行後續的程式碼(continuation)時,會 捕捉進入等待當下的SynchronizationContext,並回到那裡執行(若沒有設定SynchronizationContext,就會看是不是使用了預設以外的TaskScheduler)- WinForms / WPF 具有會把處理丟回 UI 執行緒的
SynchronizationContext,所以在await之後可以照常操作控制項 - ASP.NET Core 沒有
SynchronizationContext。因此沒有「要回去的地方」,await的後續會直接在空閒的執行緒集區執行緒上執行 ConfigureAwait(false)就是指定「不必回到捕捉到的這個內容,直接執行後續即可」
flowchart TB
accTitle: await 的接續回到哪裡的差別
accDescr: await 會捕捉進入等待當下的 SynchronizationContext 並把接續送回那裡,因此 WinForms 與 WPF 會回到 UI 執行緒而能操作控制項,但 ASP.NET Core 沒有可回去的地方,接續直接在執行緒集區上執行的圖。
aw["await 捕捉內容"] --> ui["WinForms 或 WPF 的 UI 執行緒"]
aw --> asp["ASP.NET Core 沒有可回去的地方"]
ui --> touch["await 之後可以操作 UI"]
asp --> pool["接續在執行緒集區上執行"]
圖 3: ConfigureAwait(false) 的討論,收攏成 await 的接續回到哪裡這一點。
由此就導出 3.12 的結論:「在 UI 程式碼裡不加比較自然」「ASP.NET Core 的應用程式程式碼加不加差別不大」「不知道會被誰驅動的通用函式庫,加了有價值」。詳細背景以 9. 參考資料的 ConfigureAwait FAQ 整理得最完整。
特別重要的一點是,非同步和平行是兩回事。
- 非同步:講的是怎麼等
- 平行:講的是同時推進
這兩者一混在一起,就會到處都想用 Task.Run。
這裡是第一個分歧點。
flowchart TB
accTitle: 非同步和平行是兩回事
accDescr: 非同步講的是怎麼等,平行講的是同時推進,這兩者一混在一起就會到處都想用 Task.Run,因此這裡是第一個分歧點的圖。
a["非同步(怎麼等)"] -.-> mix["混在一起就會多用 Task.Run"]
p["平行(同時推進)"] -.-> mix
mix --> fork["這裡是第一個分歧點"]
圖 4: 非同步講的是怎麼等,平行講的是同時推進。這個區別一崩掉就容易濫用 Task.Run。
3. 先看的判斷表
3.1. 整體樣貌
先從這張表看起,大方向大致就定下來了。
| 情境 | 先用的東西 | 要看的重點 |
|---|---|---|
| HTTP / 資料庫 / 檔案等的等待 | 直接 await async API |
不要用 Task.Run 包起來 |
| 不想讓 UI 停住的沉重計算 | Task.Run |
把 CPU 計算移出 UI 執行緒 |
| ASP.NET Core 的請求處理 | plain await |
不要 Task.Run 之後立刻 await |
| 少數彼此獨立的非同步處理 | Task.WhenAll |
先全部啟動,最後一起等 |
| 只用最先完成的那一個 | Task.WhenAny |
要想好其餘工作的取消與例外回收 |
| 件數多,想加上上限 | Parallel.ForEachAsync / SemaphoreSlim |
明確寫出平行數 |
| 想依序流過的背景處理 | Channel<T> |
要想好有界佇列與 backpressure |
| 固定間隔的非同步處理 | PeriodicTimer |
守住一個計時器對一個 consumer |
| 想一點一點處理結果 | IAsyncEnumerable<T> / await foreach |
不必等全部完成就能往下走 |
| 需要非同步處置 | await using |
使用 IAsyncDisposable |
| 跨越 await 的互斥 | SemaphoreSlim.WaitAsync |
用 try/finally 保證 Release |
| 通用函式庫程式碼 | 考慮 ConfigureAwait(false) |
不依賴 UI 或應用程式特有的內容 |
flowchart TD
start["想做的處理"] --> q1{"要等外部 I/O 嗎?"}
q1 -- "是" --> p1["直接 await async API"]
q1 -- "否" --> q2{"CPU 計算很重嗎?"}
q2 -- "是" --> q3{"要在哪裡執行?"}
q3 -- "UI 事件 / 桌面" --> p2["考慮 Task.Run"]
q3 -- "ASP.NET Core 的請求" --> p3["不要用 Task.Run 包起來<br/>必要時交給其他 worker 或佇列"]
q3 -- "worker / 背景" --> p4["就地執行<br/>或明確寫出平行度"]
q2 -- "否" --> q4{"要處理多件工作嗎?"}
q4 -- "等到全部完成" --> p5["Task.WhenAll"]
q4 -- "使用最先完成的" --> p6["Task.WhenAny"]
q4 -- "件數很多" --> p7["Parallel.ForEachAsync<br/>或 SemaphoreSlim"]
q4 -- "依序流過" --> p8["Channel<T>"]
q4 -- "固定間隔" --> p9["PeriodicTimer"]
q4 -- "逐筆串流" --> p10["IAsyncEnumerable<T>"]
圖 5: 判斷的整體樣貌。先分開 I/O 等待與 CPU 計算,多件工作則依匯總方式挑工具。
以下依序看各個模式。
3.2. I/O 等待就直接 await async API
這是最基本的模式。
例如 HTTP、資料庫、檔案讀寫等,先看 有沒有 async 版的 API。
有的話,直接 await 它就是基本做法。
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
這時要避免的,是把已經是 async 的 I/O 用 Task.Run 包起來。
// 不好的例子
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}
這只是把 I/O 等待重新丟到另一條執行緒,程式碼更難梳理,卻得不到好處。
- 是 I/O 等待就不需要
Task.Run - 先找 async 版的 API
- 接下 token 之後,就原樣往下游傳
這裡是相當正統的做法。
flowchart TB
accTitle: I/O 等待的基本形式
accDescr: HTTP、資料庫或檔案的等待要先找 async 版的 API 並直接 await,而把已經是 async 的 I/O 用 Task.Run 包起來只是重新丟到另一條執行緒,得不到好處的圖。
need["HTTP、資料庫、檔案的等待"] --> find["先找 async 版的 API"]
find --> aw["直接 await"]
wrap["用 Task.Run 包起來"] -.-> bad["只是重新丟一次,沒有好處"]
圖 6: I/O 等待的基本是「直接 await async API」。要避免用 Task.Run 包起來。
3.3. CPU 負荷重時,要挑 Task.Run 的使用位置
Task.Run 派得上用場的,是 想把 CPU 計算移出目前執行緒的時候。
例如在 UI 事件處理常式裡直接跑沉重的計算,畫面就會停住。
這種時候用 Task.Run 最直接了當。
flowchart TB
accTitle: Task.Run 在 UI 上怎麼發揮作用
accDescr: 在 UI 事件處理常式裡直接跑沉重的計算畫面會停住,因此用 Task.Run 把 CPU 計算移出 UI 執行緒就能保住畫面的回應,說明 Task.Run 發揮作用的典型場面的圖。
heavy["在 UI 事件裡跑沉重的計算"] -.-> freeze["畫面停住"]
run["用 Task.Run 移出 UI 執行緒"] --> keep["畫面持續有回應"]
圖 7: Task.Run 發揮作用的前提,是有一條必須空出來的特別執行緒(UI 執行緒)。
public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] current = data;
for (int i = 0; i < repeat; i++)
{
cancellationToken.ThrowIfCancellationRequested();
current = sha256.ComputeHash(current);
}
return current;
}, cancellationToken);
}
不過這裡重要的是 在哪裡呼叫。
- WinForms / WPF 等 UI:有
Task.Run有效的場面 - ASP.NET Core 的請求處理:基本上要避免寫成
Task.Run之後立刻await - worker / 背景處理:就地處理,或是把平行度設計出來
在 ASP.NET Core 的請求處理裡夾一層 Task.Run 之後立刻 await,往往只會增加多餘的排程。
這裡很容易誤解,所以把理由分開寫。並不是「因為跑在執行緒集區上,所以 Task.Run 沒有意義」(跑在執行緒集區上這件事本身,UI 應用程式的背景處理也一樣)。要點有下面兩個。
- 吞吐量(throughput)不會增加。 CPU 計算的總量沒有變,只是執行的位置換到另一條執行緒集區的執行緒。能同時處理的請求數並不會變多
- 也不算是把等待釋放出來。
Task.Run在 UI 應用程式裡有效,是因為 有一條必須空出來的特別執行緒(UI 執行緒)。伺服器端沒有那一條。原本的執行緒確實被釋放了,但換成另一條執行緒被同樣長的計算時間佔滿,相抵之後等於零
剩下的只有排入佇列與執行緒切換的成本,以及「現在跑在哪條執行緒上」多繞一層而變得比較難看清楚。所以要避免。
flowchart TB
accTitle: 在 ASP.NET Core 避免 Task.Run 的理由
accDescr: 在請求處理裡夾一層 Task.Run 計算的總量並沒有變,也沒有像 UI 那樣必須空出來的特別執行緒,相抵之後等於零,剩下的只有執行緒切換的成本與可讀性下降的圖。
tr["在請求處理裡夾一層 Task.Run"] --> r1["計算的總量沒有變"]
tr --> r2["沒有必須空出來的那一條"]
r1 --> zero["相抵之後等於零"]
r2 --> zero
zero --> cost["剩下切換成本與難讀"]
圖 8: 在伺服器端 Task.Run 之後立刻 await,既拿不到吞吐量,也釋放不了等待。
所以在 ASP.NET Core 裡,這樣想方向比較對。
- I/O 等待就用 plain
await - 短的 CPU 處理就就地執行
- 長時間的處理,或想脫離請求生命週期的處理,就交給佇列或 HostedService
另外,從 UI 呼叫 只有 sync 版的 API 時,確實會為了 UI 回應性而使用 Task.Run。
不過這不是「非同步 I/O」,只是 佔用一條執行緒來迴避問題。
在 ASP.NET Core 這類伺服器端,這種閃避方式基本上很難擴展。
3.4. 多個獨立的處理就用 Task.WhenAll
明明有多個彼此獨立的非同步處理,卻像這樣一件一件等的程式碼很常見。
// 明明彼此獨立卻寫成依序執行的例子
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);
如果它們彼此不相依,先全部啟動,最後一起等 比較直接了當。
public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
Task<string>[] tasks = urls
.Select(url => _httpClient.GetStringAsync(url, cancellationToken))
.ToArray();
return await Task.WhenAll(tasks);
}
重點是 ToArray()。
LINQ 是延遲執行,所以只做了 Select,可能還沒有真的被列舉。
先用 ToArray() 或 ToList() 定案,所有工作就會在那個時間點開始。
sequenceDiagram
participant Caller as 呼叫端
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: 啟動
Caller->>T2: 啟動
Caller->>T3: 啟動
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: 完成
T2-->>Caller: 完成
T3-->>Caller: 完成
圖 9: 彼此獨立的工作要先全部啟動,再用 Task.WhenAll 一起等它們完成。
這個模式適合的是,
- 件數少,或中等
- 想一起等全部完成
- 不設上限同時跑也沒問題
這樣的情況。
件數多的話,像接下來的 3.6 那樣加上 平行數的上限 比較安全。
3.5. 只取最先完成的就用 Task.WhenAny
例如想在多個鏡像來源中使用最先回應的那一個,這種場面用 Task.WhenAny 很好懂。
public async Task<byte[]> DownloadFromFirstMirrorAsync(
IReadOnlyList<string> urls,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
List<Task<byte[]>> pending = urls
.Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
.ToList();
var failures = new List<Exception>();
try
{
while (pending.Count > 0)
{
Task<byte[]> finished = await Task.WhenAny(pending);
pending.Remove(finished);
try
{
byte[] data = await finished; // 只有成功時才會從這裡離開
cts.Cancel(); // 勝者確定之後才停掉其餘的
return data;
}
catch (Exception ex)
{
// 如果是呼叫端喊停,那就不算「鏡像失敗」。
// 讓它就這樣通過的話,所有工作的取消都會被當成失敗累積起來,
// 最後變成 AggregateException,就分不出和真正的故障有什麼差別
cancellationToken.ThrowIfCancellationRequested();
// 這個鏡像不行。其餘的還有希望,所以繼續
failures.Add(ex);
}
}
}
finally
{
cts.Cancel(); // 因例外離開時,也要停掉還在進行的下載
try
{
await Task.WhenAll(pending);
}
catch
{
// 回收勝者以外的取消與失敗
}
}
throw new AggregateException("從所有鏡像取得資料都失敗了。", failures);
}
這段程式碼裡順序有意義的地方,是 取消是在「勝者確定之後」才發出。
Task.WhenAny傳回的是 最先完成的工作,不是最先成功的工作。最快的鏡像因為 404 或連線中斷而失敗時,它一樣會以「勝者」的身分傳回來- 如果在這裡還沒看結果就先取消,就會變成 自己把還活著的其餘鏡像停掉,然後再把失敗勝者的例外重新拋出。這是最糟糕的一種壞掉方式:準備多個鏡像的意義會整個被抹掉
- 所以要把完成的工作一個一個取出來
await,只有成功時才取消其餘的。失敗的話就把那個工作從候補中移除,等下一個完成 Cancel()只是送出要求,不會等對方真的停下來。所以要在finally等其餘的工作,把取消與失敗的例外在這裡觀測掉。省略這一段,工作那邊就會留下沒人看過的例外- 全部都失敗時,把個別的失敗一起拋出。只拋第一個例外的話,「哪個鏡像怎麼壞的」就消失了
- 只有呼叫端的取消不算失敗,直接往外拋。
cancellationToken一被觸發,所有工作都會以OperationCanceledException結束,把這些堆進failures,最後就會變成AggregateException,使用者的中斷或逾時會被記錄成「所有鏡像故障」並被重試。在catch的開頭呼叫ThrowIfCancellationRequested(),讓取消維持OperationCanceledException往外傳
這裡要注意的是,WhenAny 只會傳回一個勝者。
其餘的處理如果什麼都不做,就會繼續跑下去。
所以,
- 其餘的要不要取消
- 例外要不要先觀測起來
這兩點必須先決定。
Task.WhenAny 很方便,但要設計的東西比 WhenAll 多一些。
只在「只要最先的那一個就夠」的情況才選它,比較好懂。
flowchart TB
accTitle: 用 WhenAny 確認勝者之後才停掉其餘工作的流程
accDescr: Task.WhenAny 傳回的是最先完成的工作而不是最先成功的工作,因此要一個一個 await 完成的工作,只有成功時才取消其餘的,失敗就從候補中移除並等下一個完成,全部失敗則把失敗一起拋出的流程的圖。
any["用 WhenAny 取得最先完成的"] --> chk{"那個工作成功了嗎"}
chk -->|"成功"| win["取消其餘的並傳回"]
chk -->|"失敗"| next["從候補中移除並記錄失敗"]
next --> rest{"還有候補嗎"}
rest -->|"有"| any
rest -->|"沒有"| agg["把失敗一起拋出"]
圖 10: 「最先完成」不等於「最先成功」。確認勝者的結果之後才停掉其餘的。
3.6. 件數多又想限制平行數,就用 Parallel.ForEachAsync 或 SemaphoreSlim
Task.WhenAll 會讓建立出來的工作全部同時跑。
所以對象件數一多,HTTP 連線、資料庫連線、記憶體用量、外部服務的負荷就會一口氣暴增。
這種時候,先決定 同時最多跑幾件 會比較穩定。
Parallel.ForEachAsync 把這個意圖寫得相當好讀。
public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
urls.Select((url, index) => (url, index)),
options,
async (item, token) =>
{
string html = await _httpClient.GetStringAsync(item.url, token);
string path = Path.Combine("cache", $"{item.index}.html");
await File.WriteAllTextAsync(path, html, token);
});
}
這個模式適合的是,
- 件數很多
- 每一項的處理彼此獨立
- 但想避免全部一次上
這樣的情況。
另一方面,想更自由地控制的話,也有使用 SemaphoreSlim 的做法。
例如「特定的外部 API 同時最多 4 件」這種控制。
也就是說,
- 幾件的話用
Task.WhenAll - 大量件數就用
Parallel.ForEachAsync或SemaphoreSlim
這樣取捨,大方向不會偏得太離譜。
flowchart TB
accTitle: 依件數選擇平行的匯總方式
accDescr: 幾件的獨立處理可以用 Task.WhenAll 全部同時跑,但件數一多連線、記憶體與外部負荷都會一口氣暴增,因此要用 Parallel.ForEachAsync 或 SemaphoreSlim 決定同時執行數上限的圖。
q{"對象的件數多不多"}
q -->|"幾件左右"| all["用 Task.WhenAll 一次全上"]
q -->|"很多"| limit["決定平行數的上限"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["用 SemaphoreSlim 自由控制"]
圖 11: 分界點在於能不能一次全部送出。件數多就要明確寫出同時執行數。
3.7. 想依序流過就用 Channel<T>
有時會想把「不必馬上做完,但一定要處理掉」的工作,從呼叫端拆出來。 例如寄送郵件、日誌轉送、Webhook 的後續處理、檔案轉換等。
這種時候把 Task.Run 丟出去就不管,
- 例外要在哪裡看
- 結束時要不要等
- 件數增加時要接到什麼程度
這些都會變得曖昧。
這類工作 排入佇列,由專用的 consumer 依序處理 比較好管理。
flowchart LR
p["producer"] --> w["WriteAsync"]
w --> q{"佇列還有空位嗎?"}
q -- "是" --> c["進入 Channel"]
q -- "否" --> b["等到有空位為止"]
c --> d["consumer 執行 ReadAsync"]
d --> e["依序 await 並處理"]
圖 12: 有界 Channel 的流程。佇列滿了就讓寫入端等待,backpressure 就是這樣產生的。
Channel<T> 可以把 producer / consumer 的形式寫得相當直接了當。
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
public ValueTask EnqueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workItem);
return _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
=> _queue.Reader.ReadAsync(cancellationToken);
}
這個例子裡的 BoundedChannelFullMode.Wait,是 佇列滿了就讓寫入端等待 的設定。
這就是 backpressure。
在 ASP.NET Core 裡,把這種佇列和 BackgroundService 組合起來消費的形式很好懂。
比起「真正的 fire-and-forget」,這一邊在例外、停止、平行數、上限的處理上都容易得多。
flowchart TB
accTitle: 丟出去不管與佇列管理的對比
accDescr: 用 Task.Run 丟出去不管會讓例外、結束與可接受件數都變得曖昧,而排進 Channel 由 BackgroundService 的 consumer 依序處理,就能好好處理例外、停止、平行數與上限的圖。
ff["用純 Task.Run 丟出去不管"] -.-> vague["例外、結束、上限都曖昧"]
ch["排進 Channel 的佇列"] --> bs["BackgroundService 負責消費"]
bs --> mng["例外、停止、平行數都管得住"]
圖 13: 要把生命週期和呼叫端拆開,就不要 fire-and-forget,而是交到受管理的位置。
3.8. 想固定間隔執行就用 PeriodicTimer
固定間隔的非同步處理,PeriodicTimer 相當好讀。
public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(cancellationToken);
}
}
這種寫法的好處是,
- 比回呼型的 Timer 更容易追流程
- 可以用
await為基礎來寫 - 停止時可以直接了當地使用
CancellationToken
這幾點。
要注意的是,PeriodicTimer 的使用前提是 同一個計時器不會同時送出多個 WaitForNextTickAsync。
另外,如果處理時間比週期長,那個延遲必須當成設計問題來處理。
計時器不會自己平行化來追上進度。
flowchart TB
accTitle: PeriodicTimer 的週期迴圈
accDescr: 用 WaitForNextTickAsync 等下一個週期,以 await 執行處理之後再回到等待的迴圈,停止交給 CancellationToken,而處理比週期長時的延遲必須當成設計問題處理的圖。
tick["用 WaitForNextTickAsync 等待"] --> proc["以 await 執行處理"]
proc --> tick
stop["用 CancellationToken 停止"] -.-> tick
proc -.-> warn["比週期長的處理造成的延遲要在設計上處理"]
圖 14: 一個計時器對一個 consumer。計時器不會自己平行化來追上進度。
3.9. 逐筆送達的資料就用 IAsyncEnumerable<T>
比起把全部資料存進 List<T> 之後再傳回,有些場面更想 從送達的開始依序處理。
- 依序讀取分頁的 API
- 一點一點讀取檔案的每一行
- 把串流結果直接流出去
這種時候用 IAsyncEnumerable<T> 和 await foreach 很自然。
public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
{
await ProcessUserAsync(user, cancellationToken);
}
}
這種形式適合的是,
- 不想等到全部到齊
- 想一件一件處理
- 不想把全部資料堆在記憶體裡
這樣的情況。
傳回值要用 Task<List<T>> 還是 IAsyncEnumerable<T>,
用 結果是要全部到齊之後才用,還是照送達的順序使用 來決定就很清楚。
flowchart TB
accTitle: 依結果的用法決定傳回值的形式
accDescr: 結果要全部到齊之後才用就用 Task 傳回整份清單,要照送達的順序一筆一筆用就傳回 IAsyncEnumerable 並以 await foreach 處理的決定方式的圖。
q{"結果要怎麼用"}
q -->|"全部到齊之後"| list["用 Task 傳回整份清單"]
q -->|"照送達的順序"| ae["用 IAsyncEnumerable 流出"]
ae --> each["用 await foreach 一筆一筆處理"]
ae -.-> mem["不把全部資料堆在記憶體裡"]
圖 15: 要全部到齊,還是照送達的順序流出。用法決定傳回值的型別。
3.10. 想以非同步方式處置就用 await using
處置時需要排清(flush)或結束通訊等非同步處理的型別,會實作 IAsyncDisposable。
這種情況要用 await using 而不是 using。
public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await stream.WriteAsync(data, cancellationToken);
}
重點是,
- 是
IAsyncDisposable就用await using - 「開啟」是同步,「關閉」是非同步的情況很常見
這兩點。
想避免「寫入都做成 async 了,只有最後的處置是同步」這種對不上的情況時,它就派得上用場。
3.11. 跨越 await 的互斥就用 SemaphoreSlim
在跨越 await 的程式碼裡,有些場面要用 SemaphoreSlim 取代 lock。
public sealed class CacheRefresher
{
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
await RefreshCoreAsync(cancellationToken);
}
finally
{
_gate.Release();
}
}
private static Task RefreshCoreAsync(CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
重要的是,
- 用
WaitAsync進入 Release一定要在finally呼叫
這兩點。
在「同時只想放進一件」「外部 API 呼叫同時最多 3 件」
這樣的場面,SemaphoreSlim 相當實用。
flowchart TB
accTitle: 跨越 await 的互斥形式
accDescr: 在跨越 await 的程式碼裡要用 SemaphoreSlim 取代 lock,以 WaitAsync 進入並執行含 await 的處理,再在 finally 一定要呼叫 Release,這兩點很重要的圖。
lk["lock 無法跨越 await"] -.-> alt["改用 SemaphoreSlim"]
wait["用 WaitAsync 進入"] --> crit["執行含 await 的處理"]
crit --> rel["在 finally 一定要 Release"]
圖 16: 入口是 WaitAsync,出口是 finally 裡的 Release。不讓這一對拆開才是重點。
3.12. UI / 應用程式程式碼 / 函式庫要分開寫 await
ConfigureAwait(false) 並不是隨時加上就好的東西。
大致的分法如下。
flowchart LR
a["UI / 應用程式程式碼"] --> b["await someAsync()"]
b --> c["回到原本的內容繼續執行"]
d["通用函式庫"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["不預設會回到特定的內容"]
圖 17: 應用程式端的程式碼用 plain await 回到原本的內容,通用函式庫則考慮 ConfigureAwait(false)。
- UI / 應用程式程式碼
- 先用一般的
await就好 - 如果
await之後要更新 UI,或要做依賴應用程式端內容的處理,不加ConfigureAwait(false)比較自然
- 先用一般的
- ASP.NET Core 的應用程式程式碼
- 通常用一般的
await就夠 - 不必把
ConfigureAwait(false)當成整體的規矩硬性貫徹
- 通常用一般的
- 通用函式庫程式碼
- 只要不依賴 UI 或應用程式模型,
ConfigureAwait(false)就很有力
- 只要不依賴 UI 或應用程式模型,
也就是說,
- 應用程式端的程式碼用 plain
await - 通用函式庫則考慮
ConfigureAwait(false)
記住這一點,實務上大致不會遇到困擾。
4. 寫法的基本規則
4.1. 傳回值先選 Task / Task<T>
async 方法的傳回值,先照這個順序考慮。
| 傳回值 | 起手的想法 |
|---|---|
Task |
沒有傳回值的 async 方法的基本選擇 |
Task<T> |
要傳回值的 async 方法的基本選擇 |
ValueTask / ValueTask<T> |
先量測,看出必要性之後再選 |
ValueTask 看起來很方便,但 並非永遠比 Task 好。
它是結構,所以有複製成本,用法上也有限制。
特別重要的是,ValueTask 基本上是以只 await 一次為前提。
它不適合隨手存進區域變數再等好幾次的寫法。
所以在日常的應用程式程式碼裡,先用 Task / Task<T> 就夠。
另外,方法名稱加上 Async 後綴 比較好懂。
public Task SaveAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<int> CountAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_count);
}
像上面那樣,如果沒有要 await 的處理,就不必硬加 async,直接傳回 Task.CompletedTask 或 Task.FromResult 比較直接了當。
4.2. async void 只用於事件處理常式
async void 基本上要避免用在事件處理常式以外的地方。
理由很單純,
- 呼叫端無法 await
- 等不到完成
- 例外處理變得困難
- 不容易測試
就是這幾點。
只有事件處理常式因為必須是 void,才在那裡使用。
private async void SaveButton_Click(object? sender, EventArgs e)
{
try
{
await SaveAsync(_saveCancellation.Token);
_statusLabel.Text = "已儲存。";
}
catch (OperationCanceledException)
{
_statusLabel.Text = "已取消。";
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "儲存錯誤");
}
}
在事件處理常式裡,攔下例外並回報到 UI 端 這一段必須自己寫完;心裡先有這個認知很重要。
flowchart TB
accTitle: 避免 async void 的理由與唯一的例外
accDescr: async void 會讓呼叫端無法 await、等不到完成、例外處理與測試都變困難,因此一般的方法要避免,只在簽章上必須是 void 的事件處理常式裡使用,並在其中用 try/catch 把例外回報到 UI 端的圖。
av["async void 方法"] --> p1["無法 await"]
av --> p2["等不到完成"]
av --> p3["例外與測試都困難"]
ev["事件處理常式是例外"] -.-> duty["用 try/catch 回報到 UI"]
圖 18: 一般的方法要傳回 Task / Task。允許 async void 的只有事件處理常式。
4.3. 接下 CancellationToken 並往下游傳
只要是可取消的操作,就接下 CancellationToken 並原樣往下游傳。
public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
這裡常見的情況是,上層接下了 token,卻沒有往下游傳。 這樣很容易寫出「看起來可以取消,中途卻停不下來」的程式碼。
另外,逾時 也會因為是「只想給等待加上限」還是「連實際處理本身也要停掉」而意義不同。
- 只想給等待加上限:
WaitAsync - 連實際處理本身也要停掉:
CancellationTokenSource.CancelAfter加上 token 的傳遞
這個差異之後很容易變成缺陷,一開始就決定好會比較穩定。
flowchart TB
accTitle: CancellationToken 的傳遞
accDescr: 把上層接下的 CancellationToken 原樣傳給下游的 API 就能在中途停下來,只接下而不往下傳則會寫出看起來可以取消,中途卻停不下來的程式碼的圖。
up["在上層接下 token"] --> pass["原樣傳給下游的 API"]
pass --> stop["中途也能確實停下來"]
nopass["只接下而不往下傳"] -.-> fake["看起來會停,其實停不下來"]
圖 19: token 接下之後就要一路傳到底。漏傳會生出「停不下來的取消」。
4.4. 非同步 API 要一路非同步接到底
既然要用 async / await,盡量 一路非同步接到最後 比較直接了當。
替換的大致參考如下。
| 容易想這樣寫 | 換成 |
|---|---|
Task.Result / Task.Wait() |
await |
Task.WaitAll() |
await Task.WhenAll(...) |
Task.WaitAny() |
await Task.WhenAny(...) |
Thread.Sleep(...) |
await Task.Delay(...) |
特別是在 UI 或 ASP.NET Core 裡,一旦混入 同步等待的寫法,卡住的方式就會變得很難看清楚。
現在的 C# 也能用 async Task Main(),所以就算是主控台應用程式,硬要同步化的理由也少了很多。
flowchart TB
accTitle: 不混入同步等待的替換
accDescr: 既然要用 async/await 就一路非同步接到最後,Result 與 Wait 換成 await,Thread.Sleep 換成 Task.Delay,混入同步等待的寫法會讓卡住的方式難以看清楚的圖。
chain["一路非同步接到最後"] --> r1["Result 與 Wait 換成 await"]
chain --> r2["Thread.Sleep 換成 Task.Delay"]
mix["混入同步的等待"] -.-> clog["卡住的方式難以看清楚"]
圖 20: 一旦決定用 async,就不要中途落回同步等待,一路非同步接到最後。
4.5. 用 LINQ 建立工作時要以 ToArray / ToList 定案
把 Task.WhenAll 或 Task.WhenAny 和 LINQ 組合起來時,
先用 ToArray() 或 ToList() 定案比較安全。
Task<User>[] tasks = userIds
.Select(id => _userRepository.GetAsync(id, cancellationToken))
.ToArray();
User[] users = await Task.WhenAll(tasks);
理由是 LINQ 為延遲執行。 讀的時候以為「全部都已經開始了」,實際上卻還沒被列舉,這種狀況不起眼卻很危險。
- 要一起等全部完成就用
ToArray() - 中途想刪除或抽換就用
ToList()
這樣記就容易取捨。
5. 常見的反模式
| 反模式 | 難處在哪 | 先換成 |
|---|---|---|
Task.Run(async () => await IoAsync()) |
白白把 I/O 等待重新丟一次 | await IoAsync() |
Task.Result / Wait() |
佔住執行緒,容易卡住 | await |
在 async 流程裡混入 Thread.Sleep() |
等待期間也佔用執行緒 | Task.Delay() |
在一般方法上使用 async void |
等不到,例外也難管理 | Task / Task<T> |
該用 Task.WhenAll 的場面卻依序 await |
沒必要地變慢 | 先全部啟動再 WhenAll |
用 WhenAll 一口氣送出大量件數 |
負荷暴增 | Parallel.ForEachAsync / SemaphoreSlim |
想用 lock 跨越 await |
不符合用途 | SemaphoreSlim.WaitAsync |
用純 Task.Run 打發 fire-and-forget |
例外、停止、上限的管理都很曖昧 | Channel<T> / BackgroundService |
機械式地在 UI 程式碼加上 ConfigureAwait(false) |
await 之後的 UI 更新容易壞掉 | plain await |
把 ValueTask 當成標準 |
相對於複雜度,常常得不到好處 | 先用 Task |
這張表裡,實務上特別常見的是下面三個。
- 明明是 I/O 卻用
Task.Run - 其實彼此獨立卻依序 await
fire-and-forget沒有生命週期管理
光是修好這三個,程式碼就會變得容易看清全貌很多。
flowchart TB
accTitle: 實務上特別常見的三種修法
accDescr: 明明是 I/O 卻用 Task.Run 包起來、其實彼此獨立卻依序 await、fire-and-forget 沒有生命週期管理這三種實務上特別常見的情況,各自換成對應的做法就能改善可讀性的圖。
a1["明明是 I/O 卻用 Task.Run"] --> f1["直接 await async API"]
a2["獨立處理卻依序 await"] --> f2["先啟動再 WhenAll"]
a3["fire-and-forget 放著不管生命週期"] --> f3["交給 Channel 或 BackgroundService"]
f1 --> better["更容易看清全貌"]
f2 --> better
f3 --> better
圖 21: 反模式表裡,先修好這三個最有效。
6. 審查時的檢查清單
在 async / await 相關的程式碼審查中,會由上而下依序檢查這些項目。
- 能不能一開始就用話說清楚,那個處理是 I/O-bound 還是 CPU-bound
- 有沒有殘留
Task.Result/Task.Wait()/Thread.Sleep() - 有沒有把 I/O 等待用
Task.Run包起來 - 有沒有把彼此獨立的處理不必要地依序 await
- 反過來,有沒有把大量件數無上限地丟給
WhenAll - 既然接下了
CancellationToken,有沒有確實往下游傳 - 事件處理常式以外有沒有出現
async void - 既然放進了
fire-and-forget,例外、停止、上限由誰管理有沒有定下來 - 既然用了
SemaphoreSlim,Release有沒有放在finally裡 - 既然用了
ValueTask,有沒有量測上的理由,是不是以只 await 一次為前提 - 加不加
ConfigureAwait(false),和那段程式碼的種類合不合- UI / 應用程式程式碼就用 plain
await - 通用函式庫就考慮
ConfigureAwait(false)
- UI / 應用程式程式碼就用 plain
這份檢查清單也很適合用來統一團隊的審查觀點。
7. 大致的取捨
取捨的清單都收攏在 3.1 的判斷表。與其把同一張表再貼一次,需要時回去那裡查更好找,所以這裡不放表。
- 想依情境查「先用什麼」→ 3.1 的判斷表
- 想看各模式怎麼寫 → 3.2 〜 3.12(與 3.1 表格的各列對應)
有一個判斷沒有進到 3.1 的表裡,就是 傳回值的型別。這不是情境的問題而是方法設計的問題,所以整理在 4.1。只寫結論的話:先選 Task / Task<T>,ValueTask 要先量測,看出必要性之後再說。
8. 總結
async / await 的最佳實踐,與其記住一堆細瑣的技巧,
不如用 依處理的種類選擇形式 這樣的梳理方式,在實務上更管用。
查看的順序大致如下。
- 分清楚是 I/O 等待還是 CPU 計算
- 是 I/O 就直接
awaitasync API - 是 CPU 計算就決定該在哪裡執行
- 多個處理就在
WhenAll/WhenAny/ 平行數限制之間選 - 要脫離請求的生命週期,就改成排入佇列而不是純 fire-and-forget
- 把傳回值、取消、例外、互斥、內容的處理方式統一起來
async / await 的寫法本身很簡潔,隨手亂用反而會讓方針變得看不清楚。反過來說,
- I/O 就當成 I/O 來處理
- CPU 就當成 CPU 來處理
- 背景處理就當成背景處理來管理生命週期
光是把這三者分開,可讀性就會提高很多。
9. 參考資料
- 本文的完整範例程式碼(函式庫、示範、單元測試) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
針對 .NET/C# 整理能防止「建立執行緒後偶爾當機、卡死」的設計定石。內容涵蓋不自行建立執行緒而改用 Task、減少共享可變狀態、鎖的紀律、以 CancellationToken 設計停止機制,以及 UI 執行緒的處理方式。
業務系統的代碼設計 ── 商品代碼・客戶代碼的訂定方式與檢查碼
商品代碼・客戶代碼等業務系統代碼體系的實務指南。有意義代碼與無意義流水號的判斷表、JAN・Luhn等檢查碼算式與C#實作、Excel的0消失對策,乃至位數溢位與遷移一次整理清楚。
如何理解 Windows 的工作階段隔離 ── Session 0・RDP・多使用者同時執行
整理 Windows 應用程式開發者容易混淆的「工作階段」概念。說明服務無法顯示 UI 的 Session 0 隔離原因、RDP 連線時工作階段的行為、具名物件的工作階段隔離,以及共用 PC・RDS 環境常見的設計失誤,從實務角度解說。
Windows 的行程間通訊該怎麼選 ── 具名管道 / TCP / gRPC / 共享記憶體 / COM 判斷表
整理 Windows 應用程式之間該如何選擇溝通方式。以判斷表整理具名管道、本機 TCP、gRPC、共享記憶體、檔案協作、COM 各自的強項與陷阱,並從實務角度說明 UI+服務分離・32bit/64bit 橋接・權限邊界等典型架構,以及具名管道的實作範例。
Windows應用程式資料儲存位置怎麼選 ── SQLite / JSON / 登錄檔 / Access 判斷表
Windows桌面應用程式的資料該存在哪裡、用什麼格式儲存?本文整理AppData/ProgramData的使用區分,以及SQLite、JSON檔案、登錄檔、Access(.accdb)各自的優勢與陷阱,並附判斷表,從實務角度說明防止資料損毀與位元數問題等注意事項。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
UI 執行緒 & 計時器
整理 WPF / WinForms UI 執行緒、非同步流程、Dispatcher 使用、計時器判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
在包含 UI、背景處理與 I/O 的 Windows 應用程式中,async/await 的取捨直接決定實作品質。
技術諮詢 & 設計審查
若想把 Task.Run 與 ConfigureAwait 的判斷連同職責劃分一起梳理,就會延伸到技術諮詢與設計審查。
常見問題
整理諮詢這個主題時常見的問題。
- 在 C# 中什麼時候該用 Task.Run?
- Task.Run 派得上用場的場合,是想把 CPU 計算移出目前的執行緒時。例如在 WinForms / WPF 的 UI 事件處理常式裡直接跑沉重的計算,畫面就會停住,因此用 Task.Run 把它移出 UI 執行緒最直接了當。另一方面,ASP.NET Core 的請求處理本來就在 ThreadPool 上執行,夾一層 Task.Run 之後立刻 await,往往只會增加多餘的排程,所以基本上要避免。長時間的處理,或想脫離請求生命週期的處理,交給佇列或 HostedService 才是方向比較對的做法。
- I/O 處理不可以用 await Task.Run() 包起來嗎?
- HTTP、資料庫、檔案讀寫等 I/O 等待,基本上直接 await async 版的 API 即可,不需要用 Task.Run 包起來。把已經是 async 的 I/O 再用 Task.Run 包一層,只是把 I/O 等待重新丟到另一條執行緒,程式碼更難梳理卻得不到好處。另外,從 UI 呼叫只有 sync 版的 API 時,確實會為了回應性而使用 Task.Run,但這不是非同步 I/O,只是佔用一條執行緒來迴避問題,在伺服器端是很難擴展的閃避方式。
- ConfigureAwait(false) 該加在哪裡?
- 在 UI 或應用程式端的程式碼裡,先用一般的 await 就好。如果 await 之後要更新 UI,或要做依賴應用程式端內容的處理,不加 ConfigureAwait(false) 才自然。ASP.NET Core 的應用程式程式碼通常也用一般的 await 就夠,不必當成規矩硬性貫徹。ConfigureAwait(false) 真正派得上用場的地方,是不依賴 UI 或應用程式模型的通用函式庫程式碼。記住「應用程式端用 plain await,通用函式庫再考慮 ConfigureAwait(false)」,實務上大致不會遇到困擾。
- 為什麼在事件處理常式以外應該避免 async void?
- 因為 async void 讓呼叫端無法 await,等不到完成,例外處理變得困難,也不容易測試。一般的方法基本上要傳回 Task 或 Task<T>。只有事件處理常式因為簽章上必須是 void 才在那裡使用;這時要清楚知道,在處理常式裡用 try/catch 攔下例外並回報到 UI 端這一段,得由自己寫完。