乐趣区

关于node.js:深度理解NodeJS事件循环

导读

ALL THE TIME,咱们写的的大部分 javascript 代码都是在浏览器环境下编译运行的,因而可能咱们对浏览器的事件循环机制理解比 Node.JS 的事件循环更深刻一些,然而最近写开始深刻 NodeJS 学习的时候,发现 NodeJS 的事件循环机制和浏览器端有很大的区别,特此记录来深刻的学习了下,以帮忙本人及小伙伴们遗记后查阅及了解。

什么是事件循环

首先咱们须要理解一下最根底的一些货色,比方这个事件循环,事件循环是指 Node.js 执行非阻塞 I / O 操作,只管 ==JavaScript 是单线程的 ==, 但因为大多数 == 内核都是多线程 == 的,Node.js会尽可能将操作装载到零碎内核。因而它们能够解决在后盾执行的多个操作。当其中一个操作实现时,内核会通知 Node.js,以便Node.js 能够将相应的回调增加到轮询队列中以最终执行。

当 Node.js 启动时会初始化 event loop, 每一个event loop 都会蕴含按如下程序六个循环阶段:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
  • [x] 1. timers 阶段: 这个阶段执行 setTimeout(callback)setInterval(callback) 预约的 callback;
  • [x] 2. I/O callbacks 阶段: 此阶段执行某些零碎操作的回调,例如 TCP 谬误的类型。例如,如果 TCP 套接字在尝试连贯时收到 ECONNREFUSED,则某些 * nix 零碎心愿期待报告谬误。这将操作将期待在 ==I/ O 回调阶段 == 执行;
  • [x] 3. idle, prepare 阶段: 仅 node 外部应用;
  • [x] 4. poll 阶段: 获取新的 I / O 事件, 例如操作读取文件等等,适当的条件下 node 将阻塞在这里;
  • [x] 5. check 阶段: 执行 setImmediate() 设定的 callbacks;
  • [x] 6. close callbacks 阶段: 比方 socket.on(‘close’, callback) 的 callback 会在这个阶段执行;

事件循环详解

这个图是整个 Node.js 的运行原理,从左到右,从上到下,Node.js 被分为了四层,别离是 应用层V8 引擎层Node API 层LIBUV 层

  • 应用层:即 JavaScript 交互层,常见的就是 Node.js 的模块,比方 http,fs
  • V8 引擎层:即利用 V8 引擎来解析 JavaScript 语法,进而和上层 API 交互
  • NodeAPI 层:为下层模块提供零碎调用,个别是由 C 语言来实现,和操作系统进行交互。
  • LIBUV 层:是跨平台的底层封装,实现了 事件循环、文件操作等,是 Node.js 实现异步的外围。

每个循环阶段内容详解

timers阶段 一个 timer 指定一个上限工夫而不是精确工夫,在达到这个上限工夫后执行回调。在指定工夫过后,timers 会尽可能早地执行回调,但系统调度或者其它回调的执行可能会提早它们。

  • 留神:技术上来说,poll 阶段管制 timers 什么时候执行。
  • 留神:这个上限工夫有个范畴:[1, 2147483647],如果设定的工夫不在这个范畴,将被设置为 1。

I/O callbacks阶段 这个阶段执行一些零碎操作的回调。比方 TCP 谬误,如一个 TCP socket 在想要连贯时收到 ECONNREFUSED,
类 unix 零碎会期待以报告谬误,这就会放到 I/O callbacks 阶段的队列执行.
名字会让人误会为执行 I / O 回调处理程序, 实际上 I / O 回调会由 poll 阶段解决.

poll阶段 poll 阶段有两个次要性能:(1)执行上限工夫曾经达到的 timers 的回调,(2)而后解决 poll 队列里的事件。
当 event loop 进入 poll 阶段,并且 没有设定的 timers(there are no timers scheduled),会产生上面两件事之一:

  • 如果 poll 队列不空,event loop 会遍历队列并同步执行回调,直到队列清空或执行的回调数达到零碎下限;
  • 如果 poll 队列为空,则产生以下两件事之一:

    • 如果代码曾经被 setImmediate()设定了回调, event loop 将完结 poll 阶段进入 check 阶段来执行 check 队列(外面的回调 callback)。
    • 如果代码没有被 setImmediate()设定回调,event loop 将阻塞在该阶段期待回调被退出 poll 队列,并立刻执行。
  • 然而,当 event loop 进入 poll 阶段,并且 有设定的 timers,一旦 poll 队列为空(poll 阶段闲暇状态):
    event loop 将查看 timers, 如果有 1 个或多个 timers 的上限工夫曾经达到,event loop 将绕回 timers 阶段,并执行 timer 队列。

check阶段 这个阶段容许在 poll 阶段完结后立刻执行回调。如果 poll 阶段闲暇,并且有被 setImmediate() 设定的回调,event loop 会转到 check 阶段而不是持续期待。

  • setImmediate() 实际上是一个非凡的 timer,跑在 event loop 中一个独立的阶段。它应用 libuv 的 API
    来设定在 poll 阶段完结后立刻执行回调。
  • 通常上来讲,随着代码执行,event loop 终将进入 poll 阶段,在这个阶段期待 incoming connection, request 等等。然而,只有有被 setImmediate()设定了回调,一旦 poll 阶段闲暇,那么程序将完结 poll 阶段并进入 check 阶段,而不是持续期待 poll 事件们(poll events)。

close callbacks 阶段 如果一个 socket 或 handle 被忽然关掉(比方 socket.destroy()),close 事件将在这个阶段被触发,否则将通过 process.nextTick() 触发

这里呢,咱们通过伪代码来阐明一下,这个流程:

// 事件循环自身相当于一个死循环,当代码开始执行的时候,事件循环就曾经启动了
// 而后顺序调用不同阶段的办法
while(true){
// timer 阶段
    timer()
// I/O callbacks 阶段
    IO()
// idle 阶段
    IDLE()
// poll 阶段
    poll()
// check 阶段
    check()
// close 阶段
    close()}
// 在一次循环中,当事件循环进入到某一阶段,退出进入到 check 阶段,忽然 timer 阶段的事件就绪,也会等到以后这次循环完结,再去执行对应的 timer 阶段的回调函数 
// 上面看这里例子
const fs = require('fs')

// timers 阶段
const startTime = Date.now();
setTimeout(() => {const endTime = Date.now()
    console.log(`timers: ${endTime - startTime}`)
}, 1000)

// poll 阶段(期待新的事件呈现)
const readFileStart =  Date.now();
fs.readFile('./Demo.txt', (err, data) => {if (err) throw err
    let endTime = Date.now()
    // 获取文件读取的工夫
    console.log(`read time: ${endTime - readFileStart}`)
    // 通过 while 循环将 fs 回调强制阻塞 5000s
    while(endTime - readFileStart < 5000){endTime = Date.now()
    }

})


// check 阶段
setImmediate(() => {console.log('check 阶段')
})
/* 控制台打印 check 阶段 read time: 9timers: 5008 通过上述后果进行剖析,1. 代码执行到定时器 setTimeOut,目前 timers 阶段对应的事件列表为空,在 1000s 后才会放入事件 2. 事件循环进入到 poll 阶段,开始一直的轮询监听事件 3.fs 模块异步执行,依据文件大小,可能执行工夫长短不同,这里我应用的小文件,事件大略在 9s 左右 4.setImmediate 执行,poll 阶段临时未监测到事件,发现有 setImmediate 函数,跳转到 check 阶段执行 check 阶段事件(打印 check 阶段),第一次工夫循环完结,开始下一轮事件循环 5. 因为工夫仍未到定时器截止工夫,所以事件循环有一次进入到 poll 阶段,进行轮询 6. 读取文件结束,fs 产生了一个事件进入到 poll 阶段的事件队列,此时事件队列筹备执行 callback,所以会打印(read time: 9),人工阻塞了 5s,尽管此时 timer 定时器事件曾经被增加,然而因为这一阶段的事件循环为实现,所以不会被执行,(如果这里是死循环,那么定时器代码永远无奈执行)7.fs 回调阻塞 5s 后,以后事件循环完结,进入到下一轮事件循环,发现 timer 事件队列有事件,所以开始执行 打印 timers: 5008ps:1. 将定时器延迟时间改为 5ms 的时候,小于文件读取工夫,那么就会先监听到 timers 阶段有事件进入,从而进入到 timers 阶段执行,执行结束持续进行事件循环 check 阶段 timers: 6read time: 50082. 将定时器事件设置为 0ms,会在进入到 poll 阶段的时候发现 timers 阶段曾经有 callback,那么会间接执行,而后执行结束在下一阶段循环,执行 check 阶段,poll 队列的回调函数 timers: 2check 阶段 read time: 7 */

走进案例解析

咱们来看一个简略的 EventLoop 的例子:

const fs = require('fs');
let counts = 0;

// 定义一个 wait 办法
function wait (mstime) {let date = Date.now();
  while (Date.now() - date < mstime) {// do nothing}
}

// 读取本地文件 操作 IO
function asyncOperation (callback) {fs.readFile(__dirname + '/' + __filename, callback);
}

const lastTime = Date.now();

// setTimeout
setTimeout(() => {console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

// process.nextTick
process.nextTick(() => {
  // 进入 event loop
  // timers 阶段之前执行
  wait(20);
  asyncOperation(() => {console.log('poll');
  });  
});

/** * timers 21ms * poll */

这里呢,为了让这个 setTimeout 优先于 fs.readFile 回调, 执行了process.nextTick, 示意在进入timers 阶段前, 期待 20ms 后执行文件读取.

1. nextTicksetImmediate

  • process.nextTick 不属于事件循环的任何一个阶段,它属于该阶段与下阶段之间的过渡, 即本阶段执行完结, 进入下一个阶段前, 所要执行的回调。有给人一种插队的感觉.
  • setImmediate 的回调处于 check 阶段, 当 poll 阶段的队列为空, 且 check 阶段的事件队列存在的时候,切换到 check 阶段执行,参考 nodejs 进阶视频解说:进入学习

nextTick 递归的危害

因为 nextTick 具备插队的机制,nextTick 的递归会让事件循环机制无奈进入下一个阶段. 导致 I / O 解决实现或者定时工作超时后依然无奈执行, 导致了其它事件处理程序处于饥饿状态. 为了避免递归产生的问题, Node.js 提供了一个 process.maxTickDepth (默认 1000)。

const fs = require('fs');
let counts = 0;

function wait (mstime) {let date = Date.now();
  while (Date.now() - date < mstime) {// do nothing}
}

function nextTick () {process.nextTick(() => {wait(20);
    console.log('nextTick');
    nextTick();});
}

const lastTime = Date.now();

setTimeout(() => {console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

nextTick();

此时永远无奈跳到 timer 阶段去执行 setTimeout 外面的回调办法, 因为在进入timers 阶段前有一直的 nextTick 插入执行. 除非执行了 1000 次到了执行下限,所以下面这个案例会一直地打印出 nextTick 字符串

2. setImmediate

如果在一个 I/ O 周期 内进行调度,setImmediate() 将始终在任何定时器(setTimeout、setInterval)之前执行.

3. setTimeoutsetImmediate

  • setImmediate()被设计在 poll 阶段完结后立刻执行回调;
  • setTimeout()被设计在指定上限工夫达到后执行回调;

无 I/O 解决状况下:

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

setImmediate(function immediate () {console.log('immediate');
});

执行后果:

C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate

C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate

C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

从后果,咱们能够发现,这里打印输出进去的后果,并没有什么固定的先后顺序,偏差于随机,为什么会产生这样的状况呢?

答:首先进入的是 timers 阶段,如果咱们的机器性能个别,那么进入 timers 阶段,1ms曾经过来了 ==(setTimeout(fn, 0)等价于 setTimeout(fn, 1))==,那么 setTimeout 的回调会首先执行。

如果没有到 1ms,那么在timers 阶段的时候,上限工夫没到,setTimeout回调不执行,事件循环来到了 poll 阶段,这个时候队列为空,于是往下持续,先执行了 setImmediate()的回调函数,之后在下一个事件循环再执行 setTimemout 的回调函数。

问题总结:而咱们在 == 执行启动代码 == 的时候,进入 timers 的时间延迟其实是 == 随机的 ==,并不是确定的,所以会呈现两个函数执行程序随机的状况。

那咱们再来看一段代码:

var fs = require('fs')

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

打印后果如下:

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

# ... 省略 n 屡次应用 node test.js 命令,后果都输入 immediate timeout

这里,为啥和下面的随机 timer 不统一呢,咱们来剖析下起因:

起因如下:fs.readFile的回调是在 poll 阶段执行的,当其回调执行结束之后,poll队列为空,而 setTimeout 入了 timers 的队列,此时有代码 setImmediate(),于是事件循环先进入 check 阶段执行回调,之后在下一个事件循环再在 timers 阶段中执行回调。

当然,上面的小案例同理:

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

以上的代码在 timers 阶段执行内部的 setTimeout 回调后,内层的 setTimeoutsetImmediate入队,之后事件循环持续往后面的阶段走,走到 poll 阶段 的时候发现 队列为空 ,此时有代码有setImmedate(),所以间接进入check 阶段 执行响应回调(== 留神这里没有去检测 timers 队列中是否有成员 达到上限事件,因为 setImmediate() 优先 ==)。之后在第二个事件循环的timers 阶段中再去执行相应的回调。

综上所演示,咱们能够总结如下:

  • 如果两者都在主模块中调用,那么执行先后取决于过程性能,也就是你的电脑好撇,当然也就是随机。
  • 如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate 的回调永远先执行

4. nextTickPromise

概念:对于这两个,咱们能够把它们了解成一个 微工作 。也就是说,它其实 不属于事件循环的一部分
那么他们是在什么时候执行呢?
不论在什么中央调用,他们都会在其所处的事件循环最初,事件循环进入下一个循环的阶段前执行。

setTimeout(() => {console.log('timeout0');
    new Promise((resolve, reject) => {resolve('resolved') }).then(res => console.log(res));
    new Promise((resolve, reject) => {setTimeout(()=>{resolve('timeout resolved')
      })
    }).then(res => console.log(res));
    process.nextTick(() => {console.log('nextTick1');
        process.nextTick(() => {console.log('nextTick2');
        });
    });
    process.nextTick(() => {console.log('nextTick3');
    });
    console.log('sync');
    setTimeout(() => {console.log('timeout2');
    }, 0);
}, 0);

控制台打印如下:

C:\Users\92809\Desktop\node_test>node test.js
timeout0
sync
nextTick1
nextTick3
nextTick2
resolved
timeout2
timeout resolved

最总结:timers阶段执行外层 setTimeout 的回调,遇到 同步代码先执行 ,也就有timeout0sync 的输入。遇到 process.nextTickPromise后入微工作队列,顺次 nextTick1nextTick3nextTick2resolved 入队后出队输入。之后,在下一个事件循环的 timers 阶段,执行 setTimeout 回调输入 timeout2 以及微工作 Promise 外面的 setTimeout,输入timeout resolved。(这里要阐明的是 微工作nextTick 优先级要比 Promise 要高)

5. 最初案例

代码片段 1:

setImmediate(function(){console.log("setImmediate");
  setImmediate(function(){console.log("嵌套 setImmediate");
  });
  process.nextTick(function(){console.log("nextTick");
  })
});

/*     C:\Users\92809\Desktop\node_test>node test.js    setImmediate    nextTick    嵌套 setImmediate*/

解析:

事件循环 check 阶段执行回调函数输入 setImmediate,之后输入nextTick。嵌套的setImmediate 在下一个事件循环的 check 阶段执行回调输入嵌套的setImmediate

代码片段 2:

async function async1(){console.log('async1 start')
    await async2()
    console.log('async1 end')
  }
async function async2(){console.log('async2')
}
console.log('script start')
setTimeout(function(){console.log('setTimeout0') 
},0)  
setTimeout(function(){console.log('setTimeout3') 
},3)  
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function(resolve){console.log('promise1')
    resolve();
    console.log('promise2')
}).then(function(){console.log('promise3')
})
console.log('script end')

打印后果为:

C:\Users\92809\Desktop\node_test>node test.js
script start
async1 start
async2
promise1
promise2
script end
nextTick
promise3
async1 end
setTimeout0
setTimeout3
setImmediate

大家呢,能够先看着代码,默默地在心底走一变代码,而后比照输入的后果,当然最初三位,我集体认为是有点问题的,毕竟在主模块运行,大家的答案,最初三位可能会有偏差;

退出移动版