← 全部文章 C# 语言

C# 多线程(一):Task

2023 年 7 月 26 日 · 2 分钟读完

目录
  1. Task 是什么
  2. 一、后台执行
  3. 二、await 等待完成
  4. 三、并行执行多个任务:WhenAll
  5. 一个坑:new Task 包 async 方法
  6. 四、处理异常
  7. 五、取消
  8. WhenAll 和 WhenAny
  9. 延伸

写代码经常用到多线程,实现方式很多,想把它们的关系和差异理清楚。这个系列从 Task 开篇。

Task 是什么

Task 是 .NET 里表示「一个异步操作」的类:

  1. 后台执行代码:在线程池线程上跑耗时操作,不阻塞主线程(界面不卡);
  2. 等待完成:用 await 非阻塞地等它做完;
  3. 并行:同时启动多个任务,用 WhenAll 一起等;
  4. 异常处理:任务里的异常会被保存下来,在 await 处重新抛出;
  5. 线程池:复用线程,没有频繁创建、销毁线程的开销;
  6. 取消:通过 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。

# C# # 多线程 # Task

原文首发于 blog.csdn.net