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)
目录
- 先说结论(一句话)
- 本文使用的术语
- 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 确定下来
- 常见的反面模式
- 审查时的检查清单
- 大致的选型建议
- 总结
- 参考资料
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 再说」。
先弄清楚,
- 这个处理在等待什么
- 谁掌管这个处理的生命周期
- 在哪里控制同时执行的数量
看清这 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 / 应用特有的上下文 |
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>"]
下面依次看一下每种模式。
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() 先确定下来,所有任务就会在那一刻全部启动。
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: 完成
这种模式适用于下面这些情况。
- 数量较少或中等
- 想统一等待所有结果
- 不设上限同时运行也没有问题
如果数量较多,像后面 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.ForEachAsync或SemaphoreSlim
按这样区分使用,基本不会出大问题。
3.7. 如果想按顺序处理,使用 Channel<T>
有时会希望把「不需要马上完成,但一定要确实处理」的工作,从调用方中分离出来。 比如邮件发送、日志转发、Webhook 的后续处理、文件转换等。
这种情况下如果只是把 Task.Run 扔出去不管,
- 在哪里查看异常
- 结束时要不要等待
- 数量增多时能接受到什么程度
都会变得含糊不清。
这类工作,放入队列,由专门的消费者按顺序处理,会更便于管理。
flowchart LR
p["producer"] --> w["WriteAsync"]
w --> q{"队列还有空位吗?"}
q -- "是" --> c["写入 Channel"]
q -- "否" --> b["等待直到有空位"]
c --> d["consumer 调用 ReadAsync"]
d --> e["按顺序 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) 并不是随时加上就好的东西。
大致的区分方式是这样的。
flowchart LR
a["UI / 应用代码"] --> b["await someAsync()"]
b --> c["返回原来的上下文后继续执行"]
d["通用库"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["不假设要返回特定的上下文"]
- 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。
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.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 种。
- 明明是 I/O,却用
Task.Run - 本来是独立的,却写成了串行 await
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,是否已确定由谁来管理异常、停止和上限 - 如果使用了
SemaphoreSlim,Release是否写在finally中 - 如果使用了
ValueTask,是否有测量上的依据,是否以只 await 一次为前提 ConfigureAwait(false)的使用与否,是否与该代码的类型相符- UI / 应用代码使用 plain
await - 通用库考虑使用
ConfigureAwait(false)
- UI / 应用代码使用 plain
这份检查清单,也很适合用来在团队中统一审查的视角。
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 的最佳实践,与死记硬背许多细碎技巧相比,
根据处理的种类来选择对应模式 这种整理方式在实务中更有效。
大致的判断顺序是这样的。
- 区分是 I/O 等待还是 CPU 计算
- 如果是 I/O,直接
awaitasync API - 如果是 CPU 计算,决定应该在哪里运行
- 如果是多个处理,选择
WhenAll/WhenAny/ 限制并行数 - 如果要与请求生命周期分离,不要用裸的 fire-and-forget,而要做成队列
- 统一返回值、取消、异常、互斥、上下文的处理方式
正因为 async / await 本身的写法很简洁,一旦用得随意,方针就会变得不清晰。反过来,
- 把 I/O 当作 I/O 来处理
- 把 CPU 当作 CPU 来处理
- 把后台处理作为后台处理来管理其生命周期
仅仅分清这 3 点,代码就会变得相当易读。
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
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
如何理解 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)各自的优势与陷阱,并附判断表,从实务角度讲解防止数据损坏与位数问题等注意事项。
.NET Generic Host 到底是什么 - DI、配置、日志与 BackgroundService 的基础
本文从 DI、配置、日志、IHostedService、BackgroundService 的关系入手,整理 Generic Host 的作用,从实务角度总结它在什么地方有效、在什么地方又显得多余。
.NET 的 Native AOT 是什么 - 先厘清与 JIT、ReadyToRun、trimming 的区别
把 .NET 的 Native AOT 与 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起厘清,并从启动、发布、依赖关系的角度整理它适合与不适合的场景,帮助读者判断该不该采用。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。