乐趣区

浏览器和Node中的事件循环机制

一、前言

前几天听公司一个公司三年的前端说“今天又学到了一个知识点 - 微任务、宏任务”,我问他这是什么东西,由于在吃饭他浅浅的说了下,当时没太理解就私下学习整理一番,由于谈微任务、宏任务必谈到事件循环,于是就有了这篇博客。

在谈到事件循环机制之前我们需要知道一些基础知识就是:

  • js 是单线程的
  • js 一开始是作为脚本语言运行在客户端

其实 js 是单线程在它作为脚本语言操作 dom 的时候就决定了。那么此时就有一个性能问题,那么 js 在浏览器端是如何处理这个问题的呢?同时,js 在后台 Node 中又是如何解决的呢?这就是本篇需要介绍的事件循环机制,这里我将分别以浏览器和 Node 两个方面来分析。

二、浏览器端

在讲解事件循环之前先谈谈 js 中同步代码、异步代码的执行流程。

2.1、js 同步代码执行过程

js 引擎在执行通过代码的过程中,会安装顺序依次存储到一个地方去,这个地方就叫做 执行栈,当我们调用一个方法的时候,js 会生成一个和这个方法相对应的上下文(context)。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的 this 对象。

function a() {console.log("method a execute...");
}
function b() {a();
}
function c() {b();
}
c();

以上面例子分析:js 在执行的时候会有一个全局上下文, 我们这里就称为 GContext, 下面分析步骤

  1. 调用 c(),c 入栈, 此时栈中内容为:GContext->c-contextC
  2. 接着调用 b(),b 入栈, 此时栈中内容为:GContext->c->contextC->b->contextB
  3. 接着调用 a(),a 入栈, 此时栈中内容为:GContext->c->contextC->b->contextB-c->contextC
  4. a 执行完,a 出栈, 此时栈中内容为:GContext->c->contextC->b->contextB
  5. b 执行完,b 出栈, 此时栈中内容为:GContext->c->contextC
  6. c 执行完,b 出栈, 此时栈中内容为:GContext
  7. 全部执行完, 释放资源

ok, 上面是同步代码的执行, 上面会涉及到两个核心概念: 执行整个代码的线程我们称之为 主线程 , 存放方法执行的地方我们称之为 执行栈.

2.2、js 异步代码执行过程

上面说完了同步过程,那这里来谈谈异步的过程。js 引擎在遇到一个异步事件, 不会一直等待返回结果而是将它挂起。当异步任务执行完之后会将结果加入到和执行栈中不同的 任务队列 当中,注意的是: 此时放入队列不会立即执行其回调, 而是当主线程执行完执行栈中所有的任务之后再去队列中查找是否有任务, 如果有则取出排在第一位的事件然后将回调放入执行栈并执行其代码。如此反复就构成了事件循环。

这里同样有一个核心概念:任务队列

2.3、微任务、宏任务

上面提到 js 执行异步方法的时候会将其返回结果放到队列中,这是比较笼统的,具体来说,js 会根据任务的类型将其放入不同的队列,任务类型有两种:微任务、宏任务。那么其对应的哪些是微任务、哪些是宏任务呢?

  • 微任务:Promise、process.nextTick()、整体代码 script、Object.observer、MutationObserver
  • 宏任务:setTimeout()、setInterval()

浏览器在执行的时候, 先从宏任务队列中取出一个宏任务执行宏, 然后在执行该宏任务下的所有的微任务, 这是一个循环; 然后再取出并执行下一个宏任务, 再执行所有的微任务, 这是第二个循环, 以此类推.

注意: 整个 javascript 代码是第一个宏任务

const process = require('process')
setTimeout(function () {// 分发宏任务到 EventQueue
    console.log("1");
}, 0);
setTimeout(() => {console.log("11");
}, 0);
setTimeout(() => {console.log("111");
}, 0);
new Promise(function (resolve) {console.log('2');
    resolve();}).then(function () {// 发送微任务
    console.log('3');
});
// 输出
2
3
1
11
111

2.4、小结

在浏览器端, 在我们执行一片 script 的时候, 当遇到同步代码,依次进入 执行栈 ,遇到异步代码,将其挂起,继续执行其它方法,当异步方法执行完之后根据任务类型进入到 任务队列 ,在执行栈执行完, 主线程 空闲下来了之后会到任务队列中取任务回调并执行。

三、Node 端

我自己认为 Node 的事件循环和浏览器端还是有点区别的,它的事件循环依靠 libuv 引擎。

该图来自官网,这里展示了在 node 的事件循环的 6 个阶段。

  • timers: 该阶段执行定时器的回调, 如 setTimeout() 和 setInterval()。
  • I/O callbacks: 该阶段执行除了 close 事件,定时器和 setImmediate()的回调外的所有回调
  • idle, prepare: 内部使用
  • poll: 等待新的 I / O 事件,node 在一些特殊情况下会阻塞在这里
  • check: setImmediate()的回调会在这个阶段执行
  • close callbacks: 例如 socket.on(‘close’, …)这种 close 事件的回调

对于我们来说我们更关注 timer、poll、check 这三个阶段即可。

poll 阶段有两个主要的功能:

  • 处理 poll 队列(poll quenue)的事件(callback);
  • 执行 timers 的 callback, 当到达 timers 指定的时间时;

poll 阶段的逻辑

  • 如果 event loop 进入了 poll 阶段,且代码未设定 timer,将会发生下面情况:

    • a、如果 poll queue 不为空,event loop 将同步的执行 queue 里的 callback, 直至 queue 为空,或执行的 callback 到达系统上限;
    • b、如果 poll queue 为空,将会发生下面情况:

      * 如果代码已经被 setImmediate()设定了 callback, event loop 将结束 poll 阶段进入 check 阶段,并执行 check 阶段的 queue (check 阶段的 queue 是 setImmediate 设定的)
      * 如果代码没有设定 setImmediate(callback),event loop 将阻塞在该阶段等待 callbacks 加入 poll queue;
      
  • 如果 event loop 进入了 poll 阶段,且代码设定了 timer:

    • 如果 poll queue 进入空状态时(即 poll 阶段为空闲状态),event loop 将检查 timers,
    • 如果有 1 个或多个 timers 时间时间已经到达,event loop 将按循环顺序进入 timers 阶段,并执行 timer queue

3.1、setTimeout、setImmediate

这两个函数的功能还是类似的, 不同的是他们处于 EventLoop 的不同阶段:timer、check。

setImmediate(()=>console.log("setInterval"));
setTimeout(() => {console.log("setTimeout")},0);

上面两行代码会输出顺序是什么呢? 其实两种可能都有.
1. 当 setTimeout 的 0ms 并不能做到绝对 0ms, 如果已经过了 timer 阶段, 那么此时 setTimeout 就会在下一次循环中执行, 也就是说先 setInterval、再 setTimeout。
2. 第二种可能就是正常流程了, 先 timer、再 check

如果上面的代码再一个 IO 操作作呢? 如:

require('fs').readFile(__filename,()=>{setImmediate(()=>console.log("setInterval"));
    setTimeout(() => {console.log("setTimeout")});
})

此时只可能出现一种情况, 先 setInterval、再 setTimeout,因为在 io 中已经执行过了 timer(readFile 时处于 IO callback)。
下面一起来看如下代码:

setTimeout(() => {console.log("timer1")
    Promise.resolve().then(() => console.log("promise1"));
    process.nextTick(() => console.log("nextTick1"))
}, 0);
setTimeout(() => {console.log("timer2")
    Promise.resolve().then(() => console.log("promise2"));
    process.nextTick(() => console.log("nextTick2"))
}, 0);

按照我的理解, 它的输出应该是如下: 先 timer、然后切换阶段的时候执行微任务.

// 情况 1
timer1
timer2
nextTick1
nextTick2
promise1
promise2

可是并不是, 它的输出一直是:

// 情况 2
timer1
nextTick1
promise1
timer2
nextTick2
promise2

后台晚上查资料因为 Node11 对 EventLoop 作了修改, 为了和浏览器兼容。于是呼我切换到 10.8.0, 发现上面两种情况都有(情况 1 比例大于情况 2)。这点暂时还未查明什么原因。

3.2、小结

node 中的 6 个阶段每个阶段执行完都会伴随着执行微任务, 同个 MicroTask 队列下 process.tick()会优于 Promise。

四 总结

本篇主要介绍了浏览器和 Node 对于事件循环机制实现,由于能力水平有限,其中可能有误之处欢迎指出。

欢迎关注公众号:

退出移动版