事件循环补全计划

前言

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

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

浏览器 - 事件循环初探

https://youtu.be/8aGhZQkoFbQ

JavaScript运行的基本流程

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

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

function fun1() {
  return 'fun1'
}

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

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

fun3();

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

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

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

+----------+ +----------+ +----------+
|    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();

能够在控制台中的报错中看到调用栈的信息:web

clipboard.png

可是调用栈也是脆弱的若是咱们建立一个没法中断调用函数, 那么调用栈会瞬间爆炸💥这种行为被称为 "栈溢出". 可喜可贺的是JavaScript会监视调用栈, 一旦调用栈出现 "栈溢出" JavaScript 会终止代码的执行而且抛出错误:面试

function foobar() {
    foobar();
}

foobar();

抛出 "栈溢出" 错误:ajax

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...

相关文章
相关标签/搜索