C# 学习

再次学习 C# 异步,从老板吃薯条说起

2017-10-21  本文已影响779人  BossOx

本文主要介绍了在 C# 中使用 Async 和 Await 关键字进行异步编程的心得,是入门级的学习笔记。


题解:关于「再次」这个说法,是因为前几次学习都失败了,这要怪微软在 MSDN 上给出的那张执行顺序图,实在是拧麻花。光是弄清楚所谓的「不阻塞 UI 线程,立即返回,等到执行结束后继续后面的操作」这句话是什么含义,我就折腾了好久。

其实 C# 5.0 推出的异步,是个「人人用了都说好」的事情,极大地简化了多线程的实现方式,所以应该更接地气才对的。只不过,众多教程都是秉持科学严谨专业的态度,不能给初学者一个直观的感受,让初学者一下子透彻地理解——异步时,电脑究竟在干什么。

在「终于」学会异步之时,激动地想要实现化繁为简的伟大事业,遂撰此文。只讲故事,不说技术细节。


文章结构


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 本身解耦工作做得并不到位,存在严重的用术语解释术语的问题,以及出神入化的行文逻辑,让初学者越看越晕,所以我们要从一个更小的切入点开始说起。

先来建立一下对异步编程模型各个要素的认识。

这样说来太抽象,用一个例子来说明。假设我们要实现这样的功能:点击一个按钮,进行一个计算量巨大的操作,要耗时 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」;

关于程序的运行顺序,先按下不表,下文详谈。来说说这里几处关键的代码变动。

  1. 添加 async 关键字

    添加 async 关键字的目的在于,将方法明示为一个异步方法,从而在其内部的 await 单词会被识别为一个关键字,如果方法签名中没有 async 关键字的话,方法体中的 await 是作为标识符来识别的,也就是说你可以定义一个名为 await 的变量,例如 string await = "hehe"(不推荐这么做)。因而要使用 await 语句,必须在方法签名中加入 async 关键词。其实这对于编译器来说是多余的,但对于代码的可读性而言大有裨益。

  2. 在对 doSomething 方法的调用前添加 await 关键字

    await 是异步编程的灵魂,用于等待一个「可等待」(awaitable)的对象返回值,同时向异步方法的调用者返回控制权。这里,我们使用 Task 对象来实现计算任务。

  3. 将计算任务的返回值更改为 Task<string>

    这里,如果不了解 Task 的话,需要去补补课。这里的含义是「返回值类型为字符串的任务」。Task 本身是可等待的对象,因而可以作为 await 关键字操作的要素。这个方法是 await 要等待的任务,它本身是不需要用 async 关键字来修饰的。

  4. 建立新线程完成具体工作

    1. Task.Run 方法直接将 t 定义为一个新的 Task,并且立刻执行。由于 Task 本身是利用线程池在后台执行的,所以这一步是实现异步编程多线程步骤的核心。当我们撰写自己的异步实现方法(注意不是异步方法)时要进行多线程的操作,否则代码始终还是同步(按顺序)执行的。
    2. 变量 t 作为返回值,必须与方法签名相同,是 Task<string> 类型的,但是在 Task.Run 中并没有体现,而是在参数中的 lambda 表达式所体现的,因为 lambda 表达式代码块中返回了一个字符串。这里如有不明的地方,需要去补充一下关于 lambda 表达式的知识。实际上,也可以显示地将 t 定义为 Task<string>.Run
  5. 返回变量 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 上,关于这个问题,微软提供了一张图,就是下面这个。

What Happens in an Async Method

遗憾的是,虽然图上画的东西完全正确,但对于初学者来说,实在是太懵圈了。我自己在学习的时候,反复阅读也只能是有一个抽象的印象,不能建立直观的了解,不清楚这背后究竟是什么逻辑。更要命的是,这两份文档现在已经归档,不再维护,而新版的文档里一张图都没有。许多引用这张图来讲解异步编程的博客也都没能给出足够容易的表达。所以这事儿只好我自己想明白之后来做了。

先来设想一个场景:老板想吃薯条并听音乐,于是对三个员工说:「我要吃薯条,我要听音乐」。可是三个人刚听到「我要吃薯条」就立刻转身离开,去计划如何做薯条了。任务内容包括:买土豆,可能需要 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 之。

老板能手下有多少员工?理论上,一大群员工即线程池,由 CLR(Common Language Runtime,公共语言运行时) 根据计算机性能进行分配和管理,所以实际上可以同时执行的异步方法比三个员工这个案例多得多。

不知道这样讲述下来,关于异步的执行顺序是否会更加清晰而具体。

0x03 一些问题的解答

Await handing via the awaitable pattern

这里有个问题,即 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》。

0x04 本文没有介绍的内容

篇幅和水平所限,以下这些内容没有涉及,或者所谈很浅。如有需要了解详细内容的,应当参阅更加专业的书籍、文档和博客。


这本是我自己想要整理的学习笔记,怕几个月之后自己又忘了,所以写得稍微啰嗦了些,希望是让小白也能轻松看懂的水平。希望能给有需求人士带来一定的帮助。


参考

上一篇 下一篇

猜你喜欢

热点阅读