C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选
· 更新日期: · 小村 豪 · C#, async/await, .NET, 设计
引用本文(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. |
目录
- 先说结论(一句话)
- 本文使用的术语
- 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 再说”。
先弄清楚,
- 这个处理在等待什么
- 谁掌管这个处理的生命周期
- 在哪里控制同时执行的数量
看清这 3 点,犹豫就会大大减少。
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 通用主机在启动时调用 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)表示的是:不必回到捕获的那个上下文,直接执行后续代码即可
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 |
保持 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>"]
图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 后,直接传递给下游
这是相当标准的做法。
flowchart TB
accTitle: I/O 等待的基本形式
accDescr: 遇到 HTTP、数据库、文件的等待时先找 async 版 API 并直接 await 是基本做法,而用 Task.Run 包裹已经是 async 的 I/O 只是把等待重新丢给别的线程,没有收益。
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 应用的后台处理同样成立)。要点是下面这两条。
- 吞吐量不会提高。 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,为了保持界面响应性,有时会使用 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: 先确认胜出者再停掉其余任务的流程
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 扔出去不管,
- 在哪里查看异常
- 结束时要不要等待
- 数量增多时能接受到什么程度
都会变得含糊不清。
这类工作,放入队列,由专门的消费者按顺序处理,会更便于管理。
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 的消费者按顺序处理,则更容易处理异常、停止、并行数和上限。
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:保持 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>,
可以按 是要等结果全部齐了再用,还是按到达顺序使用 来决定,这样比较清晰。
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中调用
在“同一时刻只想放入 1 个”“外部 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 |
在这张表中,实务里尤其常见的是下面这 3 种。
- 明明是 I/O,却用
Task.Run - 本来是独立的,却写成了串行 await
fire-and-forget没有生命周期管理
仅修正这 3 点,代码的可读性就会有明显改善。
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:反面模式表中,最先该改的就是这 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. 大致的选型建议
选型的一览表已经集中在 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 来处理
- 把后台处理作为后台处理来管理其生命周期
仅仅分清这 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
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
如何理解 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 处理会更合理。
- 不能用 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。