C#杂记之肆:异步

前方高能!
这篇笔记断断续续花了近10天,思路、结构较为混沌,如果没有充分做好心理准备,建议立刻关闭窗口。
(其实就是我写完实在不愿审阅)

首先说明,异步是极为高深的高级编程技术,这篇文章只是才疏学浅的我阅读文档之后的一些思考与理解,可能有极多描述不准确乃至完全错误之处,还望批评指导。顺便吐槽,C#文档翻译得实在是太烂了,很多地方一看就是机翻的,却没有机翻警告。很多地方百思不得其解的词,切换到英语就发现其实很简单,翻译的用词完全错误,难受啊!

何为异步

与异步相对的是“同步”,代表着一句代码的执行结束是其后一句代码的执行开始,整个流程是按照固定的顺序进行的,这是一般程序的逻辑。然而,某些语句可能需要花较长时间才能得到结果——比如网络通信和文件读写——这些语句的执行并不怎么占用CPU,但会阻塞整个线程,后续的语句需要等待这些语句执行完成,造成大量的浪费。特别是如果网络环境很不好、读取的文件好几个G,那可是尤其要命,可能整个程序都会出现肉眼可见的卡顿甚至崩溃。

为了解决这种飞快的CPU与龟爬的IO之间的不平衡,传统的方案是单独开个线程让它去执行耗时的操作,让主线程继续运行与耗时操作的结果无关的代码。但是线程操作很麻烦、开销很高,线程数量也有限,不是能随便使用的,所以依然不是个完美的方法。

异步就是为了解决这个问题而存在的。当代码需要执行一个耗时的操作时,它只发出指令,并不等待结果,然后就去执行其他代码了。一段时间后,当耗时操作返回结果时,再通知CPU进行处理。

怎么使用

从C# 5开始,编写异步程序变得比较容易。

C#的异步编程模型主要通过两个关键字来使用:asyncawait

用早餐毁掉美好的一天

先来一段同步的憨憨代码,这描述了一个“做早餐”的流程:

  1. 倒一杯咖啡。
  2. 加热平底锅,然后煎两个鸡蛋。
  3. 煎三片培根。
  4. 烤两片面包。
  5. 在烤面包上加黄油和果酱。
  6. 倒一杯橙汁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void Main(string[] args)
{
Coffee cup = PourCoffee(); //倒咖啡
Console.WriteLine("coffee is ready");
Egg eggs = FryEggs(2); //煎蛋
Console.WriteLine("eggs are ready");
Bacon bacon = FryBacon(3); //煎培根
Console.WriteLine("bacon is ready");
Toast toast = ToastBread(2); //烤面包
ApplyButter(toast); //涂黄油
ApplyJam(toast); //涂果酱
Console.WriteLine("toast is ready");
Juice oj = PourOJ(); //倒橙汁
Console.WriteLine("oj is ready");

Console.WriteLine("Breakfast is ready!");
}

线性的逻辑很简单,但有大问题。煎蛋、煎培根和烤面包都要花很长时间,其实完全可以在做煎蛋和培根的同时,让面包自己在面包机里烤。不然还没等面包烤完,别的东西早凉透了,吃了可能还会引起肠胃不适。煎蛋和煎培根甚至也没必要一前一后——谁家还没有两个锅呢?或者找个大点的锅来一起煎也行啊。

异步做早餐

来看看一个近乎完美的“做早餐解决方案”是怎样的。下面是C#文档里的示范异步代码,我擅自添加了些方便理解的沙雕注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
static async Task Main(string[] args)
{
Coffee cup = PourCoffee(); //倒杯咖啡
Console.WriteLine("coffee is ready");
var eggsTask = FryEggsAsync(2); //得煎俩鸡蛋,开整!
var baconTask = FryBaconAsync(3); //得煎仨培根,开整!
var toastTask = MakeToastWithButterAndJamAsync(2); //得搞两片涂黄油和果酱的面包,开整!

var allTasks = new List<Task>{eggsTask, baconTask, toastTask}; //列个任务清单吧
while (allTasks.Any()) //看来清单里还有任务没搞完
{
Task finished = await Task.WhenAny(allTasks); //下一个完成的任务是啥?
if (finished == eggsTask) //噢,是鸡蛋煎好了!
{
Console.WriteLine("eggs are ready");
}
else if (finished == baconTask) //噢,是培根煎好了!
{
Console.WriteLine("bacon is ready");
}
else if (finished == toastTask) //噢,是面包做完了!
{
Console.WriteLine("toast is ready");
}
allTasks.Remove(finished); //行,把完成的任务从清单里划了
}
Juice oj = PourOJ(); //最后再来杯橙汁,完事
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!"); //爽到

async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number); //要处理面包得先等面包烤好
ApplyButter(toast); //涂点黄油
ApplyJam(toast); //涂点果酱
return toast;
}
}

粗略一看,握草,感觉魔改成异步之后复杂了好多。其实不然,我慢慢来分析。

定义异步方法

1
2
3
4
5
var eggsTask = FryEggsAsync(2);     //得煎俩鸡蛋,开整!
var baconTask = FryBaconAsync(3); //得煎仨培根,开整!
var toastTask = MakeToastWithButterAndJamAsync(2); //得搞两片涂黄油和果酱的面包,开整!

var allTasks = new List<Task>{eggsTask, baconTask, toastTask}; //列个任务清单吧

可以注意到,这一段使用的几个方法,其名称都在后面加了个“Async”,这是异步方法的命名规范。调用三个异步方法,就是设置了三个任务(Task)。那谁谁,照这个任务去执行吧!我要接着跑后面的代码了。

那么,怎么定义一个异步方法呢?来看上面示例中的这个方法:

1
2
3
4
5
6
7
async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number); //要处理面包得先等面包烤好
ApplyButter(toast); //涂点黄油
ApplyJam(toast); //涂点果酱
return toast;
}

首先,方法定义的前面有async修饰符,这直接说明了此方法是异步(asynchronous)的。

异步方法返回的是Task<TResult>类型,代表着这个任务(Task)的结果是TResult所代表的类型。比如一个返回类型为Toast的同步方法,其异步版本的返回类型就应该是Task<Toast>,代表着一个“烤面包(Toast)”的任务(Task)。

async异步方法的存在,要依靠包含await运算符的表达式或语句。反过来说,异步方法里必须得有await的存在。 上面的代码里,Main方法本身也是被async修饰的,就是因为Main方法里也用到了await语句。

设置前置条件

async是用来定义异步方法的,那await是干什么的呢?按照字面意思来理解就好,“等待”。异步方法运行到这里就开始等待,直到await的操作数所表示的异步操作(一个Task<TResult>)执行完毕。一旦异步操作完成,await运算符就从Task<TResult>的任务里,获取任务的最终结果,以TResult类型返回,然后再继续运行后续代码。 这说明await所等待的任务的结果是后续操作的前置条件。上面这个异步方法第一句就在await,这是因为后面抹黄油和果酱的流程都必须建立在“烤完面包”的前置条件上。

然而,我们希望优化的耗时操作往往本身不是异步的。await只能接异步操作,怎么和非异步的耗时操作结合呢?比如说有这样一个“根正苗红”的同步函数:

1
2
3
string Foo(int bar){
Wtf();
}

要让它的执行完成作为前置条件,就得把它改造成一个返回Task<string>的函数。但是如果原函数是不允许修改的呢?依然有办法,只要用Task.Run(() => Foo(bar))来调用就好了。

Task.Run()可以把以下四种委托包装成任务,其中常用的是前两种:

  1. Action
  2. Func<TResult>
  3. Func<Task>
  4. Func<Task<TResult>>

由于Lambda表达式也是委托,完全可以构造一个符合上述种类的Lambda表达式。() => Foo(bar)没有参数,返回类型为string,是一个受到支持的Func<string>委托。因此在被Task.Run()包装之后,它就能成为一个Task<string>,作为异步过程中的前置条件。

使用连接符

再来看中间的这一段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (allTasks.Any())
{
Task finished = await Task.WhenAny(allTasks);
if (finished == eggsTask)
{
Console.WriteLine("eggs are ready");
}
else if (finished == baconTask)
{
Console.WriteLine("bacon is ready");
}
else if (finished == toastTask)
{
Console.WriteLine("toast is ready");
}
allTasks.Remove(finished);
}

allTasks.Any()使用List<T>Any()方法检查列表里是否还有剩余的元素,即当还有未完成的任务时,令程序一直在此循环,这实际上类似于一个消息循环机制。

Task.WhenAny(allTasks)有点儿特殊,它是一个Task<Task>,即“结果是任务”的任务——在allTasks里的某一个任务完成时,它把这个刚刚完成的任务作为自己的结果。这样,await运算符就提取出了那个刚刚完成的任务,将它赋给finished,接下来就可以作出相应的反应了。别忘了在任务处理完之后,把这个完成了的任务从任务列表里删去。

另有一个类似的方法是Task.WhenAll(),它所创建的任务在参数所表示的一系列任务都完成时才完成。命名空间System.Threading.Tasks里的这类辅助处理多个任务的方法被称为“连接符”。

以上就是一种基于任务的异步模式(TAP):把多个任务一齐启动,记录在任务列表里,然后用await、循环和连接符来等待所有任务的完成。对于每个任务本身,如果具有一些前置条件,就依然用await来等待其前置条件的完成。

扩展:线程池

至此,虽然我们知道了怎么把同步代码改造成异步的,但其中的原理是什么呢?

异步的关键是定义async异步方法和用await进行必要的流程控制,而这两个关键字都是围绕Task<TResult>展开的。因此要更深入地理解异步,就要了解Task<TResult>的工作机制。

Task工作的基础是线程池,C#中的线程池是命名空间System.Threading中的ThreadPool静态类。关于线程池和多线程又可以事无巨细地写一篇长文,这里先作简要的记录。

每一个进程都可以拥有一个线程池,里面有预先创建好的一大堆空闲线程。当一个任务需要用线程处理的时候,就无需临时单独创建一个线程,可以直接从线程池里找一个空闲的来跑。跑完也不用费劲将线程销毁,它会被线程池回收,等待其它任务来利用。假如要处理的任务太多,导致线程池里所有的线程都在忙碌,线程池可能会创建新线程,也可能让任务等待空闲线程的出现。

线程池也有缺点。由于池里线程的一切工作都是由CLR托管的(这又是一个天坑),虽然用起来是简单了,但你无法对这些线程进行具体的操作,一旦把任务加进队列里也没法取消(不要停下来啊!)。不过都要用上线程池了,那不是一把梭跑就完事了嘛,谁还希望让任务停下来呢?

使用线程池的方法是利用QueueUserWorkItem()方法:

1
2
3
4
5
//ThreadPool.QueueUserWorkItem 方法

public static bool QueueUserWorkItem (System.Threading.WaitCallback callBack);

public static bool QueueUserWorkItem (System.Threading.WaitCallback callBack, object state);

这个方法把一项工作添加到线程池的工作队列中,一旦线程池有空闲线程,就会从队列里取出工作来执行。

其中,WaitCallback是一个委托,它接收object作为参数,并返回void,类似Action<object>

1
2
[System.Runtime.InteropServices.ComVisible(true)]
public delegate void WaitCallback(object state);

注意,由于委托参数的逆变性(我靠,居然在这里用上了前面学的逆变),这里仅能使用object或派生程度更低的类型的参数。由于object已经是最终基类,故只能用它。

object可是啥都没有啊?如果要安排一个带有任务信息的委托作为任务,怎么把信息封在里面?

为了解答这个问题,我们可以先看QueueUserWorkItem的后一个重载。参数除了WaitCallback类型的委托callBack之外,还有一个object state,它就是委托调用时使用的参数。由于协变性,你可以把任何类型视作object,也就可以在调用委托时传入任意类型的参数。而前一个重载的参数列表中没有object state,是给无需在调用时传入参数的委托使用的,类似Action

如此一来就很明白了,支持添加到线程池工作队列里的工作是这样的函数:返回类型为void,参数列表中有0或1个任意类型的参数。

未竟之旅

原本这里应该有更多关于Task和await工作原理的内容,但当我在C#文档的迷宫中走得愈发深入时,我发现我的理解被不断推翻,比如说用async和await实现的异步实际上是单线程的,但它们所围绕的Task又和用线程池实现的多线程密不可分。

我试图理清这一切到底是如何工作的,这时回调函数、消息循环、线程调度、编译原理这几只怪物都跑了出来,甚至将一切都指向了JavaScript,告诉我是时候敬而远之了。

因此,目前这篇笔记是十分没有深度的一篇,它有太多可以写也需要写的内容了。C#我还有更多的东西一知半解,目前在此考据技术细节是不明智的。但过段时间我还会继续刨根究底,现在将非常具有参考价值的一篇StackOverflow问题链接贴在这里:

If async-await doesn’t create any additional threads, then how does it make applications responsive?

以及之前我画的理解错误的示意图:

BreakfastAsync.png

下一步,应该是回过头去搞清楚集合、列表、数组,还有相关的接口和索引器啥的。