(注:这是我在系科协写的第一篇 Weekly (面向所有同学的介绍类文章), 那就同步到这里来吧~)

一、什么是异步编程

请不要着急,在介绍异步编程前,让我们先耐心了解一下下面的几个概念,虽然可能会有些枯燥,但这对后面的学习是非常有必要的。

概念一 进程与线程、并发

进程 (process), 是运行中的可执行程序的实例。打开“任务管理器”,你会看到许多进程。如果你切换到“详细信息”选项卡,你还会看到每个进程含有的“线程”数目(如果看不到可以右键列表标题栏,在“选择列”中勾选“线程”)。那么“线程”又是什么呢?

任务管理器中显示的进程与线程信息

线程 (thread), 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。默认情况,一个进程只包含一个线程,从程序的开始到执行结束。但线程可以派生自其它线程,并且它们共享该进程所拥有的全部资源。所以一个进程可以包含不同状态的多个线程,来执行程序的不同部分,最重要的是,它们是并发执行的。

所以“并发”是什么意思呢?想一想你平常使用电脑的时候,是不是能够一边听音乐一边上网呢?这种看上去像是在同时执行的情形就是所谓的“并发”(之所以说“看上去”是因为大多数情况下各线程所含的指令只是在逻辑上是同时执行的,而在物理上并不是真正地同时执行的(即不是并行的),这涉及到现代操作系统的 CPU 调度机制,有兴趣的同学可以去了解)。

所以说,线程的并发性不仅使得我们能够同时运行不同的程序,还保证了每个程序能够通过多线程同时执行不同的任务,这是十分重要的。

概念二 同步与异步、阻塞与非阻塞

同步 (synchronous/sync), 就是发起调用后,被调用者处理消息,必须等处理完才返回结果,没处理完之前是不返回的,调用者主动等待结果。

异步 (asynchronous/async), 就是发起调用后,被调用者直接返回,但是并没有返回结果,等处理完消息后,通过状态、通知或者回调函数来通知调用者,调用者被动接收结果。

阻塞 (blocking), 就是调用结果返回之前,该执行线程会被挂起,不释放 CPU 执行权,线程不能做其它事情,只能等待,只有等到调用结果返回了,才能接着往下执行。

非阻塞 (non-blocking), 就是在没有获取调用结果时,不是一直等待,线程可以往下执行,如果是同步的,通过轮询的方式检查有没有调用结果返回,如果是异步的,会通知回调。

初学者很容易混淆同步与阻塞、异步与非阻塞这两对概念。实际上,同步和异步关注的是消息通信机制,而阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态,所以两两组合就会形成四种情况。下面引用了一个经典的例子来帮助大家理解:

  1. 同步阻塞:老张在厨房用普通水壶烧水,一直在厨房等着(阻塞),盯到水烧开(同步);
  2. 异步阻塞:老张在厨房用响水壶烧水,一直在厨房中等着(阻塞),直到水壶发出响声(异步),老张知道水烧开了;
  3. 同步非阻塞:老张在厨房用普通水壶烧水,在烧水过程中,就到客厅去看电视(非阻塞),然后时不时去厨房看看水烧开了没(轮询检查同步结果);
  4. 异步非阻塞:老张在厨房用响水壶烧水,在烧水过程中,就到客厅去看电视(非阻塞),当水壶发出响声(异步),老张就知道水烧开了。

希望你已经理解了这些概念。如果暂时不理解也不要紧,你可以随时回来查阅,或者上网搜索以便更获得更详细、深入的描述。

编程模型

下面我们来介绍异步编程模型,为了帮助大家理解,我们先看一下常见的两种编程模型。下面的阐述中,假设我们的程序包含了三个相互独立的任务。

同步模型

这是最简单的编程方式。任何时候都只有一个任务在执行,并且前一个任务结束之后后一个任务才能开始。

多线程模型

在这个模型中,每个任务都在单独的线程中完成,而这些线程都是由操作系统来管理的(在单核 CPU 上交错运行,在多 CPU/多核心上中可能会独立地运行)。多线程编程实际上是较复杂的,我们将在后面提到原因。

下面就让我们隆重地推出本文的主角——异步模型。

异步模型

在这个模型中,三个任务在一个线程中交错执行。这比多线程模型简单,因为任意时刻,编程者总可以认为只有一个任务在执行,而其它的在停止状态;但多线程的任务在逻辑上是同时执行的(尽管在物理上可能不是),这会带来一些复杂性,比如需要通过线程间通信来协调进度。而且,由于线程是由操作系统管理的,程序员无法预知什么时刻某个线程会被暂时停止或者启动。

但在异步模型中,程序员无需考虑这些问题,因为所有的任务都在一个线程中运行,并且任务的交错都是由程序员设计的。这也是异步模型相比多线程模型来说最简洁的地方。

异步模型是可以和多线程模型组合使用的,不过本文不会重点提及,有兴趣的同学可以自己钻研。

二、什么时候需要异步编程

前面说了这么多,大家是不是感觉有点没劲,怎么到现在都还没讲到异步编程能干什么呢?别着急,最枯燥的地方已经讲完了,我们马上就会提到。

异步模型背后的基本思想是,异步程序在面对同步程序中通常会阻塞的任务时,会切换到其他可以继续执行的任务。仅当没有任何任务可以执行时,异步程序会阻塞。当一个任务完成,或者到达阻塞的状态时,程序会切换到另一个任务。直白地说,异步程序能够充分利用等待的时间

请看下面这个例子。任务1、2、3已经由程序员分别划分为多个步骤,每个小块代表一个步骤,而灰色方块代表阻塞调用引起的等待。时间轴上方是同步模型,下方是异步模型。可以直观地看到,异步模型通过利用等待的时间完成其它任务,从而显著地减少了整个程序运行的时间。

Sync vs Async

总结一下,与同步模型相比,异步模型的优势在如下情况下会得到发挥:

  1. 程序有大量任务,所以任何时候总是有至少一个任务可以被执行。
  2. 任务执行大量的文件或网络 I/O 操作,这样同步模型就会因为任务阻塞而浪费大量的时间。
  3. 任务之间相互独立,以至于任务内部的交互很少。

三、C#中基于任务的异步编程 (TAP) 概述

前面的内容主要是原理性的,没有涉及到具体的编程语言。下面就让我们以 C# 为例,一起看一看 C# 中的异步编程吧!

.NET 提供了执行异步操作的三种模式:基于任务的异步模式 (TAP), 基于事件的异步模式 (EAP) 和异步编程模型 (APM) 模式。其中 TAP 是 Net Framework 4 中引入的,也是目前官方推荐的异步编程模式。本文只会涉及 TAP.

编写异步程序的传统技术是比较复杂的,不过好在 C# 5 引入了一种简便方法—— async programming, 使得我们能轻松编写异步代码,而无需头疼回调或异步库。

Async programming 的核心是 Task 和 Task<T> 对象,它们是异步操作的模型表示,受关键字 async 和 await 支配。Task 和 Task<T> 对象类似于 JavaScript 中的 Promise, 是表示“稍后能完成工作”的一种承诺。Task 表示不返回值的单个操作,而 Task<T> 表示返回 T 类型的值的单个操作。值得注意的一点是,C# 中的 Task 是可以显式请求(通过 Task.Run)在独立的线程上运行的,这可以克服一些单纯的异步模型不能解决的问题,稍后将会提到。

根据 Task 所包含的具体操作,Task 可分为绑定 I/O 操作的任务和绑定 CPU 操作的任务。在编写程序中,区分二者是非常重要的。对于绑定 I/O 操作的任务(如等待数据库数据),它在 CPU 上几乎没有耗时,因此其被调用时,直接运行在当前线程上是合理的,只要在等待的时候将控制让步于其调用方,就能够让程序执行其他有用的工作。但绑定 CPU 操作的任务(如执行开销巨大的计算)是实打实地需要花费较长的时间,这些时间内 CPU 并不是空闲的,因此无法用于执行其他工作。如果该任务运行在原线程上,很有可能阻塞原线程,造成不希望的结果(例如阻塞 UI 线程引起 UI 无响应)。因此最好的处理方式是利用其他线程执行该任务。

合理地运用异步编程,能够改进用户体验,提高硬件利用率和系统的吞吐能力,增强系统的健壮性。

四、代码实现

下面,我们将通过 .NET Docs 中的一个示例学习如何通过代码实现异步编程。示例代码如下:

以下特征总结了使该示例成为异步方法的原因。

  • 方法签名包含 async 修饰符。
  • 按照约定,异步方法的名称以“Async”后缀结尾。
  • 返回类型为下列类型之一:
    • Task<TResult>: 适用于有操作数为 TResult 类型的返回语句的方法。
    • Task: 适用于没有返回语句或具有没有操作数的返回语句的方法。
    • void: 应当只用于异步事件处理程序。
    • 包含 GetAwaiter 方法的其他任何类型(自 C# 7.0 起)。
  • 方法通常包含至少一个 await 表达式(如果不含 await 表达式,就和同步方法没有差别了),该表达式标明了在该点上,只有等待的异步操作完成后方法才能继续。运行到此处时,方法将被挂起,并且控制将返回到方法的调用方。

微软的官方文档给出了示例中异步方法具体地运行机制,受篇幅所限,此处不再转述。但强烈建议大家参看该部分,它对于帮助你理解 C# 中的异步编程有很大的帮助(链接地址: https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/async/index#BKMK_WhatHappensUnderstandinganAsyncMethod )。

下面我们来实战一下,通过编写一个使用异步编程的程序,来对比一下同步方法和异步方法的区别吧。

新建一个 Windows 窗体应用,注意 Net Framework 版本应不低于 4.5, 命名为 AsyncExample. 向窗体添加两个按钮,Name 设置为 SyncPingButton 和 AsyncPingButton, Text 设置为“Ping (Sync)”和“Ping (Async)”;添加一个文本框,Name 设置为 ResultTextBox. 像下面这样:

按下 F7, 编辑代码如下:

分别选择 SyncPingButton 和 AsyncPingButton, 在“属性”->“事件”中分别绑定 SyncPingButton_Click 和 AsyncPingButton_ClickAsync 到各自的 Click 事件。

在上面的代码中,我们分别通过同步和异步的 Ping 方法,设置超时为 3000 毫秒,去 ping 1.2.3.4 这个 IP(一定会超时,因为这是一个没有分配的 IP 地址)。同步方法在等待响应时会阻塞当前线程,造成 UI 无响应。而异步方法由于在发出 ping 之后就将控制权让出,所以并没有造成阻塞。当 ping 超时后,文本框都显示了“TimedOut”提示。

效果对比图如下(不停晃动鼠标是为了检测 UI 是否响应):

同步方法阻塞了 UI 线程

异步方法执行时 UI 仍然能够响应

哈,虽然这只是一个简单的例子。但是只要合理利用异步编程,它就能发挥突出的作用。

工程文件下载:AsyncExample

五、还有什么没有提到

本篇教程就要结束了,但是,上面提到的仅仅是异步编程的一点皮毛,而且主要都是原理性的,而具体的 C# 实现其实也只讲了最基础的部分,还有很多重要的东西都没有讲到。其实我希望这篇教程只要能够起到让大家领略到异步编程的魅力,领会到异步编程的思想的作用就行了。至于 C# 的部分其实只是一个入门的介绍,想要更深入地理解还得靠自己钻研哦,我在下面给出了一些扩展阅读的资料供大家参考。

考虑到知识的完整性,在这里简要列举一下还没有提到的东西:

  1. 同步上下文
  2. 同时使用多个异步方法
  3. 转换异步模式
  4. 异步编程的异常处理
  5. 任务的取消
  6. ……

因为笔者水平和时间有限,难免会有不恰当甚至错误的地方,希望大家不吝指出哦!

六、参考资料与扩展阅读

下面给出了一些本文参考的和可供大家扩展阅读的资料。虽然我给出的 Microsoft Docs 链接都是中文的,但是推荐大家阅读其对应的英文版本,因为翻译质量并不高。而且,习惯于阅读英文资料是有好处的,因为较前沿、质量较高且较深入的资料基本上都是英文的。

  1. 操作系统——进程、线程、调度: https://www.jianshu.com/p/91c8600cb2ae
  2. 深入理解并发/并行,阻塞/非阻塞,同步/异步: https://blog.csdn.net/sinat_35512245/article/details/53836580
  3. In Which We Begin at the Beginning – krondo: http://krondo.com/in-which-we-begin-at-the-beginning
    中文译文版: http://blog.sina.com.cn/s/blog_704b6af70100py9f.html
  4. 异步编程 | Microsoft Docs: https://docs.microsoft.com/zh-cn/dotnet/csharp/async#basic-overview-of-the-asynchronous-model
  5. 深入了解异步 | Microsoft Docs: https://docs.microsoft.com/zh-cn/dotnet/standard/async-in-depth
  6. 使用 Async 和 Await 的异步编程 (C#) | Microsoft Docs: https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/async/index
  7. Async/Await - Best Practices in Asynchronous Programming: https://msdn.microsoft.com/en-us/magazine/jj991977.aspx


RainEggplant原创文章,转载请注明来自:异步编程简介与C#中基于任务的异步编程入门


0