JS 事件循环

tao
发布于2021-09-14 | 更新于2022-03-10

JavaScript 语言的一大特点是单线程,作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM,如果 JavaScript 同时有两个线程,可能会出现一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点的冲突情况。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

单线程就意味着所有任务需要排队,前一个任务结束,后一个任务才会执行,如果前一个任务耗时很大,后一个任务就不得不一直等着,可是有的时候不是因为计算量大 CPU 很忙而造成的耗时长,而是因为 IO设备(输入输出设备) 很慢(比如 Ajax 操作从网络读取数据),不得不等着结果,再往下执行。

JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有的任务可以分成两种,一种是同步任务(synchronous),一种是异步任务(asynchronous),同步任务指的是在主线程上排队执行的任务,异步任务指的是不进入主线程而进入任务队列(task queue)的任务。

异步任务的运行机制如下:
1.所有同步任务都在主线程上执行形成执行栈;
2.主线程之外还有一个任务队列,只要异步任务有了运行结果,就会在任务队列中放置一个事件;
3.一旦执行栈中所有的同步任务执行完毕,系统就会读取任务队列,对应事件的异步任务结束等待状态进入执行栈开始执行;
4.主线程不断重复上面的第三步。

任务队列是一个事件的队列(也可以理解成消息的队列),IO 设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入执行栈了。主线程读取任务队列,就是读取里面有哪些事件。

任务队列中的事件,除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等),定时(比如 setTimeout 等)事件等。只要指定过回调函数,这些事件发生时就会进入任务队列,等待主线程读取。

主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。

bg2014100802.png

一个线程中,事件循环是唯一的,但是任务队列可以有多个,分为宏任务(Task)队列与微任务(Job)队列。

浏览器为了能够使得 JS 内部宏任务与 DOM 任务能够有序的执行,会在一个宏任务结束之后,下一个宏任务开始之前对页面进行重新渲染。setTimeout、setInterval、requestAnimationFrame、IO、用户交互事件会进入宏任务队列。

微任务通常来说就是指当前宏任务执行完毕后立即执行的任务,如果在一个微任务执行期间有新的微任务被加入了微任务队列,之后也会被立即执行,Promise、MutationObserver 事件会进入微任务队列。

实例:

// 回答以下代码的输出值打印顺序
setTimeout(function() {
  console.log("set1");
  new Promise(function(resolve) {
    resolve();
  }).then(function() {
    new Promise(function(resolve) {
      resolve();
    }).then(function() {
      console.log("then4");
    });
    console.log("then2");
  });
});

new Promise(function(resolve) {
  console.log("pr1");
  resolve();
}).then(function() {
  console.log("then1");
});

setTimeout(function() {
  console.log("set2");
});

console.log(2);

new Promise(function(resolve) {
  resolve();
}).then(function() {
  console.log("then3");
});

/*
pr1
2
then1
then3
set1
then2
then4
set2
*/

Node.js 也是单线程的 Event Loop,但它的运行机制不同于浏览器环境。

bg2014100803.png

应用层:即 JavaScript 交互层,常见的就是 Node.js 的模块,比如 http、fs;
V8 引擎层:即利用 V8 引擎来解析 JavaScript 语法,进而和下层 API 交互;
NodeAPI 层:为上层模块提供系统调用,一般是由 C 语言来实现,和操作系统进行交互;
LIBUV层:是跨平台的底层封装,实现了事件循环、文件操作等,是 Node.js 实现异步的核心 。

根据上图,Node.js的运行机制如下:
1.V8引擎解析JavaScript脚本;
2.解析后的代码,调用Node API;
3.libuv 库负责 NodeAPI 的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎;
4.V8 引擎再将结果返回给用户。

Node.js 还提供了另外两个与任务队列有关的方法 process.nextTick 和 setImmediate。

process.nextTick() 是一个特殊的异步 API,他不属于任何的 Event Loop 阶段。事实上 Node 在遇到这个 API 时,Event Loop 根本就不会继续进行,会马上停下来执行 process.nextTick(),这个执行完后才会继续Event Loop。process.nextTick() 比 Promise 优先级更高。

截屏2022-03-10 上午11.08.48副本.png

timers:执行 setTimeout 和 setInterval 的回调;
pending callbacks:执行延迟到下一个循环迭代的 I/O 回调;
idle, prepare:仅系统内部使用;
poll:检索新的 I/O 事件,执行与 I/O 相关的回调。事实上除了其他几个阶段处理的事情,其他几乎所有的异步都在这个阶段处理;
check:setImmediate 在这里执行
close callbacks:一些关闭的回调函数,如:socket.on('close', ...)。


参考文章:
1.JavaScript 运行机制详解:再谈Event Loop
2.setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop