理解event loop(浏览器环境与nodejs环境)

2,778 阅读6分钟

转自IMWeb社区,作者:sugerpocket,原文链接

众所周知,javascript 是单线程的,其通过使用异步而不阻塞主进程执行。那么,他是如何实现的呢?本文就浏览器与nodejs环境下异步实现与event loop进行相关解释。

浏览器环境

浏览器环境下,会维护一个任务队列,当异步任务到达的时候加入队列,等待事件循环到合适的时机执行。

实际上,js 引擎并不只维护一个任务队列,总共有两种任务

  1. Task(macroTask): setTimeout, setInterval, setImmediate,I/O, UI rendering
  2. microTask: Promise, process.nextTick, Object.observe, MutationObserver, MutaionObserver

那么两种任务的行为有何不同呢?

实验一下,请看下段代码

setTimeout(function() {
  console.log(4);
}, 0);

var promise = new Promise(function executor(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve();
  }
  console.log(2);
}).then(function() {
  console.log(5);
});

console.log(3);

输出:

1 2 3 5 4

这说明 Promise.then 注册的任务先执行了。

我们再来看一下之前说的 Promise 注册的任务属于microTask,setTimeout 属于 Task,两者有何差别?

实际上,microTasksTasks 并不在同一个队列里面,他们的调度机制也不相同。比较具体的是这样:

  1. event-loop start
  2. microTasks 队列开始清空(执行)
  3. 检查 Tasks 是否清空,有则跳到 4,无则跳到 6
  4. 从 Tasks 队列抽取一个任务,执行
  5. 检查 microTasks 是否清空,若有则跳到 2,无则跳到 3
  6. 结束 event-loop

也就是说,microTasks 队列在一次事件循环里面不止检查一次,我们做个实验

// 添加三个 Task
// Task 1
setTimeout(function() {
  console.log(4);
}, 0);

// Task 2
setTimeout(function() {
  console.log(6);
  // 添加 microTask
  promise.then(function() {
    console.log(8);
  });
}, 0);

// Task 3
setTimeout(function() {
  console.log(7);
}, 0);

var promise = new Promise(function executor(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve();
  }
  console.log(2);
}).then(function() {
  console.log(5);
});

console.log(3);

输出为

1 2 3 5 4 6 8 7

microTasks 会在每个 Task 执行完毕之后检查清空,而这次 event-loop 的新 task 会在下次 event-loop 检测。

Node 环境

实际上,node.js环境下,异步的实现根据操作系统的不同而有所差异。而不同的异步方式处理肯定也是不相同的,其并没有严格按照js单线程的原则,运行环境有可能会通过其他线程完成异步,当然,js引擎还是单线程的。

node.js使用了Google的V8解析引擎和Marc Lehmann的libev。Node.js将事件驱动的I/O模型与适合该模型的编程语言(Javascript)融合在了一起。随着node.js的日益流行,node.js需要同时支持windows, 但是libev只能在Unix环境下运行。Windows 平台上与kqueue(FreeBSD)或者(e)poll(Linux)等内核事件通知相应的机制是IOCP。libuv提供了一个跨平台的抽象,由平台决定使用libev或IOCP。

关于event loop,node.js 环境下与浏览器环境有着巨大差异。

先来一张图

先解释一下各个阶段

  1. timers: 这个阶段执行setTimeout()和setInterval()设定的回调。
  2. I/O callbacks: 执行几乎所有的回调,除了close回调,timer的回调,和setImmediate()的回调。
  3. idle, prepare: 仅内部使用。
  4. poll: 获取新的I/O事件;node会在适当条件下阻塞在这里。
  5. check: 执行setImmediate()设定的回调。
  6. close callbacks: 执行比如socket.on('close', ...)的回调。

每个阶段的详情

timer

一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。

注意:技术上来说,poll 阶段控制 timers 什么时候执行。

I/O callbacks 这个阶段执行一些系统操作的回调。比如TCP错误,如一个TCP socket在想要连接时收到ECONNREFUSED, 类unix系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行。

poll

poll 阶段的功能有两个

  • 执行 timer 阶段到达时间上限的任务。
  • 执行 poll 阶段的任务队列。

如果进入 poll 阶段,并且没有 timer 阶段加入的任务,将会发生以下情况

  • 如果 poll 队列不为空的话,会执行 poll 队列直到清空或者系统回调数达到上限
  • 如果 poll 队列为空
    如果设定了 setImmediate 回调,会直接跳到 check 阶段。 如果没有设定 setImmediate 回调,会阻塞住进程,并等待新的 poll 任务加入并立即执行。
check

这个阶段在 poll 结束后立即执行,setImmediate 的回调会在这里执行。

一般来说,event loop 肯定会进入 poll 阶段,当没有 poll 任务时,会等待新的任务出现,但如果设定了 setImmediate,会直接执行进入下个阶段而不是继续等。

close

close 事件在这里触发,否则将通过 process.nextTick 触发。

一个例子
var fs = require('fs');

function someAsyncOperation (callback) {
  // 假设这个任务要消耗 95ms
  fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();

setTimeout(function () {

  var delay = Date.now() - timeoutScheduled;

  console.log(delay + "ms have passed since I was scheduled");
}, 100);


// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function () {

  var startCallback = Date.now();

  // 消耗 10ms...
  while (Date.now() - startCallback < 10) {
    ; // do nothing
  }

});

当event loop进入 poll 阶段,它有个空队列(fs.readFile()尚未结束)。所以它会等待剩下的毫秒, 直到最近的timer的下限时间到了。当它等了95ms,fs.readFile()首先结束了,然后它的回调被加到 poll 的队列并执行——这个回调耗时10ms。之后由于没有其它回调在队列里,所以event loop会查看最近达到的timer的 下限时间,然后回到 timers 阶段,执行timer的回调。

所以在示例里,回调被设定 和 回调执行间的间隔是105ms。

setImmediate() vs setTimeout()

现在我们应该知道两者的不同,他们的执行阶段不同,setImmediate() 在 check 阶段,而settimeout 在 poll 阶段执行。但,还不够。来看一下例子。

// timeout_vs_immediate.js
setTimeout(function timeout () {
  console.log('timeout');
},0);

setImmediate(function immediate () {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

结果居然是不确定的,why?

还是直接给出解释吧。

  1. 首先进入timer阶段,如果我们的机器性能一般,那么进入timer阶段时,1毫秒可能已经过去了(setTimeout(fn, 0) 等价于setTimeout(fn, 1)),那么setTimeout的回调会首先执行。
  2. 如果没到一毫秒,那么我们可以知道,在check阶段,setImmediate的回调会先执行。

那我们再来一个

// timeout_vs_immediate.js
var fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})

输出始终为

$ node timeout_vs_immediate.js
immediate
timeout

这个就很好解释了吧。 fs.readFile 的回调执行是在 poll 阶段。当 fs.readFile 回调执行完毕之后,会直接到 check 阶段,先执行 setImmediate 的回调。

process.nextTick()

nextTick 比较特殊,它有自己的队列,并且,独立于event loop。 它的执行也非常特殊,无论 event loop 处于何种阶段,都会在阶段结束的时候清空 nextTick 队列。

参考

juejin.cn/post/684490… jakearchibald.com/2015/tasks-… flyyang.github.io/2017/03/07/… hao5743.github.io/2017/02/27/… github.com/ccforward/c… github.com/creeperyang… developer.mozilla.org/zh-CN/docs/…