关于C# async/await的一些说明
关于C# async/await的一些说明
下文以个人对async/await的理解为基础进行一些说明。
1、自定义的几个关键概念
- 调用流阻塞:不同于线程阻塞,调用流阻塞只对函数过程起作用,调用流阻塞表示在一次函数调用中,执行函数代码的过程中发生的无法继续往后执行,需要在函数体中的某个语句停止的情形;
- 调用流阻塞点:调用流阻塞中,执行流所停下来地方的那条语句;
- 调用流阻塞返回:不同于线程阻塞,调用流发生阻塞的时候,调用流会立即返回,在C#中,返回的对象可以是Task或者Task<T>;
- 调用流阻塞异步完成跳转:当调用流阻塞点处的异步操作完成后,调用流被强制跳转回调用流阻塞点处执行下一个语句的情形;
- async传染:指的是根据C#的规定:若某个函数F的函数体中需要使用await关键字的函数必须以async标记,进一步导致需要使用await调用F的那个函数F'也必须以async标记的情况;
- Task对象的装箱与拆箱:指Task<T>和T能够相互转换的情况。
- 异步调用:指以await作为修饰前缀进行方法调用的调用形式,异步调用时会发生调用流阻塞。
- 同步调用:指不以await作为修饰前缀进行方法调用的调用形式,同步调用时不会发生调用流阻塞。
2、async/await的使用场景
async/await用于异步操作。
在使用C#编写GUI程序的时候,如果有比较耗时的操作(如图片处理、数据压缩等),我们一般新开一个线程把这些工作交给这个线程处理,而不放到主线程中进行操作,以免阻塞UI刷新,造成程序假死。
传统的做法是直接使用C#的Thread类(也存在别的方式,参考这篇文章)进行操作。传统的做法在复杂的应用编写中可能会出现回调地狱的问题,因此C#目前主要推荐使用async/await来进行异步操作。
async/await通过对方法进行修饰把C#中的方法分为同步方法和异步方法两类,异步方法命名约定以Async结尾。但是需要注意的是,在调用异步方法的时候,并非一定是以异步方式来进行调用,只有指定了以await为修饰前缀的方法调用才是异步调用。
3、async/await的调用过程
考虑以下C#程序:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
TestMain();
}
static void TestMain()
{
Console.Out.Write("Start\n");
GetValueAsync();
Console.Out.Write("End\n");
Console.ReadKey();
}
static async Task GetValueAsync()
{
await Task.Run(()=>
{
Thread.Sleep(1000);
for(int i = 0; i < 5; ++i)
{
Console.Out.WriteLine(String.Format("From task : {0}", i));
}
});
Console.Out.WriteLine("Task End");
}
}
}
在我的计算机上,执行该程序得到以下结果:
Start
End
From task : 0
From task : 1
From task : 2
From task : 3
From task : 4
Task End
下面来分析该程序的执行流程:
- Main()调用TestMain(),执行流转入TestMain();
- 打印Start
- 调用GetValueAsync(),执行流转入GetValueAsync(),注意此处是同步调用;
- 执行Task.Run(),生成一个新的线程并执行,同时立即返回一个Task对象;
- 由于调用Task.Run()时,是以await作为修饰的,因此是一个异步调用,上下文环境保存第4步中返回的Task对象,在此处发生调用流阻塞,而当前的调用语句便是调用流阻塞点,于是发生调用流阻塞返回,执行流回到AysncCall()的
GetValueAsync()
处,并执行下一步;
第5步之后就不好分析了,因为此时已经新建了一个线程用来执行后台线程,如果计算机速度够快,那么由于新建的线程代码中有一个Thread.Sleep(1000);
,因此线程会被阻塞,于是主线程会赶在新建的线程恢复执行之前打印End然后Console.ReadKey()
。在这里我假设发生的是这个情况,然后进入下面的步骤。
- 新的线程恢复执行,打印0 1 2 3 4 5,线程执行结束,Task对象的IsCompleted变成true;
- 此时执行流(强制被)跳转到调用流阻塞点,即从调用流阻塞点恢复执行流,发生了调用流阻塞异步完成跳转,于是打印
Task End
; - 程序执行流结束;
仔细研究以上流程,可以发现async/await最重要的地方就是调用流阻塞点,这里的阻塞并不是阻塞的线程,而是阻塞的程序执行流。整个过程就像是一个食客走进一间饭馆点完菜,但是厨师说要等半个小时才做好(调用流阻塞),于是先给这个食客开了张单子(调用流阻塞点)让他先去外面逛一圈(调用流阻塞返回),等时间到了会通知他然后他再拿这张票来吃饭(调用流阻塞异步完成跳转);整个过程中这个食客并没有在饭馆做下来等(线程阻塞),而是又去干了别的事情了。在这里,await就是用来指定调用流阻塞点的关键字,而async则是用来标识某个方法可以被调用流阻塞的关键字。
4、假如不用await?
如果我们不使用await异步调用方法F的话,那么方法F将会被当成同步方法调用,即发生同步调用,这个时候执行流不会遇到调用流阻塞点,因此会直接往下执行,考虑上面的代码如果写成:
static async Task GetValueAsync()
{
Task.Run(()=>
{
Thread.Sleep(1000);
for(int i = 0; i < 5; ++i)
{
Console.Out.WriteLine(String.Format("From task : {0}", i));
}
});
Console.Out.WriteLine("Task End");
}
那么执行流不会在Task.Run()
这里停下返回,而是直接“路过”这里,执行后面的语句,打印出Task End,然后和一般的程序一样返回。当然新的线程还是会被创建出来并执行,但是这种情况下的程序就不会去等Task.Run()
完成了。在我的计算机上输出的结果如下:
Start
Task End
End
From task : 0
From task : 1
From task : 2
From task : 3
From task : 4
5、async传染与病源隔断方法
根据C#的规定:若某个函数F的函数体中需要使用await关键字则该函数必须以async标记,此时F成为异步方法,于是,这会导致这样子的情况:需要使用await调用F的那个函数F'也必须以async标记。
这个现象我称之为async传染。
同时,C#又规定,Main函数不能够是异步方法,这意味着至少在Main函数中是不能够出现await异步调用的,进一步说明了任何的异步调用都是同步调用的子调用,而调用异步方法的那个方法我称之为病源隔断方法,因为在这里开始,不再会发生async传染。
而在病源隔断方法中,一般会在其他操作完成之后去等待异步操作完成:
// 病源隔断方法
void M()
{
var task = F();
DoSomething();
if(task.IsCompleted)
{
// 类似Thread的join()方法
task.Wait();
}
}
// 异步方法
async Task F()
{
await DoAsync();
}
5、如果异步方法要返回值?
在上面的例子中,异步方法都是返回的Task,表示没有返回值。而如果要返回值的话,那么就简单地把Task换成Task<T>就行了,其中T是你的返回值的类型。
C#的Task<T>会自动和T完成装箱拆箱操作。也就是说如果异步方法F返回Task<int>对象,那么当异步方法完成的时候,它会自动变成int,整个过程由编译器完成:
void async M()
{
int r = await F();
}
// 异步方法
async Task<int> F()
{
await DoAsync();
return 0;
}
这里说C#会自动完成Task<int>到T的装箱和拆箱事实上是不严谨的,因为编译器为我们隐藏了很多细节,这里只是“看起来”像是有这么个过程,但实质上并非如此。
事实上异步方法的返回值声明声明的只是调用阻塞返回值,并不是异步方法执行完成后的真正返回值。造成这个事实的主要原因是存在调用阻塞返回和真实方法返回两个返回值,前一个是“临时”的,而后一个是“执行完成后”的,因此我们可以认为Task<int>对应的是调用阻塞返回的返回值,而T这对应的是真实方法返回的返回值。
我们可以把M进行改写,事实上编译器是为我们做了类似下面这样子的工作:
void M()
{
int r;
Task<int> t = 获取调用F()时的调用阻塞点的Task<int>对象;
t.OnCompleted += () => {
r = (int)t.Value;
};
t.Wait();
}
6、异步方法的定义约束
首先要明白的一点,就是async/await是不会主动创建线程(Task)的,创建线程的工作还是交给程序员来完成;async/await说白了就只是用来提供阻塞调用点的关键字而已。
因此,如果我们要定义一个异步方法,那么至少要保证:
- 在异步方法的调用中会出现新的线程(Task),无论调用层数有多深;
- 一个新线程(Task)应该有且仅有一个阻塞调用点;
- 异步方法嵌套调用的时候, 每个嵌套调用的异步方法内部至少要调用一个异步方法或者await一个返回值为Task的同步方法。
7、一个容易误解的地方
考虑以下代码:
async int M()
{
return await F();
}
其中F()是一个异步方法,它返回的是Task<int>对象。
这段代码事实上等价于:
async int M()
{
int r = await F();
return r;
}
注意和
async Task<int> M()
{
return F();
}
区分。后面这段代码是一个同步方法,它只会返回F()的真实返回值。