虽然我们生活在一个异步的世界里,但对于多数编程初学者来说,异步还是很陌生。学习一门编程语言,通常都是从同步流程开始的,即顺序、分支和循环。而异步流程是什么呢——开始一个异步调用,然后……就没有然后了。异步程序跑哪去了?

异步程序会以某种异步的形式在运行着,比如多线程、异步IO等,直到处理完成。那如果需要处理结果怎么办?给一个程序入口,让它处理完当前过程之后,把处理结果送到这个入口,然后执行另一段程序——俗称回调。回调一般使用 callback 这个名称,不过有时候我更喜欢使用 next,因为它代表着下一个处理步骤。

同步和异步的概念

现在我们接触到了一些概念,比如同步和异步,它们是什么?

这两个概念并不来源于编程语言,而是来源于低层指令,甚至更低层的——电路。它们是基于时序的两个概念,其中,“步”是指步调,所以同步表示相同的步调,而异步表示不同的步调。当然这两个概念提升到程序这个级别的时候,精确的意思与时钟无关,但所表示的意义仍然未变。

同步

举个生活中的例子来说明这个问题——排除买票。售票厅开了一个窗口,有一队人在排队依次买票。这个队伍中,前面一个人往前走了一步,后面的人才能往前走一步;前面的人在等待,后面的人就一定在等待。那么在理想的情况下,所有人可以同时向前迈步。OK,大家步伐一致,称为同步。

这里把售票窗口看作是处理器,每个人看作是等待执行的指令,买票这个动作就是在执行指令。它的特点是按步就班,如果一个人买票时间过长(指令执行时间过长),就会造成阻塞。

异步(多线程)

现在买票的人渐渐多起来,所以售票厅多开了几个窗口同时售票。每个单独的队伍仍然保持着同步,但不同的队伍之间,步伐不再一致,称为异步。A 队列售票很顺利,队伍在有序快速的前进,但 B 队列的某个顾客似乎在付费时遇到点麻烦,花了很长的时候,造成阻塞,但这对 A 队列并不产生影响。

这时候的售票厅可以看作是在以多线程的方式运行着异步程序。从这个例子可以看到异步的两个特点:其一,两个异步流程之间相互独立,它们相互不会阻塞(有个前提,不需要等待共享资源的情况下);其二,异步程序内部仍然是同步的

异步(IO)

上面的例子比较符合多线程异步的情况。那 IO 异步又是什么样呢?

年底了,M 在准备年终汇报的资料,这可是个紧张的工作(CPU),要收集不少数据来写好些文案。为了其中一份文案,M 需要车间的生产数据,但跑一趟车间(IO)可需要花不少时间,所以他让 N 去车间收集数据,自己则继续写其它方案,同时等 N 把数据收集回来(启动异步程序)。半天以后,N 带回了数据(插入事件消息),M 继续完成手上的文案(完成当前事件循环),之后使用 N 带回来的数据开始撰写关于车间的报告(新的事件循环)……

IO 的处理速度比 CPU 慢得多,所以 IO 异步让 CPU 不必闲置着等待 IO 操作完成。当 IO 操作完成之后,CPU 会适地使用 IO 操作结果继续工作。

同步逻辑和异步逻辑

回到程序上来,我们以一个函数的处理过程来描述同步和异步的处理方式。

同步逻辑

那么,同步处理过程是:

接受输入 ⇒ 处理 ⇒ 产生输出

用一段伪代码来描述就是

注:本文中的伪代码比较接近 JavaScript 语法,而有时候为了说明类型,采用了 TypeScript 的类型申明语法。

function func(input) {
    do something with input
    return output
}

这是标准的 IPO(Input-Process-Output) 处理。

异步逻辑

而异步呢,是:

接受输入 ⇒ 处理 ⇒ 启动下一步(如果有)

用伪代码来描述就是:

function asyncFunc(input, next) {
    do something with input
    if (next is a entry) {
        next(output)
    }
}

这个过程称为 IPN(Input-Process-Next)。

注意到这里的 Next,下一步,只有一步。这一步,囊括了后续的若干步骤。所以这一步,只能是后续若干步骤封装出来一个模块入口,或者说函数。

因此,模块化思想在异步思维中是一个非常关键的思想。很多初学者写代码喜欢像记流水账一样一句句往下写,动不动就是成百上千行的函数,这就是一种缺乏模块化思想的表现。模块化思想需要训练,分析代码的相关性,提炼函数,提取对象,在具有一定经验之后还需要掌握模块细化的粒度平衡。这不是一朝一夕之功,不过我推荐看看“设计模式”和“重构”相关的书籍。

异步开发工具(SDK和语法层面的)

承诺(Promise)

再想想上面关于年终汇报的例子,M 请 N 去车间收集数据的时候,N 会说:“好的,我很快就把数据带回来”,这是一种承诺。基于这个承诺,M 才能安排后面撰写关于车间的汇报材料。这个过程用伪代码来描述就是

function collectData(): Promise {
    // N 去收集数据,产生了一个承诺
    return new Promise(resolve => {
        collect data from workshop
        // 这个承诺最终会带来数据
        resolve(data)
    })
}

function writeWorkshopReport(data) {
    write report with data
}

// 收集数据的承诺兑现之后,可将这个数据用于写报告
collectData()
    .then(data => writeWorkshopReport(data))

以 JavaScript 为代表的一些语言 SDK 中使用了 Promise。不过 C# 中是采用的 TaskTask<T>,相应的,使用了 Task.ContinueWithTask<T>.ContinueWith 来代替 Promise.then

异步逻辑同步化

上面提到了同步思维和异步思维在一个处理步骤中的区别。如果跳出一个处理步骤,从更大范围的处理流程来看,异步与同步其实也没多大区别,都是 输入-->处理-->产生输出-->将输出用于下一步骤,唯一要注意的是需要等待异步处理产生的输出,我们可以称之为异步等待。由于我们可以一边进行异步等待(async wait,简写 await),一边做别的事情,所以这个等待并不产生阻塞。但是,由于声明了这个等待,编译器/解释器会将后面的代码自动放在等待完成之后调用,这让异步代码写起来就像写同步代码一样。

上面的例子使用异步等待的伪代码会像这样

async function collectData(): Promise {
    collect data from workshop
    // 多数语言会把 async 函数的返回值封装成 Promise
    return data
}

function writeWorkshopReport(data) {
    write report with data
}

// await 只能用于声明为 async 的函数中
async function main() {
    data = await collectData()
    writeWorkshopReport(data)
}

// 定义了异步 main 函数,一定要记得调用,不然它是不会执行的
main()

像 C# 和 JavaScript 等语言都从语法层面规定了 await 必须用在声明为 async 的函数中,这就从编译/解释的层面限定了 await 的用途,只要使用了 await,那它所处的就一定是一个异步上下文。而 async 也要求编译器/解释器对其返回值进行一些自动处理,比如在 JavaScript 中,其返回值如果不是 Promise 对象,它会自动封装成一个 Promise 对象;而在 C# 中,它会自动封装成 TaskTask<T>(所以 async 方法的类型需要声明为 TaskTask<T>)。

注意,注意,注意

尽管语言服务在异步程序同步化方面已经做了很多工作,但是仍然避免不了一些人为错误,比如忘记写 await 关键字。在强类型语言中编译器会检查得严格一些,但如果是在 JavaScript 中,忘记写 await 意味着原本应该取得一个值的语句,会取到一个 Promise。解释器不会对此质疑,但程序运行的结果会不正确。

小结

总的来说,异步编程并不是特别困难的事情。使用 async/await 语言特性甚至可以用类似编写同步代码的方法来编写异步代码。但语法糖终究是糖,要想把异步编程掌握得更好,还是需要去了解和熟悉异步、回调、Promise、模块化、设计模式、重构等概念。

相关阅读