你不会还没搞懂JS事件循环(Event Loop)吧?

4,323 阅读10分钟

一、异步执行原理

1. 单线程的JavaScript

我们知道,JavaScript是一种单线程语言,它主要用来与用户互动,以及操作 DOM。JavaScript 有同步和异步的概念,这就解决了代码阻塞的问题:

  • 同步:如果在一个函数返回的时候,调用者就能够得到预期结果,那么这个函数就是同步的
  • 异步:如果在函数返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。

由于是单线程,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。

2. 多线程的浏览器

JS 是单线程的,在同一个时间只能做一件事情,那为什么浏览器可以同时执行异步任务呢?

这是因为浏览器是多线程的,当 JS 需要执行异步任务时,浏览器会另外启动一个线程去执行该任务。也就是说,JavaScript是单线程指的是执行JavaScript代码的线程只有一个,是浏览器提供的JavaScript引擎线程(主线程)。除此之外,浏览器中还有定时器线程、 HTTP 请求线程等线程,这些线程主要不是来执行 JS 代码的。

比如主线程中需要发送数据请求,就会把这个任务交给异步 HTTP 请求线程去执行,等请求数据返回之后,再将 callback 里需要执行的 JS 回调交给 JS 引擎线程去执行。也就是说,浏览器才是真正执行发送请求这个任务的角色,而 JS 只是负责执行最后的回调处理。所以这里的异步不是 JS 自身实现的,而是浏览器为其提供的能力。

下图是Chrome浏览器的架构图:

image.png 可以看到,Chrome不仅拥有多个进程,还有多个线程。以渲染进程为例,就包含GUI渲染线程、JS引擎线程、事件触发线程、定时器触发线程、异步HTTP请求线程。这些线程为 JS 在浏览器中完成异步任务提供了基础。

二、浏览器的事件循环

何为事件循环:JS主线程不断的循环往复的从任务队列中读取任务,执行任务,其中运行机制称为事件循环(event loop)。

1. 同步任务/异步任务

  • 同步任务: 在主线程上排队执行的任务,只有一个任务执行完毕,才能执行下一个任务,
  • 异步任务: 不进入主线程,而是放在任务队列中,若有多个异步任务则需要在任务队列中排队等待,任务队列类似于缓冲区,任务下一步会被移到执行栈然后主线程执行调用栈的任务。

2. 执行栈与任务队列

(1)执行栈:它是一个存储函数调用的栈结构,遵循先进后出的原则。它主要负责跟踪所有要执行的代码。每当一个函数执行完成时,就会从堆栈中弹出(pop)该执行完成函数;如果有代码需要进去执行的话,就进行 push 操作。以下图为例:

20200815002745238.gif

当执行这段代码时,首先会执行一个 main 函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈。JavaScript 在按顺序执行执行栈中的方法时,每次执行一个方法,都会为它生成独有的执行环境(上下文),当这个方法执行完成后,就会销毁当前的执行环境,并从栈中弹出此方法,然后继续执行下一个方法。

(2)任务队列: 它使用到的是数据结构中的队列结构,用来保存异步任务,遵循先进先出的原则。它主要负责将新的任务发送到队列中进行处理。

JavaScript在执行代码时,会将同步的代码按照顺序排在执行栈中,然后依次执行里面的函数。当遇到异步任务时,就将其放入任务队列中,等待当前执行栈所有同步代码执行完成之后,就会从异步任务队列中取出已完成的异步任务的回调并将其放入执行栈中继续执行,如此循环往复,直到执行完所有任务。

JavaScript任务的执行顺序如下:

事件循环.png

3. 宏任务和微任务

异步任务可以分为微任务(Microtasks)宏任务(Macrotasks),常见的任务如下:

  • 宏任务: script( 整体代码)、setTimeoutsetIntervalI/OUI 交互事件、setImmediate(Node.js 环境)
  • 微任务: PromiseMutaionObserverprocess.nextTick(Node.js 环境)

处理宏任务和微任务的逻辑时的执行情况如下:

  1. 代码开始执行,创建一个全局调用栈,整个 script 脚本作为宏任务执行
  2. 执行过程过同步任务立即执行,异步任务根据异步任务类型分别放到微任务队列和宏任务队列
  3. 同步任务执行完毕,查看微任务队列
    • 若存在微任务,将微任务队列全部执行(包括执行微任务过程中产生的新微任务)
    • 若无微任务,查看宏任务队列,执行第一个宏任务,宏任务执行完毕,查看微任务队列,重复上述操作,直至宏任务队列为空

也是就是说,一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务。

下面通过一个例子来体会事件循环:

console.log('同步代码1')

setTimeout(() => {
    console.log('setTimeout')
}, 0)

new Promise((resolve) => {
  console.log('同步代码2')
  resolve()
}).then(() => {
    console.log('promise.then')
})

console.log('同步代码3')

代码输出结果如下:

"同步代码1"
"同步代码2"
"同步代码3"
"promise.then"
"setTimeout"

那这段代码执行过程是怎么的呢?

  1. 遇到第一个console,它是同步代码,加入执行栈,执行并出栈,打印 "同步代码1"
  2. 遇到setTimeout,它是一个宏任务,加入宏任务队列
  3. 遇到new Promise 中的console,为同步代码,加入执行栈,执行并出栈,打印 "同步代码2"
  4. 遇到Promise then,它是一个微任务,加入微任务队列
  5. 遇到第三个console,它是同步代码,加入执行栈,执行并出栈,打印 "同步代码3"
  6. 此时执行栈为空,去执行微任务队列中所有任务,打印 "promise.then"
  7. 执行完微任务队列中的任务,就去执行宏任务队列中的一个任务,打印 "setTimeout"

从上面的宏任务和微任务的工作流程中,可以得出以下结论:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
  • 微任务的执行时长会影响当前宏任务的时长
  • 微任务的优先级高于宏任务

三、Node.js的事件循环

1. 事件循环的概念

当 Node.js 启动时,它会初始化一个事件循环,来处理输入的脚本,这个脚本可能进行异步 API 的调用、调度计时器或调用 process.nextTick(),然后开始处理事件循环。JavaScript 和 Node.js 是基于 V8 引擎的,浏览器中包含的异步方式在 Node 中也是一样的。除此之外, Node.js 中还有一些其他的异步形式:

  • 文件 I/O:异步加载本地文件
  • setImmediate():与 setTimeout 设置 0ms 类似,在某些同步任务完成后立马执行
  • process.nextTick():在某些同步任务完成后立马执行
  • server.close、socket.on('close'):关闭回调

Node.js 使用 V8 作为 JS 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API,事件循环机制也是它里面的实现的,Node.js 的运行机制如下:

  1. V8 引擎负责解析 JavaScript 脚本
  2. 解析后的代码,调用 Node API
  3. libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给 V8 引擎
  4. V8 引擎将结果返回给用户

2. 事件循环的流程

其中libuv引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。下面是Eventloop 事件循环的流程:

事件循环-第 2 页.png

  1. timers 阶段:执行timer(setTimeout、setInterval)的回调,由 poll 阶段控制
  2. I/O callbacks 阶段:主要执行系统级别的回调函数,比如 TCP 连接失败的回调
  3. idle, prepare 阶段:仅Node.js内部使用,可以忽略
  4. poll 阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等
  5. check 阶段:执行 setImmediate() 的回调
  6. close callbacks 阶段:执行关闭请求的回调函数,比如socket.on('close')

注意:上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段,这里也是与浏览器中逻辑差异较大的地方。

其中,这里面比较重要的就是第四阶段:poll,这一阶段中,系统主要做两件事:

  • 回到 timer 阶段执行回调
  • 执行 I/O 回调

这一过程的具体执行流程如下图所示:

image.png

3. 宏任务和微任务

  • 宏任务:setTimeout、setInterval、setImmediate、script(整体代码)、 I/O 操作
  • 微任务:process.nextTick、promise.then()

任务的优先级:process.nextTick > promise.then() > setTimeout > setImmediate

4. process.nextTick() 

它会在上述各个阶段结束时,在进入下一个阶段之前立即执行。

setTimeout(() => {
    console.log('timeout')
}, 0);

Promise.resolve().then(() => {
    console.error('promise')
})

process.nextTick(() => {
    console.error('nextTick')
})

// 输出
nextTick
promise
timeout

5. setImmediate/setTimeout

  • setImmediate:在 poll 阶段完成时执行,即 check 阶段
  • setTimeout:在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行
setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('setImmediate')
})

// 输出
timeout
setImmediate

在上面代码的执行过程中,第一轮循环后,分别将 setTimeout  和 setImmediate 加入了各自阶段的任务队列。第二轮循环首先进入 timer 阶段,执行定时器队列回调,然后 pending callbacks 和 poll 阶段没有任务,因此进入 check 阶段执行 setImmediate 回调。

四、Node与浏览器事件循环的差异

  • Node.js:microtask 在事件循环的各个阶段之间执行
  • 浏览器:microtask 在事件循环的 macrotask 执行完之后执行

Nodejs和浏览器的事件循环流程对比如下:

  1. 执行全局的 script 代码(两者都一样)
  2. 把微任务队列清空:在浏览器中,我们只有一个微任务队列需要接受处理;但在 Node 中有两类微任务队列:next-tick 队列(用来收敛 process.nextTick 派发的异步任务)和其它队列,会优先清空 next-tick 队列中的任务,随后才会清空其它微任务
  3. 开始执行宏任务:在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到系统限制)
  4. 步骤3开始,会进入 3 -> 2 -> 3 -> 2…的循环

参考文章:blog.51cto.com/u_14627797/…