C# async/await编程模型的执行逻辑解析

简介

C# 5引入了async/await异步编程模型,旨在进一步简化异步编程的难度,让异步执行的代码看起来像同步代码一样简洁和容易理解。但初学者往往会将此异步编程模型与多线程编程模型混淆,以致引起一些不必要的错误如死锁或性能损失。

简而言之,Task是对异步操作的抽象,而非对线程的抽象。 可以认为await关键字暂时释放了线程控制权给界面或主线程,而待较为耗时的异步操作完成之后继续执行下面的代码。一般来说,此处所谓的释放控制权并不一定另起一个线程进行异步操作,这也是许多新接触async/await关键字的开发者最容易疑惑的地方。

在分析async代码执行顺序前,首先比较一下Thread.Sleep()和Task.Delay():

  • Thread.Sleep(): 同步方法,阻塞当前线程,返回值void。
  • Task.Delay(): 异步方法,awaitable,返回值Task。

因此,我们结合这两种方法模拟一个耗时的异步函数DoWorkAsync(),其中同步耗时操作主要发生在Thread.Sleep():

public static async Task DoWorkAsync(string name)
{
    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Enter {name}");
    Thread.Sleep(100);
    await Task.Delay(500);
    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Leave {name}");
}

“假”多任务异步执行

一种常见的异步操作场景即有多个任务希望并发执行,待所有任务执行完毕后,合并结果。则最为常见的写法如下:

class Program
{
    static void Main(string[] args)
    {
        Test().Wait();

        Console.ReadKey();
    }

    public static async Task Test()
    {
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main");
        var task1 = DoWorkAsync("Task1");
        var task2 = DoWorkAsync("Task2");
        await task1;
        await task2;
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Leave Main");
    }

    public static async Task DoWorkAsync(string name)
    {
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Enter {name}");
        Thread.Sleep(100);
        await Task.Delay(500);
        Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Leave {name}");
    }
}

执行结果:

[1] Main
[1] Enter Task1
[1] Enter Task2
[4] Leave Task1
[4] Leave Task2
[4] Leave Main

代码执行顺序如下图所示,括号中的数字代表执行步骤顺序:

即首先执行Task1在await关键字之前的同步操作,然后让出控制权到主线程,执行Task2在await关键字之前的同步操作,再让出控制权到主线程,等待两个Task执行完毕。因此,若每个Task在await关键字前有一些开销较大的同步操作,则在执行多个Task时会造成似乎它们在顺序同步执行的错觉。所以这里称其为“假”多任务异步执行。

同时注意到在执行Task1时的线程Id与主线程相同,说明每一个Task在执行行并非一定会创建单独线程,即Task只是对异步操作的抽象。

“真”多任务异步执行

如果希望程序像下图一样同时开始执行两个Task,可以使用Task.Run()指定一个Task在线程池中执行。

public static async Task Test()
{
    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main");
    var task1 = Task.Run(() => DoWorkAsync("Task1"));
    var task2 = Task.Run(() => DoWorkAsync("Task2"));
    await task1;
    await task2;
    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Leave Main");
}

执行结果:

[1] Main
[4] Enter Task2
[3] Enter Task1
[5] Leave Task1
[6] Leave Task2
[6] Leave Main

注意到这里Task1与Task2所在的线程与主线程不同,且进入函数的时机不确定:可能出现先Task2后Task1。而在“假”异步多线程的情况,Task1一定先于Task2执行。

总结

  1. Task是对异步操作的抽象,而非对线程的抽象。
  2. 是否需要显示指定在线程池中执行Task,由实际应用场景决定。在并发执行多任务的场景,尽量在异步方法中少写耗时的同步方法,以最大发挥异步方法的优势。

References:

await (C# Reference)

Async in depth

Asynchronous programming with async and await (C#)

Async/Await - Best Practices in Asynchronous Programming