在C#的后端开发中,一个常见且关键的场景是:如何正确并发执行多个任务,并统一等待它们完成?这个问题直接关系到API的性能和响应速度。本章将深入探讨C#/.Net中实现这一目标的核心模式。
本章内容围绕三个紧密关联的知识点展开:
- Task什么时候开始执行
- 并发启动任务的正确方式
- Task.WhenAll任务组合
这三个知识点构成了一条完整的逻辑链:
任务什么时候启动
↓
如何同时启动多个任务
↓
如何优雅等待它们完成
任务并发与组合
在真实工程中,我们经常需要同时发起多个独立的异步操作,并等待它们全部完成后再进行后续处理。
典型场景包括:
- 同时加载用户信息、订单列表和账户余额。
- 并发调用多个下游微服务接口以聚合数据。
如果代码写得不好,很容易将本可以并发执行的操作变成串行执行,导致总耗时急剧增加。
示例:
三个操作各需100毫秒
串行执行总耗时 ≈ 300毫秒
并发执行总耗时 ≈ 100毫秒
Task什么时候开始执行?
许多初学者有一个误解:认为await关键字才是启动异步任务的信号。
实际上,调用异步方法的那一刻,任务就已经开始执行了。
看这段代码:
Task t = Task.Delay(2000);
它的执行流程是:
- 创建一个Task对象。
- 内部的定时器立即开始计时。
- 2秒后,该Task自动完成。
关键点在于:await并不会启动任务,它的作用仅仅是等待一个已经在执行中的任务完成。
示例分析
async Task Test()
{
Console.WriteLine(“A”);
var t = Task.Delay(2000);
Console.WriteLine(“B”);
await t;
Console.WriteLine(“C”);
}
这个方法的执行顺序和输出是:
A
B
(等待约2秒)
C
这清晰地证明了Task.Delay(2000)在创建时(var t = ...这一行)就已经启动了,而不是在await t时才启动。
如何正确并发启动任务?
理解了任务的启动时机,我们来看一个常见的错误写法。
错误示例(串行执行):
async Task Test()
{
await Task.Delay(1000);
await Task.Delay(1000);
await Task.Delay(1000);
}
这段代码的总执行时间大约是3秒。它的执行模型是线性的:
第一个Delay完成
↓
第二个Delay开始并完成
↓
第三个Delay开始并完成
这不是并发,而是串行。
正确并发方式:
async Task Test()
{
var t1 = Task.Delay(1000);
var t2 = Task.Delay(1000);
var t3 = Task.Delay(1000);
await t1;
await t2;
await t3;
}
这段代码的执行模型是:
t1 ┐
t2 ├ 三者在同一时刻同时开始
t3 ┘
总执行时间大约是1秒。原因很简单:三个任务在创建时都已启动,后续的await只是在分别等待它们各自完成。
Task.WhenAll:优雅的任务组合
上面的写法虽然实现了并发,但代码显得不够优雅,尤其是当任务数量较多时,需要写很多行await。
为此,.NET提供了Task.WhenAll方法,它的作用就是等待传入的所有任务完成。
基础用法示例
async Task Test()
{
var t1 = Task.Delay(2000);
var t2 = Task.Delay(3000);
var t3 = Task.Delay(1000);
await Task.WhenAll(t1, t2, t3);
Console.WriteLine(“全部完成”);
}
这段代码的总执行时间大约是3秒,因为Task.WhenAll会等待所有任务中最慢的那个(这里是t2,3秒)完成。
处理带返回值的任务
如果任务有返回值,Task.WhenAll同样适用,并且会以一个数组的形式返回所有任务的结果,顺序与传入的任务顺序一致。
Task<int> t1 = GetData(1);
Task<int> t2 = GetData(2);
Task<int> t3 = GetData(3);
int[] results = await Task.WhenAll(t1, t2, t3);
// results[0] 对应 t1 的结果
// results[1] 对应 t2 的结果
// results[2] 对应 t3 的结果
一个真实的工程例子
假设我们需要为用户首页加载三部分数据:用户信息、订单列表和账户余额。
低效的串行写法:
var user = await GetUser(); // 假设耗时 100ms
var order = await GetOrders(); // 假设耗时 100ms
var balance = await GetBalance();// 假设耗时 100ms
// 总耗时 ≈ 100ms + 100ms + 100ms = 300ms
优化后的并发写法:
var userTask = GetUser(); // 调用即开始,不等待
var orderTask = GetOrders(); // 调用即开始,不等待
var balanceTask = GetBalance();// 调用即开始,不等待
await Task.WhenAll(userTask, orderTask, balanceTask);
var user = userTask.Result;
var order = orderTask.Result;
var balance = balanceTask.Result;
// 总耗时 ≈ max(100ms, 100ms, 100ms) = 100ms
通过并发执行,我们将总响应时间从300毫秒降低到了100毫秒,提升了三倍!这是构建高性能API聚合服务的标准写法。
本章核心模式总结
本章建立了一个至关重要且常用的异步编程模式:
启动所有任务
↓
让它们并发执行
↓
统一等待所有任务完成
其代码模板非常清晰:
// 1. 启动所有任务
var task1 = AsyncOperation1();
var task2 = AsyncOperation2();
var task3 = AsyncOperation3();
// 2. 统一等待
await Task.WhenAll(task1, task2, task3);
// 3. 处理结果
这个模式在服务器端开发中极其常见,是优化I/O密集型操作性能的关键手段。通过在云栈社区与其他开发者交流,你可以发现更多关于后端 & 架构中高并发处理的实战技巧和最佳实践。
练习与思考
通过练习来巩固理解:
练习1
async Task Test()
{
var t1 = Task.Delay(2000);
await Task.Delay(2000);
await t1;
}
问: 这个方法的总执行时间大约是多少?为什么?
分析:
t=0: 创建t1,一个2秒的延时任务立即开始。
t=0: 执行await Task.Delay(2000),另一个2秒的延时任务立即开始。
t=2: 两个延时任务都已完成。
await t1会立即返回,因为t1在第2秒时已经完成。
- 总时间 ≈ 2秒。两个任务是并发执行的。
练习2
async Task Test()
{
var t1 = Task.Delay(2000);
var t2 = Task.Delay(4000);
await t1;
Console.WriteLine(“A”);
await t2;
Console.WriteLine(“B”);
}
问: A和B分别会在什么时候打印?
分析:
t=0: t1(2秒)和t2(4秒)同时开始。
t=2: t1完成,await t1返回,立即打印A。此时t2已经执行了2秒,还剩2秒。
t=4: t2完成,await t2返回,打印B。
- 关键点: 在等待和打印
A的期间,t2并没有停止,它一直在后台并发执行。
练习3(深入理解并发时机)
问: 下面这行代码能实现真正的并发吗?任务是在什么时候开始执行的?
await Task.WhenAll(
GetData1(),
GetData2(),
GetData3()
);
分析:
- 执行过程:
- 调用
GetData1() → Task1开始执行。
- 调用
GetData2() → Task2开始执行。
- 调用
GetData3() → Task3开始执行。
Task.WhenAll接收这三个已经启动的任务,并等待它们全部完成。
- 结论: 并发发生在调用各个方法的时候,而不是在
WhenAll内部。WhenAll只是一个“等待器”。