事件循环补全计划

1

前言

事件循环(event loop)是一个在 JavaScript 常被提起的概念, 它存在于浏览器也存在于 Node.js 中, 因为它复杂的运行机制令人难以琢磨, 更是成为面试时候的必考题目.

本篇文章的内容源自我对网络上的一些介绍 "事件循环" 的演讲视频中内容的总结, 其中大多数来自于 jsconf. 在每一章中我都附上的视频地址, 建议之间直接观看视频相于文字来说视频更容易理解.

浏览器 - 事件循环初探

https://youtu.be/8aGhZQkoFbQ

JavaScript运行的基本流程

我们都知道JavaScript是一个单线程的编程语言, 这意味着它只能有一个调用栈, 执行过程中每次只能做一件事情.

为了充分理解, 我们来例举一个简单的例子:

function fun1() {
  return 'fun1'
}

function fun2() {
  return 'fun2' + fun1();
}

function fun3() {
  console.log(fun2());
}

fun3();

一旦代码执行调用栈中函数压入的顺序如下:

+----------+ +----------+ +----------+
|    stack | |    stack | |    stack |
|          | |          | |          |
|          | |          | |          |
|          | |          | | +------+ |
|          | |          | | | fun3 | |
|          | |          | | +------+ |
|          | |          | |          |
|          | | +------+ | | +------+ |
|          | | | fun2 | | | | fun2 | |
|          | | +------+ | | +------+ |
|          | |          | |          |
| +------+ | | +------+ | | +------+ |
| | fun1 | | | | fun1 | | | | fun1 | |
| +------+ | | +------+ | | +------+ |
|          | |          | |          |
+----------+ +----------+ +----------+

位于栈顶的函数(fun3)执行完成后调用栈会把他弹出:

+----------+ +----------+ +----------+
|    stack | |    stack | |    stack |
|          | |          | |          |
|          | |          | |          |
|          | |          | |          |
|   💥     | |          | |          |
|          | |          | |          |
|          | |          | |          |
| +------+ | |          | |          |
| | fun2 | | |    💥    | |          |
| +------+ | |          | |          |
|          | |          | |          |
| +------+ | | +------+ | |          |
| | fun1 | | | | fun1 | | |    ✨    |
| +------+ | | +------+ | |          |
|          | |          | |          |
+----------+ +----------+ +----------+

调用栈被JavaScript引擎使用和监视, 所以当我们在函数中抛出了一个错误但是没有对应的 try/catch 语句的时候:

function fun1() {
  throw new Error('Warning: Nuclear Missile Launched') // 抛出一个错误但是没有对于的 try/catch 语句
}

function fun2() {
  return 'fun2' + fun1();
}

function fun3() {
  console.log(fun2());
}

fun3();

可以在控制台中的报错中看到调用栈的信息:

clipboard.png

但是调用栈也是脆弱的如果我们创建一个无法中断调用函数, 那么调用栈会瞬间爆炸💥这种行为被称为 "栈溢出". 可喜可贺的是JavaScript会监视调用栈, 一旦调用栈出现 "栈溢出" JavaScript 会终止代码的执行并且抛出错误:

function foobar() {
    foobar();
}

foobar();

抛出 "栈溢出" 错误:

clipboard.png

了解 "阻塞" 以及它的危害

在 Web 开发中我们经常要会听到要避免浏览器阻塞, "阻塞" 这个词感觉上有点像我们常说的 "电脑卡住了" 中的 "卡".

实际上阻塞并没有一个严格的定义, JavaScript 的运行速度是很快的, 执行几个简单的 "console.log" 你无法体会到这其中所花费的时间, 如果调用栈中正在执行的函数花费了大量的时间, 我们感觉浏览器被卡住了, 体验不流畅此时我们说, 浏览器被阻塞了.

但是浏览器不仅仅执行 JavaScript 代码还会异步的加载外部资源文件, 这些加载的流程也可以被 JavaScript 所控制, 例如我们通过 JavaScript 发起一个同步的网络请求:

var oReq = new XMLHttpRequest();
oReq.onload = function(){};
oReq.open("get", "https://xxx.com/", false); // false 表示同步发送请求(这种方式已经不在被推荐使用仅仅用于示例)
oReq.send();
alert('running!'); // 只有请求完成后 alert 才会执行

这个请求有可能需要 20ms 或者 300ms 甚至更长, 在请求的过程中浏览器会一直等待请求完成甚至会停止页面的渲染, 我们可以明显的感受到浏览器卡住了, 这是典型的阻塞.

异步回调

浏览器将所有可能花费大量时间等待的操作都提供了对应的异步接口, 这种解决方式被称为 "异步函数" 或者 "回调函数" 或者 "异步回调" 等.

一个典型的例子如下:

console.log('hello');

setTimeout(function foobar(){
    console.log('delay');
},1000);

console.log('world');

我们都知道这段代码会输出的顺序是:

  1. hello
  2. world
  3. delay

那么浏览器到底是如何解释这段代码的呢, 我们可以观察浏览器的调用栈:

console.log('hello'); // 压入栈中执行
console.log('hello'); // 执行完成栈弹出

setTimeout            // 压入栈中执行
setTimeout            // 执行完成栈弹出

console.log('world'); // 压入栈中执行
console.log('world'); // 执行完成栈弹出

// -------1000ms过后------

foobar                // 压入栈中执行
console.log('delay'); // 压入栈中执行
console.log('delay'); // 执行完成弹出
foobar                // 执行完成弹出

最神奇的事情出现了 setTimeout 执行完成后就被调用栈弹出了, 但是不知何故 1000ms 后 setTimeout 中的 foobar 被神奇的唤醒了, 这是怎么回事?

webapi

在 JavaScript 中执行元素绑定事件, 使用 setTimeout 设置一个延时执行, 或者发起一次网络请求. 这些都是web开发者的家常便饭, 对于我们来说这些内容就是 JavaScript 的一部分了, 但是实际上这些内容并没有在 ECMAScript 制定的标准中, 包括我们讨论的 "事件循环" 机制它也没有存在于规范中也就是说这个机制是独立于 JavaScript 引擎的功能.

这些API被称为 webapi 它们有自己的规范和实现与 JavaScript 这门语言没有关系, 在这份来自于MDN的页面上列举了大部分的API.

基本事件循环

我们之前提到了 JavaScript 由于其单线程的特性只能在同一时间执行同一间事情, 这是正确的, 但是浏览器不仅仅拥有解释 JavaScript 脚本的引擎, 还有一堆其他的程序来处理诸如 DOM XmlHttpRequest setTimeout 这些任务.

这些程序各司其职完成自己负责的部分, 它们是独立于 JavaScript 引擎之外的内容, 这些程序可能运行在独立线程上或者进程上它们通过webapi 来和 JavaScript引擎进行通信, 所以我们需要通过 "回调" 的方式进行异步编程.

事件循环 用于管理这些异步任务在合适的时机执行.

setTimeout 作为 webapi 典型的例子, 我们来观察一下它是如何运行的, 首先 setTimeout 一旦被执行便将函数钩子移交给对应的 webapi 并且开始计时:

+--------------------------------+     +---------------------+
|                                |     |webapis              |
|                                |     | +----------------+  |
| setTimeout(function foobar() { |     | |                |  |
|     console.log('delay')       | +-> | | timer-0 -- cb()|  |
| },0);                          |     | |                |  |
|                                |     | +----------------+  |
|                                |     |                     |
+--------------------------------+     +---------------------+

由于我们的倒计时是0, 所以计时会立即完成, webapi 将函数钩子移交给 任务队列 .

+--------------------------------+     +----------+
|                                |     |webapis   |
|                                |     |          |
| setTimeout(function foobar() { |     |          |
|     console.log('delay')       |     |          | +-+
| },0);                          |     |          |   |
|                                |     |          |   |
|                                |     |          |   |
+--------------------------------+     +----------+   |
                                                      |
+-------------------------------------------------+   |
|                               task queue        |   |
| +--------------+                                |   |
| |              |                                |   |
| |    cb()      |                                | <-+
| |              |                                |
| +--------------+                                |
|                                                 |
+-------------------------------------------------+

接下来终于轮到 事件循环 上场了, 事件循环完成一件非常简单的工作, 它判断如果:

  1. 调用栈是空的
  2. 任务队列中存在着任务

那么就将这个任务移入到调用栈中执行:

+--------------------------------+ +------------------+
|                                | |           stack  |
|                                | | +--------------+ |
| setTimeout(function foobar() { | | |              | |
|     console.log('delay')       | | |  cb - foobar | |
| },0);                          | | |              | |
|                                | | +--------+-----+ |
|                                | |          ^       |
+--------------------------------+ |          |       |
event loop ⏳                      |          |       |
+----+---------------------------+ |          |       |
|    |                           | |          |       |
|    |                           | |          |       |
|    +-- task ------> push -------------------+       |
|                                | |                  |
+--------------------------------+ +------------------+

然后往复循环这个过程, 这就是事件循环的基本流程.

现在我们来提出一个 ajax 的例子, 尝试一下你能说出他的执行流程吗:

console.log('hello');

$.get('https:www.google.com',function foobar(){ console.log('callback') });

console.log('world');

他的执行流程如下:

  1. console.log('hello') --> 压入调用栈中执行
  2. console.log('hello') --> 执行完成弹出调用栈
  3. $.get --> 压入栈中执行
  4. $.get --> 调用了 webapi 中的 XHR 发起了网络请求, 并保存其函数钩子
  5. $.get --> 执行完成弹出调用栈
  6. console.log('world') --> 压入调用栈中执行
  7. console.log('world') --> 执行完成弹出调用栈
  8. 网络请求完成 webapi 将函数钩子移动到任务队列中
  9. 事件循环--> 检查调用栈是否为空 检查任务队列中是否有任务 (事件循环一直存在并非在只在此刻进行检查)

    1. 调用栈为空
    2. 检查到请求任务
    3. 将该任务(foobar函数)压入到调用栈中
  10. console.log('callback') --> 压入调用栈中执行
  11. console.log('callback') --> 执行完成弹出调用栈
  12. foobar --> 执行完成弹出调用栈
  13. 调用栈再次清空

事件循环如何影响渲染

https://youtu.be/cCOL7MC4Pl0

在上一节中我们提到了 JavaScript 是单线程的如果花费大量的时间运行 JavaScript 那么就会阻塞浏览器, 导致浏览器无法完成其他工作, 并且初次了解了 "事件循环" 是如何解决此问题的. 在这一节中我们会更加了解另外一个话题 "事件循环" 与页面渲染之间的关系.

实际上事件循环的作用范围可能超乎你的想象, 所有的DOM事件实际上都是受到了 "事件循环" 控制的, 除此以外还包括包括网络请求, IO操作等等. 因为在背后这些事件的触发者实际上都是将与事件有关的信息放入到了 "任务队列" 中真正让这些内容被执行的实际上是 "事件循环", 还记得 "事件循环" 是如何工作的吗?

当下列条件成立时, "事件循环" 会将最近的任务移送到 "调用栈" 中:

  1. 任务队列中含有任务
  2. 调用栈为空

但是浏览器不仅可以执行 JavaScript 还会可以处理页面渲染, 我们知道如果进行大量的 JavaScript 计算会阻塞页面渲染, 这里到底有何种联系? 在此之前我们先来了解一下基本的渲染概念.

基本的渲染

我们在页面中动态的修改样式界面我们可以看到实时的效果, 所以给我们一种修改样式是同步的操作的错觉, 实际上浏览器在背后进行了优化, 重复多此的样式操作会被进行合并然后由浏览器决定一个合适的时机然后统一更新:

element.style.transition = "transform 1s";
element.style.transform = "translateX(100px)";
element.style.transform = "translateX(500px)";

应用了样式的元素并不会横向在 100px 和 500px 之间来回移动而是直接移动到了 500px, 浏览器抛弃了旧的无用的样式修改.

所以说修改样式并不是实时的, 这种批量更新样式的机制导致它和 "事件循环" 之间产生了一些微妙的关联, 在了解这些关联前我们先来了解一下浏览器的基本渲染机制.

元素样式决定渲染的结果, 从一堆代码转为可视化的界面经历了许多环节, 这里我们来简单的了解一下其中的几个关键步骤:

  1. 计算样式 - 收集 css 计算应用到每个元素上的样式
  2. 布局 - 确定页面上的元素的位置与层叠关系
  3. 绘制 - 创建实际的像素绘制到页面上

被动的渲染

在下面的这个例子中使用了一个 video 来表示页面进行动态的持续的页面渲染, 这些嵌入的内容可以在不受 JavaScript 的干扰下影响页面的显示, 当点击按钮的时候 JavaScript 进入死循环:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>test</title>
</head>
<body>
  <video src="https://vjs.zencdn.net/v/oceans.mp4" controls autoplay></video>
  <button id="button">while true</button>
  <script>
    document.getElementById('button').addEventListener('click',()=>{
      while(true);
    });
  </script>
</body>
</html>

当我们点击了按钮的时候:

  1. click 事件被放入到了任务队列中
  2. 事件循环将click 事件压入调用栈
  3. while true 执行

此时渲染工作在等待 JavaScript 执行完成, 但是 JavaScript 进入了无限循环中所以渲染工作就一直在等待中永远不会得到完成.

但是页面的渲染却受到了来自事件循环中的阻塞, 为什么会这样?

解释这种行为的一个好的方式就是: 我们不妨把页面的渲染过程也视为 "任务队列" 中的一个任务, 这个任务的创建者就是浏览器本身,它在一个合适的时机把渲染任务放入到任务队列中等待执行, 但是由于代码阻塞导致页面无法及时更新.

思考

下列代码会造成阻塞吗:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>test</title>
</head>
<body>
  <video src="https://vjs.zencdn.net/v/oceans.mp4" controls autoplay></video>
  <button id="button">while true</button>
  <script>
    function loop(){
      setTimeout(loop,0);
    }
    loop();
  </script>
</body>
</html>

这段代码不仅不会造成阻塞甚至不会造成栈溢出, 每次调用 loop 函数会向 "任务队列" 添加一个任务然后 loop 就会被弹出调用栈, 此时的调用栈就被清空了. 而负责控制任务队列的事件循环只有在调用栈为空的时候才能继续执行任务, 也就是说调用栈永远不会累加.

其次任务的执行并不影向任务队列添加内容, 在调用 setTimeout(loop,0) 后 "事件循环" 继续工作处理那些已经被填入到任务队列中在此之前的填入的其他事件以及页面的渲染, 直到 "任务队列" 中再次执行有关 loop 的任务.

流畅的渲染

试想一下你在页面上制作了一个动画效果使用如下代码:

function animate(){
  // 修改样式
}

setInterval(animate,1000/60);

你希望动画可以达到 60fps 所以向 setInterval 传入了 1000/60 期待它可以每秒执行 60 次动画函数.

但是由于 setInterval 并不精确在一帧中可能执行了多此, 也可能一次也没有执行, 或者执行了一个耗时的任务导致浏览器无法在一帧中进行渲染操作.

我们希望每一帧中至少有一次渲染过程, 但是随机的任务执行会打乱理想中有规律的渲染过程, 导致渲染操作不能平均分布到每帧中:

clipboard.png

而浏览器本身的渲染实际上是非常智能且节约计算. 例如页面渲染频率自动和屏幕刷新率调整到一致, 当页面静止或者不可视的时候页面会停止渲染, 而使用 setInterval 等很难完美的和页面渲染过程相结合.

一个解决问题的办法就是使用 requestAnimationFrame.

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画

使用 requestAnimationFrame 我们可以使用浏览器的渲染逻辑将原本杂乱的渲染过程变得有序起来, 让浏览器决定何时进行渲染, 对于动画渲染这再好不过了, 现在有关动画的任务都被排列到了渲染任务的前面:

clipboard.png

macrotask(宏任务) 和 microtask(微任务)

有关宏任务的概念在前面我们已经涉及到了, 在浏览器中以下的几个异步 API 是宏任务相关:

  • setTimeout
  • setInterval
  • setImmediate
  • UI rendering

而微任务是一个简单的概念, 我们从微任务的设计历史来解释微任务为什么这样执行.

很久前W3C给浏览器制定了一些API, 这些API用于监听DOM的变化:

element.addEventListener("DOMNodeInserted", function (ev) {
  // ...
}, false);

但是这个API有着严重的性能问题, 只要修改元素的属性对应的事件就会被触发, 对同一个属性修改100次就会触发100次的事件, 另外事件具有冒泡的特性子元素的修改也会导致父元素触发该事件:

let i = 100;
while(i--){
  const span = document.createElement('span');
  element.appendChild(span); // 追加元素触发事件一次
  span.textContent = 'Test'; // 修改追加的元素的属性又触发一次事件
}

结果就是无论你在 DOMNodeInserted 回调中写多么简单的代码, 复杂的DOM操作导致事件就会被密集调用大大降低性能, 所以这个API被废弃了.

监听DOM修改的需求依然存在, 但是我们希望这个接口的表现就和渲染一样将DOM修改进行合并后只触发一次, 这个规范在DOM3中被推出他就是 MutationObserver, 从而引入了 "微任务" 和 "微任务队列" 这个概念.

微任务是异步的我们用个 Promise 来举例:

Promise.resolve().then(()=>console.log('hello world'));
console.log('foobar');

输出:

foobar
hello world

foobar 先于 hello world 输出这点证明了它. 虽然他是异步的但这不代表他必须遵循 "事件循环" 和 "渲染" 制定的规则. 相反 "微任务" 有自己的玩法.

一个典型的特征就是只有微任务队列清空后微任务才算执行完成, 我们把之前的 "事件循环" 例子改为微任务版本:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>test</title>
</head>
<body>
  <video src="https://vjs.zencdn.net/v/oceans.mp4" controls autoplay></video>
  <button id="button">while true</button>
  <script>
    document.getElementById('button').addEventListener('click',()=>{
      function loop(){
          Promise.resolve().then(loop);
      }
      loop();
    });
  </script>
</body>
</html>

结果就是当点击按钮后页面渲染会停止浏览器进入到阻塞中, 原因很简单 "微任务队列" 中永远有任务所以浏览器一直在等待 "微任务队列" 清空然后一直执行微任务, 不幸的是这是一个无尽的任务队列所以它永远无法执行完.

微任务的另外一个特性就是 JavaScript 调用栈一旦被清空, 微任务队列中的任务执行会先于其他队列, 请观察下面的例子:

const button = document.getElementById('button');

button.addEventListener('click',()=>{
    Promise.resolve().then(()=>console.log('Microtask 1'));
    console.log('task 1');
});

button.addEventListener('click',()=>{
    Promise.resolve().then(()=>console.log('Microtask 2'));
    console.log('task 2');
});

点击按钮后输出顺序:

task 1
Microtask 1
task 2
Microtask 2

按钮点击后的执行流程如下:

  1. "事件循环" 将第一个点击事件中的匿名函数压入调用栈

    1. 微任务队列中添加任务
    2. 执行 console.log('task 1')
    3. 匿名函数执行完成弹出调用栈
    4. 执行微任务队列中的任务
    5. 执行 console.log('Microtask 1')
    6. 微任务队列清空
  2. "事件循环" 将第二个点击事件中的匿名函数压入调用栈

    1. 微任务队列中添加任务
    2. 执行 console.log('task 2')
    3. 匿名函数执行完成弹出调用栈
    4. 执行微任务队列中的任务
    5. 执行 console.log('Microtask 2')
    6. 微任务队列清空

不过如果要把这个例子稍稍修改一下情况却略有不同:

const button = document.createElement('button');

button.addEventListener('click',()=>{
    Promise.resolve().then(()=>console.log('Microtask 1'));
    console.log('task 1');
});

button.addEventListener('click',()=>{
    Promise.resolve().then(()=>console.log('Microtask 2'));
    console.log('task 2');
});

button.click();

这次我们手动触发 click 事件, 输出结果如下:

task 1
task 2
Microtask 1
Microtask 2

这次的执行流程为:

  1. button.click() 被压入调用栈执行
  2. 同步触发 'click' 事件并将第一个事件回调压入调用栈

    1. 微任务队列中添加任务 console.log('Microtask 1')
    2. 执行 console.log('task 1')
    3. 匿名函数执行完成弹出调用栈
  • 注意: 此时 button.click 并未执行完成还在调用栈中
  1. 同步触发 'click' 事件并将第二个事件回调压入调用栈

    1. 微任务队列中添加任务 console.log('Microtask 2')
    2. 执行 console.log('task 2')
    3. 匿名函数执行完成弹出调用栈
  2. button.click 从调用栈中弹出
  3. 清空微任务队列
  4. 执行 console.log('Microtask 1')
  5. 执行 console.log('Microtask 2')
  6. 微任务队列清空

在浏览器中以下的 API 是微任务的任务源:

  • Promise
  • MutationObserver

Node.js 中的事件循环

https://youtu.be/zphcsoSJMvM

计算机线程进化史

回到 ms-dosApple os 的时代那时候的操作系统使用命令行界面, 计算机CPU只有一个核心, 操作系统同一时间下只能执行一件事情.通过操作界面告诉操作系统你要运行一个应用程序, 此时系统会停止运行并运行那个程序, 当应用程序执行完后又把执行权力交由操作系统.

这种设计有着非常大的限制, 你无法同时执行多件事情, 于是一种被称作协作多任务(cooperative multitasking)的机制出现了. 这种设计下你可以同时运行多个程序.

但是这种机制出现后表现各大操作系统的实现也不是十分完美, 在当时的 Mac OSwindows 操作系统中这种多程序执行的实现交由应用程序决定, 如果程序没有编写对应的代码那么这个程序会一直占用CPU资源, 如果一旦程序崩溃甚至会牵扯到系统, 导致系统崩溃.

后来这种机制被改为了抢占式多任务(preemptive multitasking), 运行哪个程序由操作系统决定, 在切换应用程序的时候他会把正在运行中的程序暂停然后保留其状态存储到其他位置中, 然后加载另外一个程序. 这项机制最初引用到了面向服务器的 Unix 系统, 在随后的时间里才应用到了使用 NT 内核的 windows2000 和同时期的 Mac OS 1004 的个人电脑中.

此时AMD刚刚发布了它的多核CPU, 为了充分利用多核CPU的性能, 均衡多线程(symmetric multi threading)技术诞生了, 该技术的实际应用被 intel 首先采用并重新命名为超线程(hyper threading). 该技术的主要原理是: CPU在执行任务的时候并非一直满载执行, 这里有很多空闲资源可以利用, 而超线程允许一个CPU核心可以同时处理多件事情充分的利用CPU空闲资源.

在上文中我们提到了两个基本的概念 "任务" 和 "线程", 实际上任务就是我们常说的 "进程", 线程和进程的概念我们就在这里不提了, 我们需要提到的一点就是线程的 "竞态". 线程的执行是并行的线程间共享内容, 当两个线程操作同一个数据的时候会出现这种问题.

假设我们有两个线程线程A向全局变量写入一个数据,线程B读取对应的全局变量,由于两个线程都是并行的所以线程A可能在线程B读取前进行了写入, 也有可能线程B先读取后线程A再写入, 每次运行都会得到不同的结果. 这让程序充满了不确定性也会导致很多bug, 许多语言都提供了线程安全的操作来避免问题, 不过即使是经验丰富的程序员往往也得仔细思考才能设计出线程安全的程序.

对于Node来说解决的方式非常简单直接, 我们使用单线程模型, 不允许你使用多线程模型(Node 11中添加了实验中的多线程支持).

Event loop

Node.js 官网中有篇专门介绍事件循环的文章, 也是这节的核心, 这篇文章已经被翻译完成 👉访问连接. 本来打算放到这这篇文章中来的但是这样做导致本文太长了, 所以就移除出去了, 是一篇十分重要的文章, 对于理解 Node.js 中的事件循环至关重要.

有关事件循环的常见错误

https://youtu.be/gl9qHml-mKc

Node是单线程的

JavaScript 是单线程的这没有问题, 因为在 Node 中所有的 "JavaScript 脚本", "V8 引擎", "事件循环", 都运行在一个线程中这个线程被称为 "主线程".

但是这不意味着 Node 本身是单线程的因为 Node 还有其他部分. Node 的源码中还包括了 C++ 代码, C++部分拥有操作线程的能力, 这取决于你调用 JavaScript API的方式. 例如如果你调用了一个 Node 的 API, 这个 API 背后是由 C++ 代码提供支持的. 如果你同步的调用那么 C++ 代码会在主线程上执行. 如果你调用一个异步的 Node 接口, 那么 C++ 有可能会使用额外的线程来执行这个任务.

所以使用同步 API 那么 Node 就没有机会利用多线程的并行计算的特性来提升性能, 所以在任何时候都推荐使用异步的接口, 这样可以利用 Node 内部的线程机制进行优化执行效率.

在默认情况下 Node 会使用线程池线程池的容量为 4 (可以通过环境变量进行修改), 当需要线程的任务超过4个后, Node 会将任务放入到任务队列中. 一旦空闲线程出现 Node 会将任务从队列中取出放入到线程中执行.

图片:在 windows10 上使用命令行刚刚启动的 Node 就使用了12个线程:

clipboard.png

Event Loop 是基于多线程模型的

有一些任务不依赖线程池而是依赖操作系统提供的接口, 例如 http.request 背后 C++ 会尽可能的调用系统提供的异步接口 epoll(linux) kqueue(mac os), GetQueuedCompletionStatusEx(Windows)来将任务委托给操作系统去完成.

下列的列表中例举了异步 API 背后的运行机制:

  • Kernel Async

    • tcp/udp sockets, servers
    • unix domain sockets, servers
    • pipes
    • tty input
    • dns.resolveXXX
  • Thread Pool

    • files
    • fs.*
    • dns.lookup
    • pipes(exceptional)
  • Signal Handler(posix only)

    • child processes
    • signals
  • Wait Thread(windows only)

    • child processes
    • console input
    • tcp servers(exceptional)

Event Loop 运行在独立线程中

很多人认为 Event loop 是独立的它运行在一个单独的线程中, 但是实际上 Event Loop 作为 JavaScript 部分的内容是和 JavaScript 一样运行在主线程上的.

Event Loop 的概念类似于栈或者队列

如果你看过 Node.js 官方介绍事件循环的文章你就会知道(文章地址), 事件循环并不是简单的栈或者队列的概念, 而是多个 "阶段" 的集合, 在不同的 "阶段" 中用于保存任务的数据结构和执行逻辑是不同的.

事件循环在 Node 于浏览器中的异同

两者的不同点主要在事件循环的执行机制上:

  • 浏览器在执行完宏任务(micro-task)后会检查是否存在微任务(micro-task), 如果存在微任务则只有将所有的微任务执行完成后才会继续执行宏任务.
  • Node 把事件循环分为了多个阶段, 在一个阶段中集中执行同类型的任务, 执行任务所产生的新的任务被记录, 推至下一轮循环执行. 微任务在该阶段的末尾执行.

此外 Node 还有特殊的 process.nextTick, 该 API 被视为微任务源之一, 但是执行方式和浏览器中的方式不同. 在 Node11 后 Node 端的微任务执行效果开始和浏览器端趋同.

Node 和浏览器共有 setTimeoutPromise 这两个接口, 一个被视为宏任务源另一个被视为微任务源, 我们使用这两个 API 来做个小测试, 测试用例如下:

setTimeout(() => {
  console.log('setTimeout - 1');
  setTimeout(() => {
    console.log('setTimeout - 1 - 1')
  });
  new Promise(resolve => resolve()).then(() => {
    console.log('setTimeout - 1 - then')
    new Promise(resolve => resolve()).then(() => {
      console.log('setTimeout - 1 - then - then')
    });
  });
});

setTimeout(() => {
  console.log('setTimeout - 2');
  setTimeout(() => {
    console.log('setTimeout - 2 - 1')
  });
  new Promise(resolve => resolve()).then(() => {
    console.log('setTimeout - 2 - then')
    new Promise(resolve => resolve()).then(() => {
      console.log('setTimeout - 2 - then - then')
    });
  });
});

在浏览器端输出如下:

setTimeout - 1
setTimeout - 1 - then
setTimeout - 1 - then - then
setTimeout - 2
setTimeout - 2 - then
setTimeout - 2 - then - then
setTimeout - 1 - 1
setTimeout - 2 - 1

解释:

// 两个 setTimeout 中的任务都已经被到任务队列中.
-------------
// 执行任务 - 1
setTimeout - 1
    - setTimeout 添加了一个新的任务 - 3
    - Promise 添加了一个新的微任务
    // 第一个任务结束, 发现微任务存在, 执行微任务
setTimeout - 1 - then
    - Promise 添加了一个新的微任务
    // 微任务执行完成, 发现新的微任务, 执行微任务
setTimeout - 1 - then - then
    // 微任务执行完成
// 执行任务 - 2
setTimeout - 2
    - setTimeout 添加了一个新的任务 - 4
    - Promise 添加了一个新的微任务
    // 第一个任务结束, 发现微任务存在, 执行微任务
setTimeout - 2 - then
    - Promise 添加了一个新的微任务
    // 微任务执行完成, 发现新的微任务, 执行微任务
setTimeout - 1 - then - then
    // 微任务执行完成
// 执行任务 3
setTimeout - 1 - 1
// 执行任务 4
setTimeout - 2 - 1

Node10 执行如下:

// 进入 timer 阶段
-------------
// 执行任务 - 1
setTimeout - 1
  - setTimeout 添加了一个新的任务 - 3
  - Promise 添加了一个新的微任务
// 执行任务 - 2
setTimeout - 2
  - setTimeout 添加了一个新的任务 - 4
  - Promise 添加了一个新的微任务
// timer 阶段结束
// 执行微任务
setTimeout - 1 - then
  - Promise 添加了一个新的微任务
// 执行微任务
setTimeout - 2 - then
  - Promise 添加了一个新的微任务
// 执行微任务
setTimeout - 1 - then - then
// 执行微任务
setTimeout - 2 - then - then
// 微任务处理完成
// 进入其他阶段开始循环 -> 直到再次进入 timer 阶段
// 执行任务 3
setTimeout - 1 - 1
// 执行任务 4
setTimeout - 2 - 1

正如之前所说 Node11 后执行逻辑发生了变化执行逻辑向浏览器靠拢, 这里给出 Node12 中执行相同代码的输出:

setTimeout - 1
setTimeout - 2
setTimeout - 1 - then
setTimeout - 2 - then
setTimeout - 1 - then - then
setTimeout - 2 - then - then
setTimeout - 1 - 1
setTimeout - 2 - 1

另外我们都知道 Node 有一个 process.nextTick, 修改之前的代码后:

setTimeout(() => {
  console.log('setTimeout - 1');
  setTimeout(() => {
    console.log('setTimeout - 1 - 1')
  });
  process.nextTick(() => {
    console.log('setTimeout - 1 - nextTick')
    process.nextTick(() => {
      console.log('setTimeout - 1 - nextTick - nextTick')
    });
  });
});

setTimeout(() => {
  console.log('setTimeout - 2');
  setTimeout(() => {
    console.log('setTimeout - 2 - 1')
  });
  process.nextTick(() => {
    console.log('setTimeout - 2 - nextTick')
    process.nextTick(() => {
      console.log('setTimeout - 2 - nextTick - nextTick')
    });
  });
});

在 Node10 中输出如下:

setTimeout - 1
setTimeout - 2
setTimeout - 1 - nextTick
setTimeout - 2 - nextTick
setTimeout - 1 - nextTick - nextTick
setTimeout - 2 - nextTick - nextTick
setTimeout - 1 - 1
setTimeout - 2 - 1

我们可以看到输出和结果和使用 Promise 别无二致, 另外在 Node12 中的输出如下:

setTimeout - 1
setTimeout - 1 - nextTick
setTimeout - 1 - nextTick - nextTick
setTimeout - 2
setTimeout - 2 - nextTick
setTimeout - 2 - nextTick - nextTick
setTimeout - 1 - 1
setTimeout - 2 - 1

结果和之前的使用 Promise 也是没有区别的, 不过同样身为微任务源之一, 在 Node 中还是有先后之分的:

setTimeout(() => {
  console.log('setTimeout - 1');
  
  process.nextTick(() => {
    console.log('setTimeout - 1 - nextTick')
    process.nextTick(() => {
      console.log('setTimeout - 1 - nextTick - nextTick')
    });
  });

  new Promise(resolve => resolve()).then(() => {
    console.log('setTimeout - 1 - then')
    new Promise(resolve => resolve()).then(() => {
      console.log('setTimeout - 1 - then - then')
    });
  });
});

Node10 和 Node12 输出:

setTimeout - 1
setTimeout - 1 - nextTick
setTimeout - 1 - nextTick - nextTick
setTimeout - 1 - then
setTimeout - 1 - then - then

可以看出 process.nextTick 的优先级高于 Promise 的.

参考

https://nodejs.org/en/docs/gu...

https://nodejs.org/dist/lates...

https://www.dynatrace.com/new...

https://blog.csdn.net/Fundebu...

https://www.cnblogs.com/MuYun...

http://www.ruanyifeng.com/blo...

https://jsbin.com/dijodahawi/...

https://jakearchibald.com/201...

https://segmentfault.com/a/11...

你可能感兴趣的

载入中...