跨越时空的等待——async/await解密(二)

253 阅读14分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情

这篇文章同样来自我们团队的啸达同学的系列文章,听他来讲讲async、await的本质

观看指南

前言

上一篇中主要介绍了一下问题产生的原因和一些背景知识。对问题不清楚的童鞋们请移步跨越时空的等待之一温习一下。这一节中我们会更多地集中精力研究async...await...的本质原理,讲讲为什么async...await...中的try...catch...就可以轻松捕获到异步过程中产生的异常。

其实细心点看不难发现,async...await...是ES6中Generator的语法糖,阮一峰老师在解释Generator原理的时候提了一嘴协程是啥。但是由于篇幅原因,协程这块的概念没有讲的特别细致。导致对async...await...的魔法特效的认识一直不够全面。

至于这个协程是个什么东西,我个人建议要把他和进程、线程放在一起讨论会更加通俗易懂。通过对几个特性的对比,也更能凸显出协程这个感念产生的背景和原因。所以下文会从进程、线程等概念开始,一步步引出协程的概念,并最终揭开async...await...的真面目。

导航3

Nodejs最大的特点就是非阻塞,事件驱动,这样的特性使得Nodejs特别适合进行I/O密集型工作。但是摩尔定律告诉我们集成电路上可容纳的元器件的数量每隔 18 至 24 个月就会增加一倍,性能也将提升一倍。也就是说,处理器(CPU)的性能每隔大约两年就会翻一倍。 Nodejs虽然不是很擅长从事CPU密集型工作,但是也一直致力于充分利用CPU的能力。多进程、多线程、线程等概念也是在这个过程中逐渐产生。

cpu_1649427409370.png

  • 为什么try...catch...不能捕获异步异常
  • 简介Promise解决方案
  • 进程、线程、协程,傻傻分不清楚
  • show me your code

进程、线程、协程,傻傻分不清楚

多进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器。我们启动一个服务、运行一个实例,就是开一个服务进程,例如 Java 里的 JVM 本身就是一个进程,Node.js 里通过 node app.js 开启一个服务进程,多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了 IPC 通信,进程之间才可数据共享。

Node.js-Process_1649427518348.jpg

所以,当我们处于单核CPU的系统时,我们采用单进程 + 单线程的模式运行Node代码。在多核CPU的时代下,可以fork出多个进程充分利用CPU的计算能力。Nodejs的Cluster模块就是基于这一思想产生,通过主进程fork出CPU核数个子进程增强其并行处理请求的能力。比如我有一个8核处理器,那么node服务启动后会生成一个master进程和8个worker进程。master进程负责建立对TCP的监听,当遇到连接事件后,将句柄负载分发给工作进程执行。

进程越多,并行能力越强?

既然多进程可以从分利用CPU,提高并行处理能力,那是不是意味着进程越多越好呢?

答案当然是否定的!虽然进程之间数据是隔离的,并且由操作系统进行调度。但是进程的创建、销毁、切换和相互之间的通信成本是很高的。越来越多的进程会导致操作系统维护这些进程的成本也越来越高。比如进程进行上下文切换的时候,进程的用户空间资源(虚拟内存、栈、全局变量)和内核空间资源(各种集寄存器)都需要得到妥善的保存,同时还需加载切换后进程的相关资源。除此之外,如果需要做进程件数据交互的时候,需要IPC通道进行传输。这就需要传输的信息在传输两端进行序列化和反序列化,这都将大大降低并发执行的效率。

总结

  • 优点:相对隔离,比较安全,进程之间没有影响。

  • 缺点:

    • 进程切换开销大
    • IPC通信开销大

基于以上特点,NodeJS的多进程只适用于增加系统的并行性;不适合用来解决并发问题。

多线程

由于多进程不适用与解决并发问题,所以多线程的开发模式的呼声就越来越高了。线程是操做系统可以进行运算调度的最小单位。线程是隶属于进程的,而且一个进程中可以有多个线程。同一进程中的多条线程将共享该进程中的所有系统资源,如虚拟地址空间,文件描述符和信号处理等。但同一进程中的多个线程有各自的调用栈,本身的寄存器环境和本身的线程本地存储。

NodeJS多线程的擦边球

有人疑问,NodeJS不是号称单线程么?哪里来的多线程?

我只能说NodeJS在v10.5.0以后引入了worker_threads模块,从此支持了多工作线程。注意,我说的是多 工作线程而不是多线程。什么是工作线程,工作线程是一个包含了完整V8实例、EventLoop的NodeJS执行环境,它运行在独立的线程中,具备一个完整的NodeJS运行生态。所以NodeJS官方在这里实际上在打概念上的擦边球,他们将多个NodeJS执行环境分布在了不同的线程中,以此说自己具备了多线程的能力。大家针对这个特性展开了激烈的争论,说NodeJS在自己打自己的脸。这时NodeJS官方给出的解释是,NodeJS的确是单线程的,worker_threads模块只是给大家提供了一个基于NodeJS架构的多工作线程。每个工作线程中,NodeJS非阻塞、单线程、事件循环的特征都没有打破。

这种解释让你总觉得哪里有点瑕疵,却始终找不到症结所在。

大受启发_1649431543957.jpg

总结

先把概念上的争议放在一边,我们看下NodeJS多线程的特点。

  • 优点:

    • 线程切换开销比进程切换小
    • 线程之间支持数据共享
  • 缺点

    • 不够安全
    • 缺乏并发处理的手段

是的,你没看错。线程之间除了支持消息通信还支持了内存共享。通过SharedArrayBuffer可以使不同的线程操作同一块内存地址。但是,worker_threads对于线程之间的并发控制少之又少。比如NodeJS并没有提供锁机制,也不像Java一样提供synchronized这样的关键字用作并发访问控制。由于没有像样的并发保障机制,官方甚至不建议大家直接使用SharedArrayBuffer进行内存共享,而是推荐大家用一些成熟的库来操作共享内存。至于是什么库,官方直呼:“这个库官方不提供哈”!

gua_1649432584338.png

协程

既然进程和线程都觉有本质上的局限性,不适合做并发逻辑,于是乎就出现了协程的概念(终于到正题了)。我摘了一段介绍如下:

协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。协程概念的提出比较早,单核CPU场景中发展出来的概念,通过提供挂起和恢复接口,实现在单个CPU上交叉处理多个任务的并发功能。那么本质上就是在一个线程的基础上,增加了不同任务栈的切换,通过不同任务栈的挂起和恢复,线程中进行交替运行的代码片段,实现并发的功能。(注意是并发不是并行)—— 抄自:juejin.cn/post/684790…

为了防止有些人看不懂上面的解释,我在这里举个例子类比一下:

单线程模式

小A每天上班就3件事,写代码、打水、蹲坑。今天又是再普通不过的一天了,小A上班先去打水,结果发现打水的队伍好长,小A只能等前面的人都打完才能打水。接着小A回来写了一会代码,突然觉得想要蹲坑。冲去厕所发现没有坑位,只好在外面等啊等...

多线程模式

还是那个小A,不过这次小A牛X了,身边有很多跟班的。小A早上上班,命令小B去帮他打水,命令小C帮他写代码,自己则专注蹲坑去了...

协程模式

小A上班,打水排队。结果他发现排在他前面的是自己的好基友,于是小A跟自己的好基友说,你帮我排队吧,等你打完水后你叫我过来打水。好基友同意了,小A回去安心写代码了,等好基友打完水了喊他过去也把水打了。过了一阵,小A想上厕所,但是发现没有坑位。但是小A的工位离厕所很近,所以他就边写代码边听厕所有没有冲水的声音。一旦听见有人冲水就一个健步飞奔过去...

在协程跟线程对比完后,我们总结一下协程的几个特点:

  1. 首先所有的事情的都是小A在做,不存在这个事情让别人做的情况。换句话说,任意时刻仅有一个程序在运行,不存在并行运行的情况。
  2. 当打水和蹲坑需要等待的时候,小A放弃执行这两个工作,转而去执行没有阻塞的工作(写代码)去了。说明了协程具备被中断的能力。
  3. 当有同时喊小A去打水,或是小A听到厕所冲水声后会第一时间去打水或是蹲坑,说明之前被阻塞的工作有个被重新唤醒的机制。而且唤醒后,小A不需要再重新排队,直接无缝衔接该干啥就干啥去了。

对比到这里,我相信大家对协程的概念都有一定认识了吧

async...await...与协程的关系

这一节涉及很多Generator相关的知识,需要大家阅读前对Generator有个基本的认识

首先,async...await...Generator语法糖,如果不知道Generator是啥的请自己找阮一峰老师补补课。我们知道,在Generator中有yieldnext两个动作。yield会挂起当前执行的函数,线程转去执行别的函数;当遇到next时,说明之前挂起的函数具备继续执行的条件了,这时线程会唤醒挂起的函数继续执行。我们用伪码表示一下小A上班记:

function goToWC() {
    // 等待冲水声响起
}

function getWater() {
    // 前面还有10人排队
}

function 写代码() {
    // 没命往下写啊
}

function* gen() {
    yield getWater();
    yield goToWC();
}

funtion 摸鱼大法() {
    const 摸鱼 = gen();
    摸鱼.next();
    摸鱼.next();
}

function work() {
    摸鱼大法();
    写代码();
}

此时建议大家给自己5min时间好好品一品

一点个人感想:

其实多线程到来主要是想充分利用计算资源的,但是多个线程在遇到需要共同支配同一个共享资源的时候就会引出并发问题。于是乎,各种锁啊,并行策略啊就应运而生了。但是不管以什么方式进行并发控制,无非都是要求同一时刻只有一个线程可以去操控数据,这不又回到单线程运行的过程中去了么?我看有些网友把这种行为描述成脱了裤子放屁,我顶他,而且觉得这屁味挺正!

说到协程,协程切换的成本更低,消耗的资源更少。自带挂起、唤醒能力,天生是解决并发的好手。只能说既生瑜何生亮啊!(这里只是针对并发问题,跟并行无关)

不过话说回来,阮一峰老师说了,Generator的这种协程实现属于半协程,或是无栈协程。这个感兴趣的可以自行下去研究一下,这里就不展开讨论了(主要是我也不懂)。

谜底揭晓

总结一下,协程是真的具备了挂起自己和被重新唤醒的能力。可以想象一下,协程在被中断后,是需要有机制可以保存当前执行上下文的。据说,在空间上,协程初始化创建的时候为其分配的栈有2KB,用来保存执行过程中的一些关键信息。当函数被唤醒后,通过栈内保存信息恢复“案发现场”。

据说,在空间上,协程初始化创建的时候为其分配的栈有2KB 这段描述本人真的不确定,既没有搬源码的勇气,也没有在权威的地方找到明确的解释。我个人对这块不太了解,所以这里只能是据说

这时,《转越时空的等待之一》中的STEP4中案例就能解释的通了。await的时候,exec函数被挂起。等bar函数中的异步操作执行结束后,exec函数被恢复。此时恢复还有try...catch...。这时我们发现Promise的状态已经变成Rejected了。由于没有Promise.prototype.catch异常,所以这时Promise会把异常向外抛出,正好被try...catch...捕获到。到此,我终于可以自圆其说了。可以这么说,在async...await...中,try...catch...要做的就是守株待兔,并且最后还真让它等到了。

function bar() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
       reject('err');
    }, 500);
  });
}

async function exec() {
  // trycatch捕获异常
  try {
    await bar()
  } catch(err) {
     console.log('err has been caught in try-catch block');
  }
}

exec(); // "err has been caught in try-catch block" 

彩蛋

没错,这个故事还有彩蛋。因为我要讲的还没有结束。。。

egg_1649437601713.png

  1. 前面说了,协程进行切换的时候会涉及上下文的保存和恢复。我从其他的资料中了解到这块的性能好到可以忽略不计。但是道听途说显然会觉得有点心虚,所以我准备加更一期async...await...性能测试。是的,只是准备,因为这些都是我不擅长的领域,如果我没更,请大家也不要把我怎么样。
  2. 据说Java也要支持协程了:zhuanlan.zhihu.com/p/443283384 感兴趣的小伙伴快去看看她是不是你喜欢的样子~
  3. 网上针对NodeJS是不是真的有协程也展开了一场讨论,感兴趣的大家可以搜一下。但是其中有一点引起我的兴趣,里面的大意是,Generator是ES6的概念。如果你使用的平台支持ES6,并且是NodeJS8+,那么原生就支持协程。但是,如果没有...

what_1649438144775.png

ES5的场景下,async...await...或者try...catch...又是怎么运作的???没有协程,它还能玩得转么?

所以这事还没完,我们下期一起见证一下ES5下的async...await...