本文主要介绍了在 C# 中使用 Async 和 Await 关键字进行异步编程的心得,是入门级的学习笔记。
题解:关于「再次」这个说法,是因为前几次学习都失败了,这要怪微软在 MSDN 上给出的那张执行顺序图,实在是拧麻花。光是弄清楚所谓的「不阻塞 UI 线程,立即返回,等到执行结束后继续后面的操作」这句话是什么含义,我就折腾了好久。
其实 C# 5.0 推出的异步,是个「人人用了都说好」的事情,极大地简化了多线程的实现方式,所以应该更接地气才对的。只不过,众多教程都是秉持科学严谨专业的态度,不能给初学者一个直观的感受,让初学者一下子透彻地理解——异步时,电脑究竟在干什么。
在「终于」学会异步之时,激动地想要实现化繁为简的伟大事业,遂撰此文。只讲故事,不说技术细节。
文章结构
- 前言
- 0x00 异步是个什么东西
- 0x01 如何编写异步代码
- 0x02 异步代码的执行顺序(★)
- 0x03 一些问题的解答
- 0x04 本文没有介绍的内容
- 参考
0x00 异步是个什么东西
哦,异步(Asynchrony)就是「非同步」(A-Synchrony)
这里你要知道,英语中 a(n) 作为前缀,是可以表示 not 或 without 的,所以「不同步性」、「没有同步性」就是「异步性」了)。
中文里,「同步」给人的感觉是「同时进行的事情」,例如「挖土和盖楼两件事情同步进行中」,表示的是我们在挖土的同时也在盖大楼。然而在码农的世界里,「同步」的意思其实是「序贯执行」,说人话就是「一个接一个地有序执行」。例如你写了 5 件事情在任务清单上,你必须得做完第一件再做第二件,顺序不可跳跃。
这带来一个问题:当上一项任务没有完成时,下一项任务无法开始。那么当你有 30 分钟烧开水、10 分钟洗茶杯、10 分钟洗茶壶、10分钟找到茶叶这四件事情要完成时,你无法先去把水烧上,等它烧开的同时,洗杯子,洗壶,找茶叶,只能傻等到水烧开了之后再去做剩下的三件事。如此一来,总共可以在 30 分钟内完成的事情,拖拖拉拉到 60 分钟才能搞定,实乃人力物力财力的巨大浪费。
为了让电脑聪明起来,提高效率,人们发明了「异步」的概念。所以其实「异步」才真正意味着「同时进行」的事情,可以理解为「你去挖土,我来盖楼」,如此我们「各干各的,分工相异,同时进行,完事儿后一起交差」。放到沏茶的例子里,就是在烧开水的同时,去洗杯子,洗壶,找茶叶,总共 30 分钟完成。
放在计算机上来讲是这样:WinForm 程序启动时,只有一个线程,即 UI 线程。此时所有的工作都是由 UI 线程来处理的,当工作量较小时,一瞬间即可完成,用户不会觉得有任何异样;而当假设完成一件巨型计算量的工作需要 30 分钟时,这个线程就会拼命地不停地去计算这个结果,而无暇顾及用户对 UI 的操作,导致 UI 卡死无响应。这种情况是要极力避免的,任何时候都应当以向用户提供实时操作反馈为第一目标,所以那些个极其耗费计算资源的事情,应该扔到后台去做。
这听起来像是「多线程」?的确,异步其实是多线程编程的一种实现方法。与传统方法相比,异步在代码写法、实现方式、管理复杂度和异常处理方面更加便捷而高效。并且,异步代码的写法,「看上去就像同步的代码一样」,简单而直接,因而被赋予了这样一个地位极高的名字。当然了,异步的本质仍然逃不开多线程,无论是调用别人的异步方法,还是编写自己的异步方法,都是要新开线程来完成工作的,单线程的异步,本质上还是同步的。不过好在,异步的引入,使得这一过程得到了极大的简化。
0x01 如何编写异步代码
关于这个,MSDN 的官方讲解应该是介绍最为完全的了(使用 Async 和 Await 的异步编程(C# 和 Visual Basic))。但遗憾的是,MSDN 本身解耦工作做得并不到位,存在严重的用术语解释术语的问题,以及出神入化的行文逻辑,让初学者越看越晕,所以我们要从一个更小的切入点开始说起。
先来建立一下对异步编程模型各个要素的认识。
- 异步方法的返回值类型有三种:void,Task 和 Task<T>。
- 使用 async 关键字修饰方法签名,表示该方法为异步方法。
- 在异步方法内使用 await 关键字来等待一个「可等待」类型,实现异步。
这样说来太抽象,用一个例子来说明。假设我们要实现这样的功能:点击一个按钮,进行一个计算量巨大的操作,要耗时 30 秒钟,计算结束后在窗口内显示计算结果。代码如下:
private void button1_Click(object sender, EventArgs e)
{
var result = doSomething();
label1.Text = result;
}
private string doSomething()
{
System.Threading.Thread.Sleep(30000);
return "result";
}
这里将当前线程挂起 30 秒,来模拟耗时 30 秒的计算过程。很显然,运行程序点击按钮后,UI 会在 30 秒内毫无响应,全身心地投入到了复杂的计算过程中。
接下来我们用异步编程的方法来改善这一问题。异步编程的核心思想是,执行异步方法,当遇到 await 关键字时,控制权立即返回给调用者,同时等待 await 语句所指代的异步方法的结束,当方法执行完毕返回结果时,接着执行 await 语句后面的代码。
放在这里就是,当点击按钮时,我们要进行巨型耗时计算,此时我们希望将控制权立刻返还给 UI,使得 UI 可以相应用户的其他操作,同时在后台进行计算工作,当得出计算结果时,我们把它显示在窗口上。
那么就按照如下方法改造之前的代码。
// 给事件处理器添加 async 关键字
private async void button1_Click(object sender, EventArgs e)
{
// 给对计算方法的调用添加 await 关键字
var result = await doSomething();
label1.Text = result;
}
// 将返回值类型改为 Task<string>
private Task<string> doSomething()
{
// 将计算操作放到一个 Task<string> 中去,新开线程
var t = Task.Run(() =>
{
// 使用 lambda 表达式定义计算和返回工作
System.Threading.Thread.Sleep(30000);
return "result";
});
// 返回这个 Task
return t;
}
现在再运行一遍,可以发现,点击按钮后计算开始运行,但是 UI 仍然可以响应用户的操作,例如对窗口的移动、缩放,和点击其他控件等等,30 秒后,计算完成,窗口上的标签控件给出了结果「result」;
关于程序的运行顺序,先按下不表,下文详谈。来说说这里几处关键的代码变动。
-
添加 async 关键字
添加 async 关键字的目的在于,将方法明示为一个异步方法,从而在其内部的 await 单词会被识别为一个关键字,如果方法签名中没有 async 关键字的话,方法体中的 await 是作为标识符来识别的,也就是说你可以定义一个名为 await 的变量,例如
string await = "hehe"
(不推荐这么做)。因而要使用 await 语句,必须在方法签名中加入 async 关键词。其实这对于编译器来说是多余的,但对于代码的可读性而言大有裨益。 -
在对 doSomething 方法的调用前添加 await 关键字
await 是异步编程的灵魂,用于等待一个「可等待」(awaitable)的对象返回值,同时向异步方法的调用者返回控制权。这里,我们使用 Task 对象来实现计算任务。
-
将计算任务的返回值更改为 Task<string>
这里,如果不了解 Task 的话,需要去补补课。这里的含义是「返回值类型为字符串的任务」。Task 本身是可等待的对象,因而可以作为 await 关键字操作的要素。这个方法是 await 要等待的任务,它本身是不需要用 async 关键字来修饰的。
-
建立新线程完成具体工作
- 用
Task.Run
方法直接将t
定义为一个新的 Task,并且立刻执行。由于 Task 本身是利用线程池在后台执行的,所以这一步是实现异步编程多线程步骤的核心。当我们撰写自己的异步实现方法(注意不是异步方法)时要进行多线程的操作,否则代码始终还是同步(按顺序)执行的。 - 变量
t
作为返回值,必须与方法签名相同,是Task<string>
类型的,但是在Task.Run
中并没有体现,而是在参数中的 lambda 表达式所体现的,因为 lambda 表达式代码块中返回了一个字符串。这里如有不明的地方,需要去补充一下关于 lambda 表达式的知识。实际上,也可以显示地将t
定义为Task<string>.Run
。
- 用
-
返回变量
t
异步实现方法
doSomething
的返回值类型是Task<string>
,为什么在调用方法中由类型为string
的变量接收返回值?这是由异步编程模型和 Task 模型内部的逻辑所决定的,更多深入的内容请参见文末的参考文献,此处不做过多介绍。
如此,我们就实现了一个简单的异步编程,不仅包含了编写异步方法,也包含了编写异步实现方法。这可能是我个人的说法:异步方法就是签名中包含 async
关键字,在方法体中包含 await
关键字,用来执行异步操作的方法;而异步实现方法就是,返回值类型为可等待的,由多线程来执行具体任务的方法。
在 .NET 4.5 中,微软提供了一批已经预先编写好的异步实现方法,例如 HttpClient 对象的 GetStringAsync 方法,其返回值是 Task<string>
类型,我们可以在使用中直接编写如下代码:
using System.Net.Http;
......
private async void button1_Click(object sender, EventArgs e)
{
var result = await new HttpClient().GetStringAsync("about:blank");
label1.Text = result;
}
这样,我们就可以十分方便地实现异步编程,无序大量的多线程处理,就可以实现后台工作和前台响应两不误。
或者,可以编写自己的异步实现方法,用来实现异步调用,如同上文的例子一样。
0x02 异步代码的执行顺序
在 Visual Studio 2012 和 2013 版的 MSDN 上,关于这个问题,微软提供了一张图,就是下面这个。
遗憾的是,虽然图上画的东西完全正确,但对于初学者来说,实在是太懵圈了。我自己在学习的时候,反复阅读也只能是有一个抽象的印象,不能建立直观的了解,不清楚这背后究竟是什么逻辑。更要命的是,这两份文档现在已经归档,不再维护,而新版的文档里一张图都没有。许多引用这张图来讲解异步编程的博客也都没能给出足够容易的表达。所以这事儿只好我自己想明白之后来做了。
先来设想一个场景:老板想吃薯条并听音乐,于是对三个员工说:「我要吃薯条,我要听音乐」。可是三个人刚听到「我要吃薯条」就立刻转身离开,去计划如何做薯条了。任务内容包括:买土豆,可能需要 10 分钟时间;准备厨具 ,这个很快就搞定;土豆买回来削皮清洗切丝下锅炸;完后就可以送回给老板了。直到这个过程结束,三个员工才会去关心老板想要听音乐的事情。
用程序来表示这个过程,代码如下:
private void 老板()
{
老板_我要吃薯条();
老板_我要听音乐();
}
private void 老板_我要吃薯条()
{
员工.执行(买土豆);
员工.执行(准备厨具);
var 薯条 = 员工.执行(处理土豆炸薯条);
老板.吃(薯条);
}
private void 老板_我要听音乐()
{
员工.执行(打开留声机播放唱片);
老板.听(爱的礼赞);
}
这个过程的问题在于,做薯条这个事情进行了 30 分钟,老板除了干等着什么都干不了,员工们也不听使唤,直到薯条炸出来了,才去解决听音乐的问题。
现在对这个过程进行异步改造,代码如下:
private void 老板()
{
老板_我要吃薯条();
老板_我要听音乐();
}
private async void 老板_我要吃薯条()
{
var 买土豆 = Task.Run(() =>
{
// 这是一个返回值类型为 Task<土豆> 的匿名方法
return 员工.执行(买土豆);
});
员工.执行(准备厨具);
var 土豆 = await 买土豆;
var 薯条 = 员工.执行(处理土豆炸薯条);
老板.吃(薯条);
}
private void 老板_我要听音乐()
{
员工.执行(打开留声机播放唱片);
老板.听(爱的礼赞);
}
现在这个故事的剧情就变成了:老板说「我要吃薯条」,于是三个员工去开始做薯条。三人觉得这个过程可以分开来做,第一个人去买土豆;第二个人在原地等着买土豆的人回来,一起炸薯条,在买来之前,这个人先行准备厨具;第三个人回去报告老板,薯条正在制作请稍等,还有没有别的事情要做。老板说「我要听音乐」,于是第三个人立马去放音乐给老板听。如此一来,老板手下的员工还听使唤,并且不必非要等到薯条做好才能听到音乐了。当薯条做好的时候,员工把做好的薯条呈送给老板,老板来吃薯条。
个人觉得这个例子直观多了:
- 一个员工去买土豆:即新生成一个 Task,利用线程池在后台执行。
- 一个员工等在原地:
await
关键字,等待这个买土豆 Task 的执行结果,当土豆买回来了,也即 Task 执行结束后,继续后面炸薯条的工作;在买来之前,也即程序遇到await
关键字之前,这个人先做准备厨具的工作。薯条炸出来之后,交给老板。 - 一个员工回去报告老板:
await
关键词,立刻向调用方返回控制权,也即,在薯条还没做好,甚至是土豆还没买来的时候,就将程序的控制权交回给老板
,执行老板的下一条语句,即老板_我要听音乐();
。
值得一提的是,这个例子中,我们没有单独编写异步实现方法,而是直接在异步方法内部定义了一个 Task,并在后面 await 之。
老板能手下有多少员工?理论上,一大群员工即线程池,由 CLR(Common Language Runtime,公共语言运行时) 根据计算机性能进行分配和管理,所以实际上可以同时执行的异步方法比三个员工这个案例多得多。
不知道这样讲述下来,关于异步的执行顺序是否会更加清晰而具体。
0x03 一些问题的解答
-
异步一定能提高效率吗?
不一定。异步本质上还是多线程,只是简化多线程的实现方式。至于使用多线程编程时能否提高程序执行效率,取决于 CPU 核心数,计算任务的复杂度以及该项任务本身是否适合被切分为并行计算模块。过于频繁地将不适合并行计算的任务拆分成异步编程中去,反而会导致密集计算性能的下降,因为此时线程池会疲于应对大量的线程调度操作。
-
有 async 一定要有 await 吗?
不一定。在标记为 async 的方法中,不必须出现 await 关键字,只是若没有 await 关键字,这个方法不是真正意义上的异步方法,它会与普通方法一样是同步执行的。编译器不会报错,但会给出提示。
相反,若要使用 await 关键字,则必须在方法签名中包含 async 关键字。否则 await 将被当做标识符,而不能被当做一个关键字来处理。也就是说,当一个方法的签名中不包含 async 关键字时,你甚至可以在方法体中把 await 作为变量名。但这种操作是极其不推荐的,很容易造成误导。
-
异步方法的名称一定要以「Async」为结尾吗?
不一定。这只是习惯问题,就跟微软推荐所有的自定义特性后面都以「Attributes」为结尾一样,这不是必须的,只是如果大家都这样做了,理解起来更加方便一些。具体情况取决于不同场合下的规范要求。
-
使用 Task 并且 Run 了之后就实现异步了吗?
不是,这只是进行了一次多线程操作,后面的语句还是同步执行的。直到遇见 await 关键字,随着控制权的返回,才真正能实现异步。
-
异步是线程安全的吗?
理论上是的,这也是为什么异步编程模型能够极大地简化传统多线程操作所带来的各种问题的一大原因。尽管 await 所指的对象运行在其他线程上,但其后的语句还是会在原始线程上被执行。更深层次地说,后续的语句实际上是使用 Task 的 ContinueWith 方法来实现的。所以我们大可以放心的在异步方法中修改诸如 UI 元素等由主线程管理的资源。
但是,异步编程模型只是简化了这个过程,而不能替代我们解决具体的数据同步问题。如果在 await 之后有对其他共享资源的访问,而在 await 获取执行结果之前,这些资源已经被其他线程修改,那么 await 后续语句执行时所面对的数据内容将是不可预测的。
-
异步一定是返回控制权与等待结果同时进行的吗?
第一时间返回控制权是一定的,而等待与否要看任务执行的状态。当程序遇到 await 关键字时,如果 Task 所指代的对象以极快的速度完成,那么异步方法内部就会以同步执行的方式继续向后执行 await 语句后面的操作,不会产生等待。只有当 Task 没有执行完毕时,才会进行等待。流程如下图所示。
这里有个问题,即 await 要求 Task 一定要有执行结果,如果只是声明了一个 Task,但是没有运行,await 是不会继续向后进行的。虽然编译器不会报错,但是程序会永无休止地等下去。例如下面的代码:
private asycn void doSthNoResponse()
{
var t = new Task(() => {});
await t; // 永无休止地等下去
}
新人更容易犯的是造成程序锁死(Deadlock)的事故,例如如下代码:
private void doSthDeadlock()
{
var t = new Task<string>(() => { return String.Empty; });
label1.Text = t.Result; // 锁死
}
当然了,这属于关于 Task 使用的问题,这里不做详述了,有兴趣可以参考 Stephen Cleary 的博客文章《Don't Block on Async Code》。
-
异步的循环嵌套?
这曾经是个困扰我的问题,尤其是我在看了微软给的异步方法执行流程图之后:一旦在某个节点使用了 async 关键字,那么它内部一定要包含一个异步方法,而它本身又是一个异步方法,于是乎就要一层一层又一层的都变成异步方法才行。
实际上不必,如同前文所述。但是 Jon Skeet 在《C# in Depth》中强调,如果有可能的话,要秉持着「将异步进行到底」的精神,一路异步下去,这样有助于保持程序的稳健性。但愿我理解得是对的,或者他只是想说养成这样的习惯,可以给软件开发带来更多的益处。
-
用多个放在一起的 await 等待多个任务?
不行。每一个 await 在放回控制权给调用者的同时,都是阻塞执行结果的,不能够通过多个并列的 await 语句来同时等待多个结果。例如如下代码:
private async void doSth() { var t1 = new Task.Run(() => {......}); var t2 = new Task.Run(() => {......}); var t3 = new Task.Run(() => {......}); await t1; await t2; await t3; }
这段代码的意思其实基本无异于
private async void doSth() { var t1 = new Task.Run(() => {......}); var t2 = new Task.Run(() => {......}); var t3 = new Task.Run(() => {......}); t1.Wait(); t2.Wait(); t3.Wait(); }
前者只是比后者多了控制权的返回罢了。因此,即便 t2 和 t3 在 t1 之前运行结束,程序也会一直等到 t1 运行结束才会继续。正确的做法是使用 Task 的 WhenAll 或者 WhenAny 方法处理执行结束的后续事宜。
0x04 本文没有介绍的内容
篇幅和水平所限,以下这些内容没有涉及,或者所谈很浅。如有需要了解详细内容的,应当参阅更加专业的书籍、文档和博客。
-
异常处理
本文所介绍的主体是基于任务的异步编程模式(TAP,Task-based Asynchronous Pattern),因此异常处理也是与 Task 对象高度相关联的,这需要专门去了解 Task 相关的异常捕获和处理方法,以及在异步编程下的处理方法。虽然我学习、阅读过这部分内容,但是觉得体系极为庞杂,由于水平有限,就暂时不写上来了。毕竟本文的主旨是帮助初学者快速建立对异步编程模型的认识。
-
异步的实现和编译原理
嗯,没有这部分是因为,我没太看懂……
-
很多细节
这篇文章总之是过于笼统了些,很多细节上需要注意的小问题可能无暇涉及。比如说,
Task
以及Task<T>
是最为推荐的异步实现方法返回值类型,因为可以对任务执行的状态和异常进行合理的控制。而void
类型的异步方法则多用于事件处理器(Event Handler)上。
这本是我自己想要整理的学习笔记,怕几个月之后自己又忘了,所以写得稍微啰嗦了些,希望是让小白也能轻松看懂的水平。希望能给有需求人士带来一定的帮助。
参考
- C# in Depth, 3rd edition.
- Microsoft Visual C# 2013 Step by Step
- Async and Await - Stephen Cleary
- Don't Block on Async Code - Stephen Cleary
- Asynchronous Programming - MSDN
- Task-based Asynchronous Pattern (TAP) - MSDN
- Asynchronous Programming with Async and Await (C# and Visual Basic) - MSDN
- C# Async - How does it work? - Tomas Petricek's answer - stack overflow
- await使用中的阻塞和并发 - 楼上那个蜀黍
- C#基础系列——异步编程初探:async和await - 懒得安分
- 全面解析C#中的异步编程 - 小白哥哥
- C#异步编程 - 方小白
- [C#] 谈谈异步编程async await - Never、C