JavaScript 是一门单线程语言,我们可以通过异步编程的方式来实现实现类似于多线程语言的并发操作。

本文着重讲解通过事件循环机制来实现多个异步操作的有序执行、并发执行;通过事件队列实现同级多个并发操作的先后执行顺序,通过微任务和宏任务的概念来讲解不同阶段任务执行的先后顺序,最后通过将浏览器和 Node 下的事件循环机制进行对比,对比其事件循环机制的不同之处,以及在 Node 端通过libuv引擎来实现多个异步任务的并发执行。

一、前言

我们知道JavaScript 是一门单线程语言,对于大多数人而言,单线程最大的好处是不用像多线程那样处处在意状态的同步问题,这里没有死锁的存在,也没有像多线程之间来回切换带来性能上的开销。同样,单线程也存在自身的弱点,主要表现在以下几个方面:

  1. 无法利用多核cpu,一个简单的例子,在一个位置从同一台服务器拉取不同的资源,如果采用单线程同步的方式去拉取,代码大致如下:

    getData(‘from_db’),//耗时为M,
    getData(‘from_db_api’),//耗时为N,
    如果采用同步单线程的方式总共耗时为:M+N
  2. js代码错误或者耗时过长会阻塞后面代码的执行,例如页面在进行dom渲染时,如果页面的js代码报错会引起整个页面白屏的现象。

  3. 大量计算占用CPU导致无法继续调用异步I/O。
    后来HTML5定制了Web Workers能够创建多线程来进行计算,但是使用Web Workers技术开的多线程有着诸多的限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些简单的计算任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了 JavaScript 语言的单线程本质。

    所以我们可以预见,未来的 JavaScript 依然会是一门单线程语言,因此JavaScript采用异步编程方式实现程序“非阻塞”的特点,那么我们如何实现这一特征了,答案就是我们今天要讲的——event loop(事件循环)。

二、浏览器下的事件循环机制

1、执行栈

JavaScript变量主要存储在堆和栈两个位置,其中,堆里主要存储对象,栈主要存储基本类型的变量以及指针变量。当我们调用一个方法时,JS 会生成一个与这个方法对应的执行环境,又叫执行上下文,当一系列方法被调用时,由于我们的js是单线程的,所以这些方法会被单独排在一个地方,这个地方叫做执行栈。
当一个脚本第一次执行的时候,JS  引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么 JS 会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,JS 会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。

2、事件队列

以上说的都是 JS 同步代码的执行,那么当程序执行异步代码后会如何进行呢?我们前面提到过 JS 最大的特点是非阻塞,下面我们说一下实现这一点的关键在于这项机制——事件队列。

当js引擎遇到一个异步事件后不会一直等待返回结果,这个事件会先挂起,继续执行执行栈中的其他任务,直到这个异步事件的结果返回,JS 引擎会将这个事件放入与当前执行栈不同的一个队列中,我们称之为事件队列。

被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码...,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。

异步编程之事件循环机制

(图片来源:网络)

3、微任务和宏任务

关于微任务和宏任务我们可以用一张图来说明:

异步编程之事件循环机制

(图片来源:网络)

在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回调加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。

宏任务主要包含:script( 整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)

微任务主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)

三、Node环境下的事件循环模型

与浏览器有何异同?

在 Node 中,事件循环表现出的状态与浏览器中大致相同。不同的是 Node  中有一套自己的模型。Node  中事件循环的实现是依靠的libuv引擎。我们知道 Node  选择Chrome V8引擎作为js解释器,V8引擎将js代码分析后去调用对应的Node   api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。因此实际上 Node  中的事件循环存在于libuv引擎中。

异步编程之事件循环机制

(图片来源:网络)

从上面这个模型中,我们可以大致分析出 Node  中的事件循环的顺序:

外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段...

以上各阶段的名称是根据我个人理解的翻译,为了避免错误和歧义,下面解释的时候会用英文来表示这些阶段。这些阶段大致的功能如下:

timers: 这个阶段执行定时器队列中的回调如 setTimeout() 和 setInterval()。

  • I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
  • idle, prepare: 这个阶段仅在内部使用,可以不必理会。
  • poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • check: setImmediate()的回调会在这个阶段执行。
  • close callbacks: 例如socket.on('close', ...)这种close事件的回调。

四、小结

JavaScript事件循环是非常重要的一个基础概念,我们可以通过这种机制实现异步编程,解决JavaScript同步单线程无法实现并发操作的问题,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。合理的使用各种延迟事件的方法,有助于代码更好的按照其优先级去执行。

作者:Liu Gang