C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait

· 更新日期: · · C#, async/await, .NET, 設計

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

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

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

目錄

  1. 先講結論(一句話)
  2. 本文使用的詞彙
    • 2.1. 首先要分清楚的詞
    • 2.2. 經常出現的詞
  3. 先看的判斷表
    • 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. 寫法的基本規則
    • 4.1. 傳回值先選 Task / Task<T>
    • 4.2. async void 只用於事件處理常式
    • 4.3. 接下 CancellationToken 並往下游傳
    • 4.4. 非同步 API 要一路非同步接到底
    • 4.5. 用 LINQ 建立工作時要以 ToArray / ToList 定案
  5. 常見的反模式
  6. 審查時的檢查清單
  7. 大致的取捨
  8. 總結
  9. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 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 再說」。

先問自己:

  1. 這個處理在等什麼
  2. 誰持有這個處理的生命週期
  3. 同時執行數要在哪裡控制

看清楚這三點,猶豫就會少掉很多。

減少猶豫的三個問題依序看清楚這個處理在等什麼、誰持有這個處理的生命週期、同時執行數要在哪裡控制這三點,就能減少 async/await 周邊寫法上的猶豫的圖。在等什麼誰持有生命週期同時執行數要在哪裡控制寫法上的猶豫大幅減少避免先丟 Task.Run 再說

圖 1: 不要「先做再說」,先看等待的種類、生命週期、同時執行數這三點。

2. 本文使用的詞彙

2.1. 首先要分清楚的詞

先把這兩個分開,之後就不容易混淆。

詞彙 本文的意思
I/O-bound HTTP、資料庫、檔案、通訊端等,以 等待外部完成 為主的處理
CPU-bound 壓縮、影像處理、雜湊計算、沉重的轉換等,以 CPU 計算本身 為主的處理

async / await 特別派得上用場的是 I/O 等待這一邊,等待期間可以把執行緒還給其他工作。另一邊的 CPU 計算不是「等待」,而是實際在計算的時間,所以主題會變成 要在哪條執行緒上執行 以及 平行數怎麼決定。

I/O-bound 與 CPU-bound 的差別以等待外部完成為主的 I/O-bound 在 await 等待期間可以把執行緒還給其他工作,而以計算本身為主的 CPU-bound 主題則是要在哪條執行緒上執行以及平行數怎麼決定的區別的圖。I/O-bound(等待外部完成)等待期間可以把執行緒還出去async/await 特別派得上用場CPU-bound(計算本身)主題是要在哪條執行緒上執行平行數怎麼決定也是主題

圖 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) 就是指定「不必回到捕捉到的這個內容,直接執行後續即可」
await 的接續回到哪裡的差別await 會捕捉進入等待當下的 SynchronizationContext 並把接續送回那裡,因此 WinForms 與 WPF 會回到 UI 執行緒而能操作控制項,但 ASP.NET Core 沒有可回去的地方,接續直接在執行緒集區上執行的圖。await 捕捉內容WinForms 或 WPF 的 UI 執行緒ASP.NET Core 沒有可回去的地方await 之後可以操作 UI接續在執行緒集區上執行

圖 3: ConfigureAwait(false) 的討論,收攏成 await 的接續回到哪裡這一點。

由此就導出 3.12 的結論:「在 UI 程式碼裡不加比較自然」「ASP.NET Core 的應用程式程式碼加不加差別不大」「不知道會被誰驅動的通用函式庫,加了有價值」。詳細背景以 9. 參考資料的 ConfigureAwait FAQ 整理得最完整。

特別重要的一點是,非同步和平行是兩回事。

  • 非同步:講的是怎麼等
  • 平行:講的是同時推進

這兩者一混在一起,就會到處都想用 Task.Run。 這裡是第一個分歧點。

非同步和平行是兩回事非同步講的是怎麼等,平行講的是同時推進,這兩者一混在一起就會到處都想用 Task.Run,因此這裡是第一個分歧點的圖。非同步(怎麼等)混在一起就會多用 Task.Run平行(同時推進)這裡是第一個分歧點

圖 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 或應用程式特有的內容
是否是UI 事件 / 桌面ASP.NET Core 的請求worker / 背景否等到全部完成使用最先完成的件數很多依序流過固定間隔逐筆串流想做的處理要等外部 I/O 嗎?直接 await async APICPU 計算很重嗎?要在哪裡執行?考慮 Task.Run不要用 Task.Run 包起來必要時交給其他 worker 或佇列就地執行或明確寫出平行度要處理多件工作嗎?Task.WhenAllTask.WhenAnyParallel.ForEachAsync或 SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

圖 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 之後,就原樣往下游傳

這裡是相當正統的做法。

I/O 等待的基本形式HTTP、資料庫或檔案的等待要先找 async 版的 API 並直接 await,而把已經是 async 的 I/O 用 Task.Run 包起來只是重新丟到另一條執行緒,得不到好處的圖。HTTP、資料庫、檔案的等待先找 async 版的 API直接 await用 Task.Run 包起來只是重新丟一次,沒有好處

圖 6: I/O 等待的基本是「直接 await async API」。要避免用 Task.Run 包起來。

3.3. CPU 負荷重時,要挑 Task.Run 的使用位置

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

例如在 UI 事件處理常式裡直接跑沉重的計算,畫面就會停住。 這種時候用 Task.Run 最直接了當。

Task.Run 在 UI 上怎麼發揮作用在 UI 事件處理常式裡直接跑沉重的計算畫面會停住,因此用 Task.Run 把 CPU 計算移出 UI 執行緒就能保住畫面的回應,說明 Task.Run 發揮作用的典型場面的圖。在 UI 事件裡跑沉重的計算畫面停住用 Task.Run 移出 UI 執行緒畫面持續有回應

圖 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 執行緒)。伺服器端沒有那一條。原本的執行緒確實被釋放了,但換成另一條執行緒被同樣長的計算時間佔滿,相抵之後等於零

剩下的只有排入佇列與執行緒切換的成本,以及「現在跑在哪條執行緒上」多繞一層而變得比較難看清楚。所以要避免。

在 ASP.NET Core 避免 Task.Run 的理由在請求處理裡夾一層 Task.Run 計算的總量並沒有變,也沒有像 UI 那樣必須空出來的特別執行緒,相抵之後等於零,剩下的只有執行緒切換的成本與可讀性下降的圖。在請求處理裡夾一層 Task.Run計算的總量沒有變沒有必須空出來的那一條相抵之後等於零剩下切換成本與難讀

圖 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() 定案,所有工作就會在那個時間點開始。

Task 3Task 2Task 1呼叫端Task 3Task 2Task 1呼叫端啟動啟動啟動await Task.WhenAll(...)完成完成完成

圖 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 多一些。 只在「只要最先的那一個就夠」的情況才選它,比較好懂。

用 WhenAny 確認勝者之後才停掉其餘工作的流程Task.WhenAny 傳回的是最先完成的工作而不是最先成功的工作,因此要一個一個 await 完成的工作,只有成功時才取消其餘的,失敗就從候補中移除並等下一個完成,全部失敗則把失敗一起拋出的流程的圖。成功失敗有沒有用 WhenAny 取得最先完成的那個工作成功了嗎取消其餘的並傳回從候補中移除並記錄失敗還有候補嗎把失敗一起拋出

圖 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

這樣取捨,大方向不會偏得太離譜。

依件數選擇平行的匯總方式幾件的獨立處理可以用 Task.WhenAll 全部同時跑,但件數一多連線、記憶體與外部負荷都會一口氣暴增,因此要用 Parallel.ForEachAsync 或 SemaphoreSlim 決定同時執行數上限的圖。幾件左右很多對象的件數多不多用 Task.WhenAll 一次全上決定平行數的上限Parallel.ForEachAsync用 SemaphoreSlim 自由控制

圖 11: 分界點在於能不能一次全部送出。件數多就要明確寫出同時執行數。

3.7. 想依序流過就用 Channel<T>

有時會想把「不必馬上做完,但一定要處理掉」的工作,從呼叫端拆出來。 例如寄送郵件、日誌轉送、Webhook 的後續處理、檔案轉換等。

這種時候把 Task.Run 丟出去就不管,

  • 例外要在哪裡看
  • 結束時要不要等
  • 件數增加時要接到什麼程度

這些都會變得曖昧。

這類工作 排入佇列,由專用的 consumer 依序處理 比較好管理。

是否producerWriteAsync佇列還有空位嗎?進入 Channel等到有空位為止consumer 執行 ReadAsync依序 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」,這一邊在例外、停止、平行數、上限的處理上都容易得多。

丟出去不管與佇列管理的對比用 Task.Run 丟出去不管會讓例外、結束與可接受件數都變得曖昧,而排進 Channel 由 BackgroundService 的 consumer 依序處理,就能好好處理例外、停止、平行數與上限的圖。用純 Task.Run 丟出去不管例外、結束、上限都曖昧排進 Channel 的佇列BackgroundService 負責消費例外、停止、平行數都管得住

圖 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。 另外,如果處理時間比週期長,那個延遲必須當成設計問題來處理。 計時器不會自己平行化來追上進度。

PeriodicTimer 的週期迴圈用 WaitForNextTickAsync 等下一個週期,以 await 執行處理之後再回到等待的迴圈,停止交給 CancellationToken,而處理比週期長時的延遲必須當成設計問題處理的圖。用 WaitForNextTickAsync 等待以 await 執行處理用 CancellationToken 停止比週期長的處理造成的延遲要在設計上處理

圖 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>, 用 結果是要全部到齊之後才用,還是照送達的順序使用 來決定就很清楚。

依結果的用法決定傳回值的形式結果要全部到齊之後才用就用 Task 傳回整份清單,要照送達的順序一筆一筆用就傳回 IAsyncEnumerable 並以 await foreach 處理的決定方式的圖。全部到齊之後照送達的順序結果要怎麼用用 Task 傳回整份清單用 IAsyncEnumerable 流出用 await foreach 一筆一筆處理不把全部資料堆在記憶體裡

圖 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 相當實用。

跨越 await 的互斥形式在跨越 await 的程式碼裡要用 SemaphoreSlim 取代 lock,以 WaitAsync 進入並執行含 await 的處理,再在 finally 一定要呼叫 Release,這兩點很重要的圖。lock 無法跨越 await改用 SemaphoreSlim用 WaitAsync 進入執行含 await 的處理在 finally 一定要 Release

圖 16: 入口是 WaitAsync,出口是 finally 裡的 Release。不讓這一對拆開才是重點。

3.12. UI / 應用程式程式碼 / 函式庫要分開寫 await

ConfigureAwait(false) 並不是隨時加上就好的東西。

大致的分法如下。

UI / 應用程式程式碼await someAsync()回到原本的內容繼續執行通用函式庫await someAsync().ConfigureAwait(false)不預設會回到特定的內容

圖 17: 應用程式端的程式碼用 plain await 回到原本的內容,通用函式庫則考慮 ConfigureAwait(false)。

  • UI / 應用程式程式碼
    • 先用一般的 await 就好
    • 如果 await 之後要更新 UI,或要做依賴應用程式端內容的處理,不加 ConfigureAwait(false) 比較自然
  • ASP.NET Core 的應用程式程式碼
    • 通常用一般的 await 就夠
    • 不必把 ConfigureAwait(false) 當成整體的規矩硬性貫徹
  • 通用函式庫程式碼
    • 只要不依賴 UI 或應用程式模型,ConfigureAwait(false) 就很有力

也就是說,

  • 應用程式端的程式碼用 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 端 這一段必須自己寫完;心裡先有這個認知很重要。

避免 async void 的理由與唯一的例外async void 會讓呼叫端無法 await、等不到完成、例外處理與測試都變困難,因此一般的方法要避免,只在簽章上必須是 void 的事件處理常式裡使用,並在其中用 try/catch 把例外回報到 UI 端的圖。async void 方法無法 await等不到完成例外與測試都困難事件處理常式是例外用 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 的傳遞

這個差異之後很容易變成缺陷,一開始就決定好會比較穩定。

CancellationToken 的傳遞把上層接下的 CancellationToken 原樣傳給下游的 API 就能在中途停下來,只接下而不往下傳則會寫出看起來可以取消,中途卻停不下來的程式碼的圖。在上層接下 token原樣傳給下游的 API中途也能確實停下來只接下而不往下傳看起來會停,其實停不下來

圖 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(),所以就算是主控台應用程式,硬要同步化的理由也少了很多。

不混入同步等待的替換既然要用 async/await 就一路非同步接到最後,Result 與 Wait 換成 await,Thread.Sleep 換成 Task.Delay,混入同步等待的寫法會讓卡住的方式難以看清楚的圖。一路非同步接到最後Result 與 Wait 換成 awaitThread.Sleep 換成 Task.Delay混入同步的等待卡住的方式難以看清楚

圖 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

這張表裡,實務上特別常見的是下面三個。

  1. 明明是 I/O 卻用 Task.Run
  2. 其實彼此獨立卻依序 await
  3. fire-and-forget 沒有生命週期管理

光是修好這三個,程式碼就會變得容易看清全貌很多。

實務上特別常見的三種修法明明是 I/O 卻用 Task.Run 包起來、其實彼此獨立卻依序 await、fire-and-forget 沒有生命週期管理這三種實務上特別常見的情況,各自換成對應的做法就能改善可讀性的圖。明明是 I/O 卻用 Task.Run直接 await async API獨立處理卻依序 await先啟動再 WhenAllfire-and-forget 放著不管生命週期交給 Channel 或 BackgroundService更容易看清全貌

圖 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)

這份檢查清單也很適合用來統一團隊的審查觀點。

7. 大致的取捨

取捨的清單都收攏在 3.1 的判斷表。與其把同一張表再貼一次,需要時回去那裡查更好找,所以這裡不放表。

  • 想依情境查「先用什麼」→ 3.1 的判斷表
  • 想看各模式怎麼寫 → 3.2 〜 3.12(與 3.1 表格的各列對應)

有一個判斷沒有進到 3.1 的表裡,就是 傳回值的型別。這不是情境的問題而是方法設計的問題,所以整理在 4.1。只寫結論的話:先選 Task / Task<T>,ValueTask 要先量測,看出必要性之後再說。

8. 總結

async / await 的最佳實踐,與其記住一堆細瑣的技巧, 不如用 依處理的種類選擇形式 這樣的梳理方式,在實務上更管用。

查看的順序大致如下。

  1. 分清楚是 I/O 等待還是 CPU 計算
  2. 是 I/O 就直接 await async API
  3. 是 CPU 計算就決定該在哪裡執行
  4. 多個處理就在 WhenAll / WhenAny / 平行數限制之間選
  5. 要脫離請求的生命週期,就改成排入佇列而不是純 fire-and-forget
  6. 把傳回值、取消、例外、互斥、內容的處理方式統一起來

async / await 的寫法本身很簡潔,隨手亂用反而會讓方針變得看不清楚。反過來說,

  • I/O 就當成 I/O 來處理
  • CPU 就當成 CPU 來處理
  • 背景處理就當成背景處理來管理生命週期

光是把這三者分開,可讀性就會提高很多。

9. 參考資料

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

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

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

常見問題

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

在 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 端這一段,得由自己寫完。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽