C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选

· 更新日期: · · C#, async/await, .NET, 设计

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276564)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615427)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.21615426)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

小村 豪(2026)。《C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/2026/03/09/001-csharp-async-await-best-practices/

DOI(已登记存档)
10.5281/zenodo.21615426
DOI(上次登记版本)
10.5281/zenodo.22281929

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. 在哪里控制同时执行的数量

看清这 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 通用主机在启动时调用 StartAsync、停止时调用 StopAsync 的机制。它是随应用生命周期运行的常驻处理的入口
BackgroundService 实现了 IHostedService 的抽象类。只要 override 一个 ExecuteAsync(CancellationToken) 就能写出常驻循环。用 AddHostedService<T>() 注册(见 3.7)

使用 Channel<T> 时,消费端(consumer)就放在这个 BackgroundService 里。3.7 讲的是“放入队列,由专门的消费者按顺序处理”的形式,而把该消费者的生命周期与应用的启动、停止对齐并加以管理的,正是这里。

关于 SynchronizationContext 的补充

关于 ConfigureAwait(false) 的讨论(3.12),归根结底都集中在对这一个词的理解上。

  • await 在执行后续代码(延续)时,会捕获进入等待那一刻的 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 保持 1 个计时器对应 1 个消费者
想逐步处理结果 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);
}

这时要避免的是用 Task.Run 包裹已经是 async 的 I/O。

// 不好的例子
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 是基本做法,而用 Task.Run 包裹已经是 async 的 I/O 只是把等待重新丢给别的线程,没有收益。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 应用的后台处理同样成立)。要点是下面这两条。

  • 吞吐量不会提高。 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,为了保持界面响应性,有时会使用 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 需要多做一些设计。 只在“只需要第一个结果就够了”的场景下选用它,会更清晰。

先确认胜出者再停掉其余任务的流程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 扔出去不管,

  • 在哪里查看异常
  • 结束时要不要等待
  • 数量增多时能接受到什么程度

都会变得含糊不清。

这类工作,放入队列,由专门的消费者按顺序处理,会更便于管理。

是否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 的消费者按顺序处理,则更容易处理异常、停止、并行数和上限。用裸的 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:保持 1 个计时器对应 1 个消费者。计时器不会自动并行化来帮你追上进度。

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 中调用

在“同一时刻只想放入 1 个”“外部 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

在这张表中,实务里尤其常见的是下面这 3 种。

  1. 明明是 I/O,却用 Task.Run
  2. 本来是独立的,却写成了串行 await
  3. fire-and-forget 没有生命周期管理

仅修正这 3 点,代码的可读性就会有明显改善。

实务中尤其常见的三种改法明明是 I/O 却用 Task.Run 包裹、本来独立却写成串行 await、fire-and-forget 没有生命周期管理,这三种实务中尤其常见的问题只要各自换成对应方案,可读性就会改善。明明是 I/O 却用 Task.Run直接 await async API独立处理却串行 await先全部启动再 WhenAllfire-and-forget 不管生命周期交给 Channel 或 BackgroundService可读性明显改善

图21:反面模式表中,最先该改的就是这 3 点,效果也最明显。

6. 审查时的检查清单

在关于 async / await 的代码审查中,可以按顺序确认下面这些内容。

  • 这个处理是 I/O-bound 还是 CPU-bound,能不能一开始就用一句话说清楚
  • 是否还残留着 Task.Result / Task.Wait() / Thread.Sleep()
  • 是否用 Task.Run 包裹了 I/O 等待
  • 是否把独立的处理不必要地写成了串行 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 来处理
  • 把后台处理作为后台处理来管理其生命周期

仅仅分清这 3 点,代码就会变得相当易读。

9. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

C# 中 Task.Run 应该在什么时候使用?
Task.Run 起作用的场景,是想把 CPU 计算从当前线程中移出去的时候。比如在 WinForms / WPF 的 UI 事件处理器中直接运行繁重的计算会让界面卡住,这时用 Task.Run 把计算移出 UI 线程是很自然的做法。而 ASP.NET Core 的请求处理本来就运行在 ThreadPool 上,如果夹一层 Task.Run 后紧接着 await,往往只会增加多余的调度开销,因此基本上应该避免。如果是耗时较长、或者想与请求生命周期分离的处理,交给队列或 HostedService 处理会更合理。
不能用 await Task.Run() 包裹 I/O 处理吗?
HTTP、数据库、文件读写等 I/O 等待,基本做法是直接 await 对应的 async 版 API,不需要用 Task.Run 包裹。用 Task.Run 包裹已经是 async 的 I/O,只是把等待重新丢给了另一个线程,反而让代码更难整理,却没有实际收益。不过,如果调用的 API 只有 sync 版本,且是从 UI 调用的,为了保持响应性,有时会使用 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,才在那里使用 async void,此时要有意识地在处理器内部用 try/catch 捕获异常,并自己写代码把结果反馈给 UI。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表