15

腾讯技术工程 · 2021年11月11日

从根本上了解异步编程体系

作者:ronaldoliu,腾讯 IEG 后台开发工程师

或许你也听说了,摩尔定律失效了。技术的发展不会永远是指数上升,当芯片的集成度越来越高,高到 1 平方毫米能集成几亿个晶体管时,也就是人们常说的几纳米工艺,我们的半导体行业就踩到天花板了。因为再小下去,晶体管内甚至都快无法通过一个原子了,然后就是不得不面临量子效应,也就是人们常开玩笑说的——玄学,所谓遇事不决,量子力学。

总而言之,我们的计算机硬件技术发展到了瓶颈期了,CPU 的运行速度几乎不会再有太多提升了。并且随着移动互联网的普及和万物互联,我们的应用面临的请求压力也越来越大。当硬件能力难以提升时,我们就不得不比以往任何时候都更加需要在软件和系统层面进行优化。

计算机中有一个非常显著的特点,就是不同硬件的访问速度有着天壤之别,这让几乎所有的优化都是围绕这个点来进行。Jeff Dean 曾提出了一组著名的数字来描述这些硬件访问速度的差别,我们可以通过这个页面有个直观的感受。
image.png

其中我们需要关注的就是内存访问网络请求以及磁盘访问的耗时数量级。

由于 CPU 内部做了很多优化(比如流水线),我们可以粗略地认为执行一条指令时间约是 1ns(几个时钟周期)。而内存访问大概是 100ns 的数量级,也就是比执行一条指令慢 100 倍;从 ssd 上随机读取一次的时间是 16000ns 级别,而从机械磁盘读取一次,则需要 2000000ns=2ms。我们无时无刻都在使用的网络,横跨大西洋跑一个来回需要的时间约 150000000ns=150ms。你可以看到,相比这些硬件,CPU 的运行速度真的是太快太快了。或许 ns 级单位不直观,那可以把它换成我们更熟悉的秒来感受下。假如 CPU 执行一条指令是 1 秒,那么访问内存需要 1 分 40 秒,从 SSD 上随机读取一次数据需要 4 小时 24 分钟,从磁盘读取一次数据需要 23 天多,一次横跨大西洋的网络请求则需要 4.8 年…你现在可以直观地感受到 CPU 有多么快了吧。

但是快也有快的烦恼,正所谓无敌是多么寂寞。正因为 CPU 速度太快,从 CPU 的角度来说,那其它硬件的速度太慢了(相对论?)。然而关键问题是,我们程序的运算几乎都会依赖这些“慢”硬件,比如在硬盘上读取某些文件的数据到内存中,再对这些数据进行运算。这就不得不面临一个问题,由于某条指令依赖于从硬盘加载的数据,CPU 为了执行这条指令就不得不等到硬盘数据加载完。比如要执行answer = a+1,但是 a 存在磁盘上。为了执行一条 1ns 的加法运算,CPU 却等了 20000000ns,有种等到宇宙尽头的感觉。

为了应对包括以上这个问题等其它一系列问题,计算机先驱们就设计了支持分时任务的操作系统。我们不说那些老生常谈的操作系统历史了,直接快进到现在,这个分时任务可以粗略地对应成我们常说的线程。操作系统以线程作为最小调度执行单位,线程代表一个独立的计算任务,当遇到需要费时的操作时,操作系统就把当前线程停下,让 CPU 去执行别的线程任务,这样 CPU 就不需要为了要执行一个几纳秒的指令而等上两百万纳秒。如果操作系统调度得当,可以大大提高 CPU 的利用率,在相同的时间内完成多得多的任务。

在 20 年前,利用多线程就是解决并发的最主流方案。当时最流行的 apache 服务器就是多线程模型,来一个请求就新建一个线程去处理,操作系统负责回收和调度这些线程。这在当年是完全没有问题的。想想 20 年前,那时候网络还不发达,用电脑上网的人非常少,网站的功能也非常简单,因此服务器不会面临太大的并发访问。但是随着时间的推移,尤其是移动互联网的发展,万物互联,大家基本上也都是手机不离手。因而大型网站面临的用户访问量指数级地增大,多线程的不足很快就显露出来,尤其是在 web 领域。

为什么 web 领域是一个典型呢?因为大多数 web 服务都是 IO 密集型,通常都是:

收到请求 -> 查数据库 -> RPC别的几个服务 -> 组合一下数据 -> 返回 

在这个过程中,CPU 的参与其实很少,绝大部分时间都是在等待 DB 响应以及等待下游服务的响应。如果使用多线程模型来做 web server,你就会发现,虽然操作系统上有很多线程,但绝大部分线程都处在等待网络响应或者等待磁盘读取中,CPU 的利用率依然很低,而且大部分 CPU 都耗在操作系统的线程调度上了。并且,随着并发请求量的增加,线程的开销也越来越不容忽视。由于每个线程都有自己独有的堆栈空间,一般默认是 8M。我们简单计算一下,光 1000 个线程就会占用 8G 的内存。加上线程切换时的开销,每次切换,操作系统需要保存当前线程的各种寄存器,后续才能够恢复线程继续执行。当线程数量多时,这个开销也是比较可观的。

因此,即使多线程是最直观最容易理解且操作系统天然支持的解决并发的方案,但是由于系统面临的并发数越来越大,在有限的资源下,我们也不得不寻找更好的解决方法。

终于进入正题了,异步。有一点需要提前说明:

异步的目的不是让单个任务执行得更快,而是为了让计算机在相同时间内可以完成更多任务。

其实异步是一个非常复杂而庞大的体系,主要可以分为三个方面:

  • 硬件
  • 操作系统
  • 异步编程范式

毫无疑问硬件是支持异步的根本,但是我们既然标题是“异步编程体系”,拿重心还是在“程序”上,着重聊聊和异步相关的操作系统和编程范式。我在平时和大家聊天以及看网上 blog 的时候会发现,很多人一聊到异步总是很容易把操作系统对异步的支持和异步编程范式混杂在一起。聊到异步最常见的关键词就是:IO 多路复用、epoll、libev、回调地狱、async/await 等等。接下来的文章,我将比较成体系地梳理一下这些概念,让你真正地从根本上了解异步相关的东西。

初步理解异步的收益

我们从一段最简单的代码开始。

let number = read("number.txt");
print(number + 1);

以上伪代码从 number.txt 这个文件中读取一个数字,把它加一然后输出到屏幕,非常简单。由于需要一个确定的 number 的值才能进行 number+1 计算,因此要执行 print 前必然要等 read 执行完成(number 才有值)。而 read 是去读取磁盘上的文件,物理上就需要很长的时间,所以要完成 read+print,总的耗时是不会因为采用同步还是异步就会发生变化。因此:

单个异步任务绝不会比同步任务执行得更快

我们用异步最大的目的是充分利用 CPU 资源。接着上面的例子,假如操作系统提供了一个 read\_async 函数,调用它之后能够立刻返回一个对象,我们后续可以通过这个对象来判断读取操作是否完成了。来看看我们的代码可能会有什么变化:

let operation = read_async("number.txt");
let number = 0;
while true {
  if operation.is_finish() {
    number = operation.get_content();
    break;
  }
}
print(number+1);

似乎变得更糟了!!由于必须要确定 number 的值才能执行 print,因此即使我们立刻拿到一个 operation 对象,我们除了不停地询问它是否就绪以外,也没有别的办法了。CPU 使用率倒是实打实提高了,但是全部都用在了不断询问操作是否就绪这个循环上了。和之前的同步代码相比,做同样的任务,这样的异步代码耗费了相同的时间,却花费了多得多的 CPU,但是却并没有完成更多的任务。这样写异步代码,除了更费电别无它用,还让代码变得更复杂!!那异步的价值到底在哪里?

接着上面的例子,我稍微变化一下:

let a = read("qq.com");
let b = read("jd.com");
print(a+b);

假如单个网络请求耗时 50ms,忽略 print 的耗时,那么上面这段代码总耗时就是 100ms。我们再用异步看看:

let op_a = read_async("qq.com");
let op_b = read_async("jd.com");
let a = “”;
let b = “”;
while true {
  if op_a.is_finish() {
    a = op_a.get_content();
    break;
  }
}
while true {
  if op_b.is_finish() {
    b = op_b.get_content();
    break;
  }
}
print(a+b);

同样,即使是异步读取,程序中立刻返回了,但是也是要等到至少 50ms 以后才有结果返回。但是这里差别就出来了。当 CPU 一直循环执行op_a.is_finish()50ms 以后,它终于完成了,此时 a 有了确定的值。然后程序继续询问 op_b。这里要注意了,一开始程序连续执行两个异步请求,这两个请求同时发送出去了,理想情况下它们可以同时完成。也就是说很可能在 50.001ms 时,op_b 也就绪了。那么这段异步代码最终执行耗时就是 50ms 左右,相比同步代码节约了整整一半的时间。

异步并不会让逻辑上串行的任务变快,只能让逻辑上可以并行的任务执行更快

虽然以上异步代码执行速度更快了,但是它也付出了额外的代价。同步代码虽然执行耗时 100ms,但是 CPU 可以认为一直处于“休眠状态”;而以上异步代码中,CPU 却一直在不断地询问操作是否完成。速度更快,但更费电了!!

结合同步的优势

在上面的同步代码中,执行一个同步调用,操作系统会把当前线程挂起,等调用成功后再唤醒线程继续执行,这个过程中 CPU 可以去执行别的线程的任务,而不会空转。如果没有别的任务,甚至可以处于半休眠状态。这说明了一个关键问题,即操作系统是完全知道一个磁盘读取或者网络调用什么时候完成的,只有这样它才能在正确的时间唤醒对应线程(操作系统在这里用到的技术就是中断,这不在本文的范围内就不多讨论了)。既然操作系统有这个能力,那么假如操作系统提供这样一个函数:

fn wait_until_get_ready(Operation) -> Response {
  // 阻塞任务,挂起线程,直到operation就绪再唤起线程
}

有了这个函数,那我们的异步代码就可以这么写:

let op_a = read_async("qq.com");
let op_b = read_async("jd.com");
let a = wait_until_get_ready(op_a);
let b = wait_until_get_ready(op_b);
print(a+b);

当调用wait_until_get_ready(op_a)时,op_a 还没有就绪,操作系统就挂起当前线程,直到 50ms 以后 op_a 就绪。这个过程就像执行同步阻塞代码一样不耗费 CPU 资源。然后继续执行wait_until_get_ready(op_b),发现 op_b 也就绪了。这样,我们就可以利用异步代码,只花费 50ms,并且不花费额外的 CPU 资源,就能完成这个任务,完美!

要让我们的异步代码能够做到这点,其实依赖两个关键因素:

  • read_async 把任务交给操作系统后能够立刻返回,而不会一直阻塞到它执行完毕。通过这个能力,我们可以让逻辑上没有依赖的任务并发执行
  • wait_until_get_ready 依赖于操作系统的通知能力,不用自己去轮询,大大节约了 CPU 资源

read_async 和 wait_until_get_ready 是我写的伪代码函数,而要实现它们离不开操作系统的底层支撑。这里涉及到非常多的技术,比如常听说的,select、poll、epoll 这些 Linux 系统支持的方法。kqueue 和 iocp 则分别是 mac 和 windows 版本的"epoll"。不同操作系统实现这些机制的原理也不尽相同的,而且相当的复杂。顺便一提,Linux 的 epoll 的设计和性能都不如 windows 的 iocp,最新的 5.1 内核加入了 io_uring,算是向 windows 的 iocp 致敬了,这可能也会给 Linux 异步编程带来更多新的变化。操作系统和硬件对于异步编程非常重要,不过我决定后面再展开讲。

从 0 开始进化成 Javascript

接下来我还是基于上面两个“异步原语”,来讲讲是我个人认为在异步编程体系中更为重要而且我们开发者日常接触最多的部分——异步编程范式。

上面的例子都是非常简单的场景,而在实际场景中,真正并发几个没有关联的任务然后等待它们执行结束其实是不多见的,大多数是有逻辑依赖关系的。在有逻辑关联关系的情况下,我们的代码将会变得非常难以实现,难以阅读。

我们继续前面的例子(稍作修改):

let op_a = read_async("qq.com");
let op_b = read_async("jd.com");
let a = wait_until_get_ready(op_a);
write_to("qq.html", a);
let b = wait_until_get_ready(op_b);
write_to("jd.html", b);

之前我们假设每个异步请求耗时都是 50ms,但其实绝大多数时候是无法做出这种假设的,尤其是在网络环境下,两个网络请求很大概率响应时长不一样,这个很容易理解。当我们发出两个并发请求后,其实并不知道哪个请求会先响应。我假设 qq.com 的响应时长是 50ms,而 jd.com 的响应时长是 10ms,那么上面的程序会有什么问题呢?

如果我们先let a = wait_until_get_ready(op_a);,此时线程会阻塞直到 op_a 就绪,也就是 50ms 以后才能继续执行后面的语句。但其实 op_b 早在第 10ms 就已经有响应了,但我们的程序并没有及时去处理。这里的根本原因就是,我们写代码时并不知道每个异步请求会在什么时刻完成,只能按照某种特定顺序来执行 wait_until_get_ready 操作,这样势必会造成效率低下。怎么办呢?

这里的问题就出在 wait_until_get_ready 只支持 wait 一个异步操作,不好用。那我们可以考虑给开发操作系统的 Linux Torvads 大爷提需求,系统需要支持这样的两个函数:

fn add_to_wait_list(operations: Vec<Operation>)
fn wait_until_some_of_them_get_ready() ->Vec<Operation>

通过add_to_wait_list向全局的监听器注册需要监听的异步操作,然后利用wait_until_some_of_them_get_ready,如果没有事件就绪就阻塞等待,当注册的异步操作有就绪的了(可能有多个),就唤醒线程并返回一个数组告诉调用方哪些操作就绪了。如果监听队列为空时,wait_until_some_of_them_get_ready 不会阻塞而直接返回一个空数组。可以想象,当 Linux Torvads 排了几个 User Story 让操作系统支持了这两个功能并给我提供了库函数之后,我们的异步代码就可以更进一步:

let op_a = read_async("qq.com");
let op_b = read_async("jd.com");
add_to_wait_list([op_a, op_b]);
while true {
  let list = wait_until_some_of_them_get_ready();
  if list.is_empty() {
    break;
  }
  for op in list {
    if op.equal(op_a) {
      write_to("qq.html", op.get_content());
    } else if op.equal(op_b) {
      write_to("jd.html", op.get_content());
    }
  }
}

通过这种方式,我们的程序能够及时地响应异步操作,避免盲目地等待,收到一个响应就能立刻输出一个文件。

不过你如果仔细思考,可以发现这里还有两个问题。第一是由于异步操作的耗时不同,每次 wait_until_some_of_them_get_ready 返回的可能是一个就绪的异步操作,也可能是多个,因此我们必须要通过一个 while 循环不断去 wait,直到队列所有异步操作都就绪为止。第二个问题是,由于返回了一个就绪的异步操作的列表,每个异步操作后续的逻辑可能都不一样,我们必须要先判断是什么事件就绪才能执行对应的逻辑,因此不得不做一个很复杂的循环比较。想象一下,如果等待列表里有 1 万个异步操作,且每个异步操作对应的处理逻辑都不一样,那我们这个循环判断的代码得多么复杂——一万个 switch case 分支!!所以应该怎么办呢?

其实有个很简单的解决办法:由于 operation 和其就绪后要执行的逻辑是一一对应的,因此我们可以直接把对应的后续执行函数绑定到 operation 上比如:

function read_async_v1(targetURL: String, callback: Function) {
  let operation = read_async("qq.com");
  operation.callback = callback;
  return operation;
}

这样我们可以在创建异步任务时就绑定上它后续的逻辑,也就是所谓的回调函数。然后我们 while 循环内部就彻底清爽了,而且避免了一次 O(n)的循环匹配。这是不是就是 C++所谓的动态派发:

let op_a = read_async_v1("qq.com", function(data) {
  send_to("pony@qq.com", data);
});
let op_b = read_async_v1("jd.com", function(data) {
  write_to("jd.html", data);
});
add_to_wait_list([op_a, op_b]);
while true {
  let list = wait_until_some_of_them_get_ready();
  if list.is_empty() {
    break;
  }
  for op in list {
    op.callback(op.get_content());
  }
}

这里的关键一步是,read_async 返回的 operation 对象需要能够支持绑定回调函数。

当做到这一步时,其实我们还可以再更进一步,让 read_async_v1 自己去注册监听器:

function read_async_v2(target, callback) {
  let operation = read_async(target);
  operation.callback = callback;
  add_to_wait_list([operation]);
}

这样我们的代码可以更简化:

read_async_v2("qq.com", function(data) {
  send_to("mahuateng@qq.com", data);
});
read_async_v2("jd.com", function(data) {
  write_to("jd.html", data);
});
while true {
  let list = wait_until_some_of_them_get_ready();
  if list.is_empty() {
    break;
  }
  for op in list {
    op.callback(op.get_content());
  }
}

由于我们把后续逻辑都绑定到 operation 上了,每个异步程序都需要在最后执行上述的 while 循环来等待异步事件就绪,然后执行其回调。因此如果我们有机会设计一门编程语言,那就可以把这段代码放到语言的运行时里,让用户不需要每次都在最后加这么一段。经过这个改造后,我们的代码就变成了:

read_async_v2("qq.com", function(data) {
  send_to("pony@qq.com", data);
});
read_async_v2("jd.com", function(data) {
  write_to("jd.html", data);
});

// 编译器帮我们把while循环自动插到这里
// 或者什么异步框架帮我们做while循环

你看,这是不是就是 javascript 了!!js 的 v8 引擎帮我们执行了 while 循环,也就是 JS 里大家常说的EventLoop

可以看到,其实我们没有运用任何黑魔法,只依赖几个操作系统提供的基本元语就可以很自然地过渡到 Javascript 的异步编程模型。

再简单回顾一下:

  • 为了让程序在同一时间内处理更多的请求,我们采用多线程。多线程虽然编写简单,但是对内存和 CPU 资源消耗大,因此我们考虑利用系统的异步接口进行开发;
  • 我不知道异步操作什么时候结束,只能不停的轮询它的状态。当有多个异步操作,每个的响应时间都未知,不知道该去先轮询哪个。我们利用操作系统提供的能力,把异步事件加入全局监听队列,然后通过 wait_until_some_of_them_get_ready 来等待任意事件就绪,所谓的 EventLoop;
  • 当事件就绪后 EventLoop 不知道该执行什么逻辑,只能进行一个非常复杂的判断才能确认后续逻辑该执行哪个函数。因此我们给每个异步事件注册回调函数,这样 EventLoop 的实现就高效而清爽了;
  • 所有异步程序都需要在最后执行 EventLoop 等待事件就绪然后执行回调,因此可以把 EventLoop 这块逻辑放到语言自身的 runtime 中,不需要每次自己写。

我们上述利用到的wait_until_some_of_them_get_ready对应到真实的操作系统上,其实就是 Linux 的 epoll,Mac 的 kqueue 以及 windows 的 iocp。其实不止 javascript,C 和 C++很多异步框架也是类似的思路,比如著名的 Redis 使用的 libevent,以及 nodejs 使用的 libuv。这些框架的共同特点就是,它们提供了多种异步的 IO 接口,支持事件注册以及通过回调来进行异步编程。只是像 C 代码,由于不支持闭包,基于它们实现的异步程序,实际上比 js 开发的更难以阅读和调试。

所以根据上述的演进过程,你是不是觉得 js 的异步回调对于编写异步代码已经是一个相当高级的编程方式了。不过接下来才是真正的魔鬼!

回调地狱

基于回调开发异步代码,很快就会遇到臭名昭著的回调地狱。比如:

login(user => {
    getStatus(status => {
        getOrder(order => {
            getPayment(payment => {
                getRecommendAdvertisements(ads => {
                    setTimeout(() => {
                        alert(ads)
                    }, 1000)
                })
            })
        })
    })
})

以上代码就是所谓的回调地狱,由于每次异步操作都需要有一个回调函数来执行就绪后的后续逻辑,因此当遇上各个异步操作之前有先后关系时,势必就要回调套回调。当业务代码一复杂,回调套回调写多了,造成代码难以阅读和调试,就成了所谓的回调地狱。

JS 圈的大佬们花了很多精力来思考如何解决回调地狱的问题,其中最著名的就是 promise。promise并不是什么可以把同步代码变异步代码的黑魔法。它只是一种编程手法,或者你可以理解为一种封装,并没有借助操作系统额外的能力。这也是为什么我的标题是“编程范式”,promise 就是一种范式。

再仔细看看我们上面的回调地狱的示例代码,想想出现这种层层嵌套的本质是什么。说到底,其实就是我们通过回调这种方式来描述“当一个异步操作完成之后接下来该干什么”。多个异步操作有先后关系,因此自然而然形成了回调地狱。既然多个异步操作组成了“串行”逻辑,那么我们能用更“串行”的方式来描述这个逻辑吗?比如:

login(username, password)
    .then(user => getStatus(user.id))
    .then(status => getOrder(status.id))
    .then(order => getPayment(order.id))
    .then(payment => getRecommendAdvertisements(payment.total_amount))
    .then(ads => {/*...*/});

这样看起来就比层层嵌套的回调直观一些了,先执行 A 操作,then 执行 B,then 执行 C……逻辑的先后关系一目了然,书写方式也符合我们人类串行的思维方式。但是这种编程方式怎么实现呢?

回想之前我们实现异步回调时,异步函数会返回一个 operation 对象,这个对象保存了回调函数的函数指针,因此当 EventLoop 发现该 operation 就绪后就可以直接跳转到对应的回调函数去执行。但是在上述链式调用.then 的代码中,我们调用login(username, pwd).then(...)时,注意是当 login 这个函数已经执行完毕了,才调用的 then。相当于我已经把异步函数提交执行了之后,才来绑定的回调函数。这个能实现吗?

再回顾一下我们之前的read_async_v2:

function read_async_v2(target, callback) {
  let operation = read_async(target);
  operation.callback = callback;
  add_to_wait_list([operation]);
}

我们直接在函数内部把 operation 的回调给设置好了,并把 operation 加入监听队列。但其实不一定要在这个时候去设置回调函数,只要在 EventLoop 执行之前设置好就行了。基于这个思路我们可以把 operation 保存在一个对象中,后续通过这个对象给 operation 添加回调方法,比如:

function read_async_v3(target) {
  let operation = read_async(target);
  add_to_wait_list([operation]);
  return {
    then: function(callback) {
        operation.callback = callback;
    },
  }
}

// 我们可以这样
read_async_v3("qq.com").then(logic)

但是这种实现方式只能设置一个回调,不能像之前说的完成链式调用。为了支持链式调用我们可以这样:

function read_async_v4(target) {
  let operation = read_async(target);
  add_to_wait_list([operation]);
  let chainableObject = {
    callbacks: [],
    then: function(callback) {
      this.callbacks.push(callback);
      return this;
    },
    run: function(data) {
      let nextData = data;
      for cb in this.callbacks {
        nextData = cb(nextData);
      }
    }
  };
  operation.callback = chainableObject.run;
  return chainableObject;
}

// 于是我们可以这样
read_async_v4("qq.com").then(logic1).then(logic2).then(/*...*/)

如上述代码,主要的实现思路就是,我们返回一个对象,这个对象保存一个 callback 列表,每次可以通过调用对象的 then 方法新增一个回调。由于 then 方法返回了对象本身,因此可以进行链式调用。然后我们把 operation 就绪后的 callback 设置成这个对象的 run 方法,也就是说 EventLoop 调用的其实是这个包装过的对象的 run 方法,它再来依次执行我们之前通过 then 设置好的回调函数。

看起来大功告成了吗?不,这里有个严重的问题,就是我们的链式调用其实是绑定到一个异步调用上的,当这个异步操作就绪后 run 方法会把 then 绑定的所有回调都执行完。如果这些回调里又包含了异步调用,比如我们先请求 qq.com 然后输出 qq.com 的内容,接着请求 jd.com,然后输出 jd.com 的内容:

read_async_v4("qq.com")
    .then(data => console.log("qq.com: ${data}"))
    .then((_) => read_async_v4("jd.com"))
    .then(data => console.log("jd.com: {$data}"))

但是上面这段代码是有问题的,这三个 then 其实都是 qq.com 的回调,当请求 qq.com 完成时,EventLoop 执行 operation 的 run 方法,然后 run 方法会依次调用这三个回调。当调用到第二个回调时,此时它只是发出了一个对 jd.com 的异步请求然后返回了一个针对 jd.com 的 chainable 对象。因此第三个 then 的入参 data 并不是我们期望的 jd.com 返回的内容,而是一个 chainable 对象。因此最终的输出可能是:

"qq.com: <html>....</html>"
"jd.com: [Object]"

过了一会儿 jd.com 请求也完成了,但是发现没给它设置回调,所以就直接把结果丢弃了。这当然不是我们想要的样子!正确的写法应该是:

read_async_v4("qq.com")
    .then(data => console.log("qq.com: ${data}"))
    .then((_) => {
        read_async_v4("jd.com")
            .then(data => console.log("jd.com: {$data}"))
            .then((_) => {
                read_async_v4("baidu.com")
                    .then(data => console.log("baidu.com: ${data}"));
            });
    });

只有这样才是真正我们想要的结果,先输出 qq.com 的内容,在输出 jd.com 的内容。then 里面如果是异步请求,那么就必须在内部完成 then 的绑定……但是这样不就又回到回调地狱了吗???一波操作猛如虎,回头一看原地杵???、

其实要解决这个问题很简单,只需要修改一下 run 方法,当某次回调返回一个 chainableObject,那就把剩下的回调绑定到那个对象上,然后就可以退出了。比如:

function read_async_v5(target) {
  let operation = read_async(target);
  add_to_wait_list([operation]);
  let chainableObject = {
    callbacks: [],
    then: function(callback) {
      this.callbacks.push(callback);
      return this;
    },
    run: function(data) {
      let nextData = data;
       let self = this;
      while self.callbacks.length > 0 {
          // 每次从队首弹出一个回调函数
          let cb = self.callbacks.pop_front();
          nextData = cb(nextData);
          // 如果回调返回了一个ChainableObject,那么就把剩下的callback绑定到它上面
          // 然后就可以终止执行了
          if isChainableType(nextData) {
              nextData.callbacks = self.callbacks;
              return;
          }
      }
    }
  };
  operation.callback = chainableObject.run;
  return chainableObject;
}

这样之后,我们就可以真正地实现异步的链式调用,比如:

read_async_v5("qq.com")
    .then(data => console.log("qq.com: ${data}"))
    .then((_) => read_async_v5("jd.com"))
    .then(data => console.log("jd.com: {$data}"))

虽然一开始所有的 then 都把回调绑定到对 qq.com 进行异步请求的 operation 上,但这只是暂时的。当执行完第二个回调时,发现它返回了一个 chainableObject,于是就把剩余的 callback 绑定到这个新的对象上,不再继续执行了。当 jd.com 的请求就绪后,EventLoop 执行它的 operation 的 run 方法,再接着执行后面回调。

如果我们提供一个库,里面包含了各种异步方法,它们的共同特点是都会返回一个 ChainableObject,这样以来,我们就能够利用 then 来组合它们完成我们的业务开发。这就是所谓的异步生态!

比如我们提供一个异步库,它包含:http_get, http_post, read_fs, write_fs等返回 chainableObject 的方法,那么通过组合它们,我们能够非常容易地实现复杂的业务逻辑,比如前述的回调地狱可以改写为:

http_post("/login", body)
  .then(user => http_get("/order?user_id=${user.id}"))
  .then(order => http_post("/payment", {oid: order.id}))
  .then(/*...*/)

看起来是不是和 js 基于 promise 的链式调用一模一样了?

其实我们上述的 chainableObject 就可以看做是 js 中的 promise。区别是,由于 js 一开始全是基于回调的编程模型,各种标准库内置的异步方法都只能接收回调,为了向后兼容,没法把那些内置函数改成返回 promise。因此 js 的办法是提供 promise 的构造方法,把异步函数“包装”成 promise。如果基于我们 chainableObject 实现,就是这样的:

function ChainableObject() {
  return chainableObject = {
    callbacks: [],
    then: function(callback) {/*同之前,略*/},
    run: function(data) {
      let nextData = data;
      if self.resolveData != null {
        nextData = self.resolveData;
      }
      while self.callbacks.length > 0 {
        //同上,省略
      }
    },
    resolveData: null,
  };
}

function Convert2Chainable(targetFunction) {
  let obj = new ChainableObject();
  function resolve(data) {
    obj.resolveData = data;
  }
  targetFunction(resolve);
  return obj;
}

这样以后,我们可以利用标准库的老回调式方法写出这样的代码:

Convert2Chainable(resolve => {
  let sleep = 100;
  setTimeout(() => {
    resolve(sleep);
  }, sleep);
}).then(data => console.log("sleep: ${data}ms"))
  .then((_) => Convert2Chainable(resolve => {
    let sleep = 200;
    setTimeout(() => {
      resolve(sleep);
    }, sleep);
})
.then(data => console.log("sleep: ${data}ms"))
.then((_) => Convert2Chainable(resolve => {
  $.ajax("qq.com", function(res) {
    resolve(res);
  });
})
.then(data => console.log("qq.com: ${data}"));

这里的关键就是,我们通过Convert2Chainable 函数,可以把任意的标准库内置的异步方法包装成一个 ChainableObject。ChainableObject 只是我为了解释清楚异步链式调用的具体实现原理而随意实现的一个对象,它其实就是 js 中的 Promise。具体的实现肯定没这么简单,因为 Promise 还有很多处理错误的逻辑,但是原理都是一样的。后续我将不再使用 ChainableObject 这个名词,转而使用 Promise,你知道它们是一回事就行了。

小总结

本文中我花了一些篇幅,通过例子带领大家一步一步看懂异步编程的一些本质原理。你可以清楚地看到,所有的异步任务都是由一个线程发起,所有后续的逻辑都由这个线程来完成。当大量并发请求到来时,我们只用一个线程就能处理这些所有的请求,这就是异步编程的真正价值所在。这也是 nodejs 可以用来做一些后端开发并且能够达到相当好的性能的重要原因!!我们熟悉的 Redis Nginx 都是基于这样一套异步编程范式,享受异步带来的巨大优势。

And More

但有了 Promise 这种方式,异步编程就大功告成了吗?其实并没有……基于 Promise 这样的链式调用只是比回调好懂一点,但和多线程的同步编写方式相比,也并没有那么容易读。并且错误的捕获和传播也是个问题。下一篇文章我将讲一讲 Promise 的问题,然后看看我们能够提出什么样更加优雅的解决方案——其实就是 async/await。你要做好心理准备,async/await 虽然优雅,它不像 promise 这样只是一层库的封装,async/await 需要和语言的编译器结合起来,对代码做一些转换,要实现它不是一件那么容易的事情。当然也不必害怕,因为它不涉及具体的编译原理相关内容,只是做了一些代码生成而已。

另一方面,我们上述的 wait_until_some_of_them_get_ready 函数帮我们屏蔽了太多底层的细节,它其实就是一个异步任务调度器。它是怎么做到的呢?依赖 OS 的能力是必然的,但是调度器本身也需要很大的开发量。调度器本身的实现决定着某种编程语言并发方面的性能优劣。Rust 是性能堪比 C/C++的编程语言,同时提供了 async await 的支持。

原文:腾讯技术工程
作者:ronaldoliu

推荐阅读

更多腾讯AI相关技术干货,请关注专栏腾讯技术工程
推荐阅读
关注数
8125
内容数
211
腾讯AI,物联网等相关技术干货,欢迎关注
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息