C# 多线程(一):Task
2023 年 7 月 26 日 · 2 分钟读完
目录
写代码经常用到多线程,实现方式很多,想把它们的关系和差异理清楚。这个系列从 Task 开篇。
Task 是什么
Task 是 .NET 里表示「一个异步操作」的类:
- 后台执行代码:在线程池线程上跑耗时操作,不阻塞主线程(界面不卡);
- 等待完成:用
await非阻塞地等它做完; - 并行:同时启动多个任务,用
WhenAll一起等; - 异常处理:任务里的异常会被保存下来,在
await处重新抛出; - 线程池:复用线程,没有频繁创建、销毁线程的开销;
- 取消:通过
CancellationToken协作式取消。
一、后台执行
static void Main(string[] args)
{
Task.Run(() =>
{
Console.WriteLine("task");
Task.Delay(2000).Wait(); // 模拟耗时操作
Console.WriteLine("taskEnd");
});
Task.Delay(1000).Wait();
Console.WriteLine("main");
Console.ReadKey();
}

主线程没有等任务,1 秒后先打印了 main,任务 2 秒后才结束。
二、await 等待完成
在 WinForm、WPF 里,await 一个耗时操作,界面不会卡,等它完成后继续执行下面的代码(而且回到界面线程,可以直接改控件):
private async void btnStart_Click(object sender, EventArgs e)
{
lblState.Text = "处理中";
await Task.Delay(2000); // 等 2 秒,期间界面可以正常操作
lblState.Text = "完成";
}
三、并行执行多个任务:WhenAll
static async Task Test()
{
Task[] tasks = { Wait(2000), Wait(3000), Wait(4000) };
await Task.WhenAll(tasks); // 三个同时跑,总共约 4 秒
Console.WriteLine("main");
}
static async Task Wait(int ms)
{
await Task.Delay(ms);
Console.WriteLine($"Wait {ms} ms");
}

调用 async 方法时,它就已经开始运行了,返回的是一个「热」任务,不需要也不能再 Start()。
一个坑:new Task 包 async 方法
当时我换了一种写法,结果 main 比三个 Wait 先打印出来:
Task[] tasks =
{
new Task(() => Wait(2000)),
new Task(() => Wait(3000)),
new Task(() => Wait(4000)),
};
foreach (var item in tasks) item.Start();
await Task.WhenAll(tasks);
Console.WriteLine("main"); // 立刻就打印了

原文把原因归结为「new Task 没有被调度到线程池,WhenAll 等不到」,这个解释不对:
Start()同样会把任务放进线程池。
真正的原因是:new Task(...) 只接受 Action,也就是没有返回值的委托。() => Wait(2000) 被当成 Action 以后,Wait 返回的那个 Task 被直接丢掉了。外层任务的工作只是「调用 Wait」,Wait 一遇到 await Task.Delay 就返回,外层任务随即完成。WhenAll 等到的是三个早已完成的外层任务,内层真正的等待没人管。
改法:用 Task.Run。它有一个接受 Func<Task> 的重载,会自动「解包」,等内层任务完成:
Task[] tasks = { Task.Run(() => Wait(2000)), Task.Run(() => Wait(3000)), Task.Run(() => Wait(4000)) };
或者干脆像前面那样直接调用 Wait(2000)。结论:不要用 new Task + Start,它几乎没有必须用的场景。
四、处理异常
static async Task Test()
{
try
{
await Wait();
}
catch (Exception)
{
Console.WriteLine("报错啦!");
}
}
static async Task Wait()
{
await Task.Delay(2000);
Console.WriteLine("Wait 2000 ms");
throw new Exception("wwww");
}

异常会被存进 Task,要 await 它才会在 try/catch 里抛出来。只调用不 await,异常就悄悄丢了。
五、取消
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task.Run(async () =>
{
try
{
int i = 0;
while (true)
{
token.ThrowIfCancellationRequested(); // 已取消就抛 OperationCanceledException
await Task.Delay(1000, token); // 等待期间也能被取消
Console.WriteLine($"第 {i++} 次等待");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("任务被取消");
}
}, token);
Thread.Sleep(5000);
cts.Cancel();

用一个全局 bool 也能停止循环,但 CancellationToken 能一路传给 Task.Delay、HttpClient、数据库查询等 API,让它们在等待中途就停下来,还能用 token.Register(...) 注册取消回调,cts.CancelAfter(3000) 设置超时。
WhenAll 和 WhenAny
WhenAll:所有任务都完成才返回;WhenAny:任意一个完成就返回,返回值就是那个完成的任务。
await Task.WhenAny(Wait(2000), Wait(3000), Wait(4000));
Console.WriteLine("main"); // 2 秒后打印

WhenAny 常用来做超时:if (await Task.WhenAny(work, Task.Delay(5000)) != work) { /* 超时 */ }。.NET 6 起可以直接写 await work.WaitAsync(TimeSpan.FromSeconds(5));。
延伸
Task.Run适合 CPU 密集的活(图像处理、计算);I/O 等待(网络、文件、数据库)直接await对应的异步 API 就好,不需要再包一层Task.Run占用线程池线程。async void只用在事件处理器:它的异常无法被调用方捕获,会直接让程序崩溃;其他地方一律返回Task。- 别在界面线程上
.Result或.Wait():WinForm 里对一个 async 方法调用.Result,可能直接死锁,原因见本系列 第四篇。 WhenAll里多个任务都抛异常时,await只抛出第一个;全部异常在task.Exception.InnerExceptions里。- 下一篇讲更底层的 Thread。
原文首发于 blog.csdn.net