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

· · C#, async/await, .NET, 设计

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)

目录

  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. 参考资料

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 点,犹豫就会大大减少。

2. 本文使用的术语

2.1. 首先要区分的词

一开始先把这两个概念分开,就不容易混乱了。

术语 本文中的含义
I/O-bound 以 HTTP、数据库、文件、套接字等 等待外部完成 为主的处理
CPU-bound 以压缩、图像处理、哈希计算、繁重转换等 CPU 计算本身 为主的处理

async / await 特别有效的是 I/O 等待这一侧,因为可以在等待期间把线程让给其他工作。而 CPU 计算并不是「等待」,而是实际在计算的时间,因此主题变成了 在哪个线程上运行 以及 如何决定并行数

2.2. 常见词汇

术语 本文中的含义
阻塞(blocking) 在等待完成期间,持续占用该线程
fire-and-forget 调用方不等待完成的启动方式
SynchronizationContext 在 UI 等场景中用于「返回原执行位置」的机制
backpressure(背压) 当写入速度过快时,让写入方等待,以防止数据堆积过多的机制

特别要注意的是,异步与并行是两个不同的概念

  • 异步:关于「怎么等待」
  • 并行:关于「同时推进」

如果把这两者混为一谈,就会想在任何地方都使用 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;

下面依次看一下每种模式。

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 后,直接传递给下游

这是相当标准的做法。

3.3. 如果 CPU 负载较重,要选好使用 Task.Run 的位置

Task.Run 起作用的场景,是 想把 CPU 计算从当前线程移出去的时候

比如在 UI 事件处理器中直接运行繁重计算,会让界面卡住。 这种情况下用 Task.Run 是很自然的选择。

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 的请求处理本来就运行在 ThreadPool 上,如果在其中夹一层 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(...)完成完成完成

这种模式适用于下面这些情况。

  • 数量较少或中等
  • 想统一等待所有结果
  • 不设上限同时运行也没有问题

如果数量较多,像后面 3.6 那样设置 并行数上限 会更安全。

3.5. 如果只想用最先完成的结果,使用 Task.WhenAny

比如想使用多个镜像源中最先响应的那一个,这种场景下 Task.WhenAny 很直观。

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    Task<byte[]>[] tasks = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToArray();

    Task<byte[]> winner = await Task.WhenAny(tasks);
    cts.Cancel();

    try
    {
        return await winner;
    }
    finally
    {
        try
        {
            await Task.WhenAll(tasks);
        }
        catch
        {
            // 回收除胜出者以外的取消或失败
        }
    }
}

这里要注意的是,WhenAny 只返回一个胜出者。 剩下的处理如果什么都不做,会照常继续运行。

因此,需要提前决定,

  • 是否要取消剩下的处理
  • 是否要观测其中的异常

Task.WhenAny 很方便,但比 WhenAll 需要多做一些设计。 只在「只需要第一个结果就够了」的场景下选用它,会更清晰。

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.ForEachAsyncSemaphoreSlim

按这样区分使用,基本不会出大问题。

3.7. 如果想按顺序处理,使用 Channel<T>

有时会希望把「不需要马上完成,但一定要确实处理」的工作,从调用方中分离出来。 比如邮件发送、日志转发、Webhook 的后续处理、文件转换等。

这种情况下如果只是把 Task.Run 扔出去不管,

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

都会变得含糊不清。

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

producerWriteAsync队列还有空位吗?写入 Channel等待直到有空位consumer 调用 ReadAsync按顺序 await 并处理

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」,这种方式更容易处理异常、停止、并行数和上限。

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。 另外,如果处理时间比周期更长,这种延迟需要作为设计问题来处理。 计时器并不会自动并行化来帮你赶上进度。

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>, 可以按 是要等结果全部齐了再用,还是按到达顺序使用 来决定,这样比较清晰。

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 相当实用。

3.12. 在 UI / 应用代码 / 库中区分 await 的写法

ConfigureAwait(false) 并不是随时加上就好的东西。

大致的区分方式是这样的。

UI / 应用代码await someAsync()返回原来的上下文后继续执行通用库await someAsync().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.CompletedTaskTask.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

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 的传播

这个区别如果不早点确定,后续很容易出问题,提前决定好会更稳定。

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(),因此即便是控制台应用,强行同步化的理由已经大大减少。

4.5. 用 LINQ 创建任务时,用 ToArray / ToList 确定下来

Task.WhenAllTask.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 点,代码的可读性就会有明显改善。

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,是否已确定由谁来管理异常、停止和上限
  • 如果使用了 SemaphoreSlimRelease 是否写在 finally
  • 如果使用了 ValueTask,是否有测量上的依据,是否以只 await 一次为前提
  • ConfigureAwait(false) 的使用与否,是否与该代码的类型相符
    • UI / 应用代码使用 plain await
    • 通用库考虑使用 ConfigureAwait(false)

这份检查清单,也很适合用来在团队中统一审查的视角。

7. 大致的选型建议

想要做的事 首先选择
单次 HTTP / 数据库 / 文件 I/O 直接 await async API
不想让 UI 卡住的繁重计算 Task.Run
独立且数量较少的异步处理 Task.WhenAll
只想要最先完成的那一个 Task.WhenAny
想给大量任务设置上限运行 Parallel.ForEachAsync / SemaphoreSlim
带顺序的后台处理 Channel<T>
想按固定间隔运行 PeriodicTimer
想处理逐次到达的流 IAsyncEnumerable<T> / await foreach
跨越 await 的互斥 SemaphoreSlim
通用库 考虑 ConfigureAwait(false)
返回值类型难以决定 首先用 Task / Task<T>

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

返回博客列表