再学Node EventLoop

1,031 阅读5分钟

一、概览

event loop(事件循环)是一个执行模型浏览器和NodeJS基于不同的技术实现了各自的Event Loop。本文主要讲述Node中的 EventLoop。

1. 浏览器的Event Loop

浏览器的Event Loop是在Html5规范(参考:html.spec.whatwg.org/multipage/w… Loop模型,具体的实现流程了浏览器厂商。

2. NodeJS的Event Loop

NodeJS的Event Loop是基于libuv(跨平台异步IO库)实现的。可以参考Node的官方文档以及libuv的官方文档。libuv已经对Event Loop做出了实现(参考:github.com/libuv/libuv…

二、浏览器中的EventLoop

参见:  浏览器中的EventLoop

三、Node中的EventLoop

1.Node Task分类

2. Node Task Queue说明

这一部分摘抄自node官网,加了部分说明。详细的说一下每种类型的Task Queue

  • 定时器(timer):本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。(也就是那些到时间应该执行的定时器回调函数。)
  • 待定回调(pending callbacks):执行延迟到下一个循环迭代的 I/O 回调。(这个阶段参考poll阶段的说明2)
  • idle, prepare:仅系统内部使用。
  • 轮询(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。(说明:1. 适当的时候:已经没有其它可执行的任务的时候,此时还有异步的I/O没有返回;此时程序会在此阻塞,等待I/O的回调;2. 当队列用尽或达到回调限制,事件循环将移动到下一阶段执行,也就是到待定回调pending callbacks_阶段_执行 )
  • 检测(check):setImmediate() 回调函数在这里执行。
  • 关闭的回调函数(close callbacks):一些关闭的回调函数,如:socket.on('close', ...)。

3.js代码、函数调用栈、任务队列

通过下面的代码以及gif图了解一下js代码,函数调用栈,任务队列是如何配合执行代码的。

setTimeout(() => {  console.log("timer");  Promise.resolve().then(() => {    console.log("promise");  });});setTimeout(() => {  console.log("timer2");  Promise.resolve().then(() => {    console.log("promise2");  });});

4.代码示例

通过以下代码印证一下上面说到的流程

const fs = require('fs');
console.log('1.同步代码!'); // 同步代码一定比异步代码先执行
process.nextTick(() => { // 整个函数我们理解为一个宏任务,当整个代码执行完,
// 会执行此宏任务产生的微任务,process.nextTick 会立即执行  console.log('3.process.nextTick!');
});
setImmediate(() => { // check阶段执行
  console.log('5.setImmediate!');
});
Promise.resolve().then(() => { // 微任务,等待宏任务执行完毕后执行,执行顺序在process.nextTick 之后
  console.log('4.Promise.resolve then!');
});
setTimeout(() => { // 1s后会将此会将此setTimeout的回调推送到 timer阶段对应的task queue中
  console.log('7.setTimeout');
  process.nextTick(() => {
    console.log('8.setTimeout process.nextTick!');
  });
  Promise.resolve().then(() => {
    console.log('9.setTimeout Promise.resolve then');
  });
}, 1000);
fs.readFile('./event.js', () => { // I/O 回调在poll阶段执行,具体何时执行以来fs.readFile异步读取文件的所需时间
  console.log('6.fs.readFile');
});
console.log('2.同步代码!');


// 以下是输出:// 1.同步代码!
// 2.同步代码!
// 3.process.nextTick!
// 4.Promise.resolve then!
// 5.setImmediate!
// 6.fs.readFile
// 7.setTimeout
// 8.setTimeout process.nextTick!
// 9.setTimeout Promise.resolve then

5.流程图解析

下面流程图标注了几个关键点。

1.poll阶段是如何工作的

2.pending callbacks阶段的I/O指的是什么

3.poll阶段执行完毕后,不同的处理情况

4.poll阶段介绍中说在恰当的时候阻塞在此处,什么事恰当的时候

6.事件循环的源码

以下是node事件循环的核心代码(摘自网络)

// https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c

int uv_run(uv_loop_t * loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;
  // 从uv__loop_alive中我们知道event loop继续的条件是以下三者之一:
  // 1,有活跃的handles(libuv定义handle就是一些long-lived objects,例如tcp server这样)     
  // 2,有活跃的request     
  // 3,loop中的closing_handles
  r = uv__loop_alive(loop);
  if (!r) uv__update_time(loop);
  while (r != 0 && loop -> stop_flag == 0) {
    // 更新时间变量,这个变量在uv__run_timers中会用到
    uv__update_time(loop);
    // timers阶段
    uv__run_timers(loop);
    // 从libuv的文档中可知,这个其实就是I/O callback阶段,ran_pending指示队列是否为空
    ran_pending = uv__run_pending(loop);
    // idle阶段
    uv__run_idle(loop);
    // prepare阶段
    uv__run_prepare(loop);
    // 设置poll阶段的超时时间,以下几种情况下超时会被设为0,这意味着此时poll阶段不会被阻塞,在下面的poll阶段我们还会详细讨论这个       
    // 1,stop_flag不为0       
    // 2,没有活跃的handles和request       
    // 3,idle、I/O callback、close阶段的handle队列不为空       
    // 否则,设为timer阶段的callback队列中,距离当前时间最近的那个
    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) {
      timeout = uv_backend_timeout(loop);
    }
    // poll阶段
    uv__io_poll(loop, timeout);
    // check阶段
    uv__run_check(loop);
    // close阶段
    uv__run_closing_handles(loop);
    // 如果mode == UV_RUN_ONCE(意味着流程继续向前)时,在所有阶段结束后还会检查一次timers,这个的逻辑的原因不太明确              

    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }
    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) {
      break;
    }
  }
  if (loop -> stop_flag != 0) {
    loop -> stop_flag = 0;
  }
  return r;
}

四、几个问题

1.setTimeout && setImmediate执行顺序

setTimeout(() => {
  console.log('1.setTimeout');
},0);
setImmediate(() => {
  console.log('2.setImmediate');
});

分两种情况

1.当 setTimeout() 和 setImmediate() 都写在 main 里面的时候 不一定谁先执行谁后执行;这个时候受线程性能的影响。setTimeout 0 在node中会被设置为1ms后执行。时间循环的准备需要时间,如果事件循环的准备时间大于1ms那 setTimeout 0先执行。反之 setImmediate 先执行。

2.当 setTimeout() 和 setImmediate() 都写在一个 I/O 回调 或者说一个 poll 类型宏任务的回调里面的时候 一定是先执行 setImmediate() 后执行 setTimeout()

2. process.nextTick&&setImmediate

setImmediate可理解为是个宏任务,在check阶段触发执行。

process.nextTick可以理解为是一个微任务,我们知道微任务是在宏任务执行的间隙去执行,所以当一个(种)任务队列执行完毕后,会执行微任务;而process.nextTick会最优先被执行。

3.以下代码执行顺序

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

// ==>执行结果1
// 1.setTimeout
// 2.setTimeout
// 1.Promise.resolve
// 2.Promise.resolve

// ==>执行结果2
// 1.setTimeout
// 1.Promise.resolve
// 2.setTimeout
// 2.Promise.resolve

TODO 原因暂时不理解待补充?

4.setTimeout的预设时间是否一定准确

Node 并不能保证 timers 在预设时间到了就会立即执行,因为 Node 对 timers 的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲,以下是官网的例子

const fs = require('fs');
function someAsyncOperation(callback) {
  fs.readFile('/path/to/file', callback); // 假设这个任务95ms完成, callback执行需要花费10ms
}
const timeoutScheduled = Date.now();
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms have passed since I was scheduled`);
}, 100); // 100ms后,此时主线程正在执行fs.readFile的回调;需要等待105ms时才会执行此定时器
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

参考文章:

nodejs.org/zh-cn/docs/…