C#杂记之肆:异步
前方高能!
这篇笔记断断续续花了近10天,思路、结构较为混沌,如果没有充分做好心理准备,建议立刻关闭窗口。
(其实就是我写完实在不愿审阅)
首先说明,异步是极为高深的高级编程技术,这篇文章只是才疏学浅的我阅读文档之后的一些思考与理解,可能有极多描述不准确乃至完全错误之处,还望批评指导。顺便吐槽,C#文档翻译得实在是太烂了,很多地方一看就是机翻的,却没有机翻警告。很多地方百思不得其解的词,切换到英语就发现其实很简单,翻译的用词完全错误,难受啊!
¶何为异步
与异步相对的是“同步”,代表着一句代码的执行结束是其后一句代码的执行开始,整个流程是按照固定的顺序进行的,这是一般程序的逻辑。然而,某些语句可能需要花较长时间才能得到结果——比如网络通信和文件读写——这些语句的执行并不怎么占用CPU,但会阻塞整个线程,后续的语句需要等待这些语句执行完成,造成大量的浪费。特别是如果网络环境很不好、读取的文件好几个G,那可是尤其要命,可能整个程序都会出现肉眼可见的卡顿甚至崩溃。
为了解决这种飞快的CPU与龟爬的IO之间的不平衡,传统的方案是单独开个线程让它去执行耗时的操作,让主线程继续运行与耗时操作的结果无关的代码。但是线程操作很麻烦、开销很高,线程数量也有限,不是能随便使用的,所以依然不是个完美的方法。
异步就是为了解决这个问题而存在的。当代码需要执行一个耗时的操作时,它只发出指令,并不等待结果,然后就去执行其他代码了。一段时间后,当耗时操作返回结果时,再通知CPU进行处理。
¶怎么使用
从C# 5开始,编写异步程序变得比较容易。
C#的异步编程模型主要通过两个关键字来使用:async
和await
。
¶用早餐毁掉美好的一天
先来一段同步的憨憨代码,这描述了一个“做早餐”的流程:
- 倒一杯咖啡。
- 加热平底锅,然后煎两个鸡蛋。
- 煎三片培根。
- 烤两片面包。
- 在烤面包上加黄油和果酱。
- 倒一杯橙汁。
1 | static void Main(string[] args) |
线性的逻辑很简单,但有大问题。煎蛋、煎培根和烤面包都要花很长时间,其实完全可以在做煎蛋和培根的同时,让面包自己在面包机里烤。不然还没等面包烤完,别的东西早凉透了,吃了可能还会引起肠胃不适。煎蛋和煎培根甚至也没必要一前一后——谁家还没有两个锅呢?或者找个大点的锅来一起煎也行啊。
¶异步做早餐
来看看一个近乎完美的“做早餐解决方案”是怎样的。下面是C#文档里的示范异步代码,我擅自添加了些方便理解的沙雕注释:
1 | static async Task Main(string[] args) |
粗略一看,握草,感觉魔改成异步之后复杂了好多。其实不然,我慢慢来分析。
¶定义异步方法
1 | var eggsTask = FryEggsAsync(2); //得煎俩鸡蛋,开整! |
可以注意到,这一段使用的几个方法,其名称都在后面加了个“Async”,这是异步方法的命名规范。调用三个异步方法,就是设置了三个任务(Task)。那谁谁,照这个任务去执行吧!我要接着跑后面的代码了。
那么,怎么定义一个异步方法呢?来看上面示例中的这个方法:
1 | async Task<Toast> MakeToastWithButterAndJamAsync(int number) |
首先,方法定义的前面有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 | string Foo(int bar){ |
要让它的执行完成作为前置条件,就得把它改造成一个返回Task<string>
的函数。但是如果原函数是不允许修改的呢?依然有办法,只要用Task.Run(() => Foo(bar))
来调用就好了。
Task.Run()
可以把以下四种委托包装成任务,其中常用的是前两种:
Action
Func<TResult>
Func<Task>
Func<Task<TResult>>
由于Lambda表达式也是委托,完全可以构造一个符合上述种类的Lambda表达式。() => Foo(bar)
没有参数,返回类型为string
,是一个受到支持的Func<string>
委托。因此在被Task.Run()
包装之后,它就能成为一个Task<string>
,作为异步过程中的前置条件。
¶使用连接符
再来看中间的这一段。
1 | while (allTasks.Any()) |
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 | //ThreadPool.QueueUserWorkItem 方法 |
这个方法把一项工作添加到线程池的工作队列中,一旦线程池有空闲线程,就会从队列里取出工作来执行。
其中,WaitCallback
是一个委托,它接收object
作为参数,并返回void
,类似Action<object>
。
1 | [ ] |
注意,由于委托参数的逆变性(我靠,居然在这里用上了前面学的逆变),这里仅能使用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?
以及之前我画的理解错误的示意图:
下一步,应该是回过头去搞清楚集合、列表、数组,还有相关的接口和索引器啥的。