记录异步编程 async/await 的用法和理解

基于任务的异步模式(TAP)

Task-based Asynchronous Pattern(TAP)是.NET4.0推出的一种新的异步编程模式,TAP的特色就是使用单独的方法来初始化和实现异步,使得异步编程的代码很简洁。在System.Threading.Tasks 命名空间里,提供了名为 Task 的类,每一个 Task 代表一个异步操作。例如,下面的代码展示了三种方法创建并异步执行Task:

class Program
{
    static void Main(string[] args)
    {
        Program p = new Program();
        p.Test();
        Console.WriteLine("Main Thread:{0}", Environment.CurrentManagedThreadId);
        Console.ReadLine();
    }

    void Test()
    {
        Task task1 = new Task(() => 
        { 
            Console.WriteLine("Task1 Thread:{0}", Environment.CurrentManagedThreadId); 
        });
        task1.Start();

        Task task2 = Task.Factory.StartNew(() => 
        {
            Console.WriteLine("Task2 Thread:{0}", Environment.CurrentManagedThreadId);
        });

        Task task3 = Task.Run(() =>
        {
            Console.WriteLine("Task3 Thread:{0}", Environment.CurrentManagedThreadId);
        });
    }
}

一个可能的输出如下,但是下面四行输出任何顺序的情况都是可能出现的。

Main Thread:1
Task3 Thread:6
Task1 Thread:4
Task2 Thread:5

注意到这里已经实现了异步,且每个Task执行的线程是不同的,这些线程其实是.NET的ThreadPool(一个快速的线程池,在.NET用于执行Task和处理IO等任务)分配的空闲线程。

你也可以通过 RunSynchronously 方法同步执行一个Task。

上面的异步操作都没有返回值,可以创建具有返回值的Task<T>,并通过 Result 属性获取返回值,比如:

Task<string> task = new Task<string>(() =>
{
    Console.WriteLine("Task1 Thread:{0}", Environment.CurrentManagedThreadId); 
    return "Hello World";
}); 
task.Start(); 
Console.WriteLine("Main Thread:{0}", Environment.CurrentManagedThreadId); 
Console.WriteLine(task.Result);

//一个可能的输出如下
//Task1 Thread:4
//Main Thread:1
//Hello World

//另一个可能的输出
//Main Thread:1
//Task1 Thread:4
//Hello World

Start方法是立即返回的,不会导致阻塞,这时程序已经有两个同时执行的分支了,其中一个执行Task指定的异步方法,而另一个分支(主线程)继续执行Start方法下面的代码,直到遇到 task.Result,此时:

  • 若task执行完毕,则立即获取返回值
  • 若task还未执行完毕,则程序阻塞,并等待结果返回

实际上每一个Task代表一个异步方法时,也承诺了在之后通过 Result 给出该方法的返回值(Task也包含其他信息,例如任务是否完成,以及是否出现异常),而且上面的代码,虽然返回值类型是 Task<string>,但是在返回时直接返回了 string,这个包装的过程实际上是由编译器自动完成的。

下面介绍Task常用的操作:

  • Wait:等待当前 Task 实例执行完毕
  • Task.WaitAny:静态方法,等待参数中某一个Task执行完毕
  • Task.WaitAll:静态方法,等待参数中所有Task执行完毕

这三个方法都会造成阻塞,比如:

Task task1 = new Task(() => 
{
    Thread.Sleep(1000);
    Console.WriteLine("Hello World1");
});
task1.Start();

Task task2 = new Task(() =>
{
    Thread.Sleep(1000);
    Console.WriteLine("Hello World2");
});
task2.Start();

//Task.WaitAny(task1,task2);
Task.WaitAny(new Task[] { task1, task2 }); //阻塞大约1秒

如果不想造成阻塞,并且在Task没结束时就指定后续代码,可以使用 Task.WhenAll/Task.WhenAny/ContinueWith 方法:

  • Task.WhenAll:静态方法,返回一个Task,它等待参数指定的所有 Task 结束才结束
  • Task.WhenAny:静态方法,返回一个Task,它等待参数指定的某一个 Task 结束才结束
  • ContinueWith:为当前 Task 实例指定一个结束后执行的Task

使用 async/await

async 和 await 关键字提供了一种更为简洁的方法实现异步,async 用于修饰一个方法,标识它可以被异步执行,一般情况下,一个异步方法的返回值类型应该为 Task 或者 Task<T>,在UI事件中可以为 void,而 await 则用于等待某个异步方法执行完毕并获取返回值。以下面异步获取网页数据的代码为例:

class Program
{
    static async Task Main(string[] args)
    {
        Program p = new Program();
        Task ptask = p.TestAsync();
        Console.WriteLine("Main");
        await ptask;
        Console.ReadLine();
    }

    async Task TestAsync()
    {
        HttpClient client = new HttpClient();
        Task<String> task = client.GetStringAsync("https://kfm.ink/");
        SomeWork();
        string src = await task;
        Console.WriteLine(src.Length);
    }

    void SomeWork()
    {
        Console.WriteLine("Hello World!");
    }
}

首先,任何使用了 await 关键字的方法都是异步方法(由 async 修饰的方法),需要使用 async 进行修饰,反过来,任何使用了 async 修饰的方法,都一定存在至少一个 await,任何异步方法的名称都推荐以 Async 结尾,async和await的搭配形成了嵌套,然后一层一层地执行下去。具体来说,上面的 Test 方法执行顺序如下:

  • 程序创建一个 HttpClient 实例,同时调用了 GetStringAsync 异步方法,该方法立即返回一个Task<String>(这时网页获取不一定结束,回忆一下,Task承诺了在之后给出该方法的返回值)
  • 在调用 GetStringAsync 方法时,程序已经通知系统发送这个网络请求,系统机制会再通知硬件处理这个网络请求
  • 在网络请求发出之后,程序继续往下执行,调用了 SomeWork 方法,输出 “Hello World”,注意这个方法是独立于 task 的
  • 当遇到 await 关键字时,代表程序希望得到返回值(而不是通过 Result 属性),此时有两种情况:若网络请求已经完成,那么程序立即获取返回值,并赋值给 src 变量,输出长度,异步方法结束。
  • 若网络请求还未完成,那么程序将执行权移交到当前异步方法的调用方,也就是 Main 方法,Main 方法里其实也是一样的道理,输出 “Main”,并等待 ptask(若 Main 方法还被另一方法调用了,那么执行权会继续转交给调用方),而ptask在等待task,当task结束后,程序会回到 TestAsync 原来的代码处继续执行。

故上面的代码输出为:

Hello World!
Main
23808

另外一个需要注意的是,在遇到 await 之前,这个方法都是同步执行的,即 “Hello World!” 总是先于 “Main” 输出。

可以看到,使用async和await实现异步,关键是下面几点:

  • 调用异步方法后不会阻塞,而是立即返回一个 Task,这个 Task 承诺在之后给出异步方法的返回情况和返回值
  • 在调用异步方法之后,使用await之前,程序可以执行一些独立的工作,这些工作不需要用到异步方法的返回值
  • 使用await等待并获取异步方法的结果,而不是 Result 属性或者 Wait 方法。使用await等待异步方法时,程序会将执行权转交给当前异步方法的调用方,从而让程序等待的同时可以执行其他代码

如果你不需要在await之前执行独立的工作,可以直接使用下面这种更简洁的写法:

/*Task ptask = p.TestAsync();
Console.WriteLine("Main"); 
await ptask;*/
//更简洁的写法:
await p.TestAsync();

上面讲到的关于 Task 的常用方法,搭配 await 使用更加推荐:

await Task.WhenAll
await Task.WhenAny
await Task.Delay

在程序进行I/O处理,或者UI事件响应时,使用async/await是十分高效率的。

async/await ≠ 多线程

在介绍TAP时,我们可以看到Task执行的时候是使用了ThreadPool的空闲线程的,而async/await则有一些不同。我们需要先了解一下异步操作的两种类别:

  • I/O关联的异步操作(IO-bound Operation):在硬件(如硬盘、网卡)进行,不需要线程,也不占用CPU,如读取网络资源或文件
  • CPU关联的异步操作(CPU-bound Operation):需要大量使用CPU,需要其他线程,如长时间的计算

以UI程序为例,使用异步可以防止界面假死,而下图展现了上面两种情况执行时的流程:

如果 I/O 关联的异步操作不使用其他线程,那么它是如何做到异步呢?简单地说,由于读取网络资源或者读取文件的操作可以由特定的硬件完成,而不依赖CPU,所以 .NET 通过向操作系统发送对应的请求(通过较低层次的.NET API 实现),由系统通知硬件进行处理,在请求的同时,.NET 向操作系统写入了一个回调函数(委托),在硬件处理完任务并通知操作系统时,操作系统会执行这个回调函数,这个函数会改变 Task的状态, 同时 .NET 会从线程池中获取一个空闲线程(这个线程可能与异步调用时的线程相同,也可能不同),这个线程得以在原来的地方继续执行,但这个线程仅仅用于上下文切换,而不专门用 I/O 操作。如果感兴趣,可以参考这篇文章:There Is No Thread

即便是 CPU 关联的异步操作,.NET也是从 ThreadPool 获取空闲线程,而不总是创建新的线程(因为这样开销很大),ThreadPool 是 .NET 内部的一个静态类,这个类维护着一定数量的线程,在 .NET 需要时被使用,例如进行异步操作,一旦线程执行完分配的任务,会立即进入休眠状态,这样的设计相比重复的创建新线程来说,更加高效。

在实际使用 await/async 中,是否需要使用另一个线程,还由其他因素决定,例如当前环境下的 SynchronizationContext 类,它规定了上下文切换时的一些配置。但总的来说,基于 await/async 的异步编程是不会主动去创建新线程的,这与多线程编程十分不同,前者性能也更好。相比直接创建Task并通过Run等方法(这些方法总是会使用其他线程)执行而言,await/async 也更进一步,代码也更加简洁。

可以看出,在需要大量执行 I/O 操作时,await/async 有着巨大的优势,特别是网络服务器接受大量需要进行IO的请求时,await/async 能在IO时释放线程,让服务器有限的线程得以接受更多的网络请求,而不是造成阻塞,这有利于增加服务器的吞吐量。