简单介绍一下Node.js 事件循环

488 阅读6分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

Node.js 简介

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。Node.js 的包管理器 npm, 是全球最大的开源库生态系统

Node.js 是一个开源和跨平台的 JavaScript 运行时环境。

Node.js 的特点

  • 异步的 I/O
  • 事件机制
  • 单线程
  • 跨平台

image.png

Node.js 的运行机制

  • V8 引擎解析 javaScript 脚本,解析后的代码,调用 NodeAPI
  • libuv 库负责 API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎
  • V8 引擎再将结果返回给用户

Node.js 事件循环机制

node 的 Event Loop 发生在 libuv 层,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API

image.png

1. timers

执行 setTimeout()setInterval()中的 callback,并且是由 poll 阶段控制的。同样,在 Node.js 中定时器制定的时间也是不准确时间,只能是尽快执行

2. pending callback

执行上一次推迟到这一次循环迭代的 I/O 回调

3. idle, prepare

只在内部使用

4. poll

获取新的 I/O 事件,适当条件下 Node.js 将阻塞在这里

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情:

  • 回到timer 阶段执行回调
  • 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情:

  • 如果poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果poll 队列为空时,会有两件事发生:
    • 如果有setImmediate 回调需要执行,poll 阶段会停止并且进入到check 阶段执行回调
    • 如果没有setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了timer 的话且 poll 队列为空,则会判断是否有timer 超时,如果有的话会回到timer 阶段执行回调。

假设 poll 被阻塞,那么即使 timer 已经到时间了也只能等着,这也是为什么上面说定时器指定的时间并不是准确的时间。例如:

const start = Date.now(); // 获取当前的时间戳

setTimeout(function f1() {
    // f1 进入timer 队列
    console.log("setTimeout", Date.now() - start) 
}, 200)

const fs = require("fs")

fs.readFile('./index.js', 'utf-8', function f2() {
    // f2 进入到 poll 任务队列
    console.log('文件读取结束')
    const start = Date.now()
    // 强行延迟 500 毫秒
    while(Date.now() - start < 500) { }
})

执行结果如下:

image.png

5. check

执行 setImmediate()callback

setImmediate() 的回调会被加入 check 队列中,从事件循环的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后。看个例子:

Promise 会被放入到微任务队列

会先清空微任务队列,再执行其他任务队列的回调任务**

console.log('start');

setTimeout(() => {
  console.log('timer1');
  Promise.resolve().then(() => {
    console.log('promise1');
  })
}, 0)

setTimeout(() => {
  console.log('timer2');
  Promise.resolve().then(() => {
    console.log('promise2');
  })
}, 0)

Promise.resolve().then(() => {
  console.log('promise3');
})

console.log('end')
// 输出结果:start end  promise3 timer1 promise1  timer2  promise2

一开始执行同步任务,依次打印出 start end ,并将2个 timer 依次放入 timer 队列,之后会立即执行微任务队列,所以打印出 promise3。

然后进入 timer 阶段,执行 timer1 的回调函数,打印 timer1 ,发现一个 promise.then 回调将其加入到微任务队列并且立即执行,之后同样的步骤执行 timer2 , 打印 timer2 以及promise2。

需要注意

setTimeout 和 setImmediate 区别

二者非常相似,区别主要在于调用时机不同。

  • setImmediate 设计在 poll 阶段完成时执行,即 check 阶段
  • setTimeout 设计再 poll 阶段为空闲时,且设定时间(时间阈值)到达后执行,但它在 timer 阶段执行

看个例子:

setTimeout(function f3() {
  console.log("setTimeout0")
}, 0)

setImmediate(function f4() {
  console.log("setImmediate")
})

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后。首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的,进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调。如果在准备时候花费了小于 1ms ,那么就是 setImmediate 回调先执行了。

但当二者在异步I/O callback 内部调用时,总是先执行 setImmediate,再执行 setTimeout,例如:

const fs = require("fs")

fs.readFile('./test.js', 'utf-8', function f5() {
    console.log('文件读取结束5')
    setTimeout(() => {
      console.log('setTimeout5')
    }, 0)

    setImmediate(() => {
      console.log('setImmediate5')
    })
})
// 输出:setImmediate5 setTimeout5

在上述代码中, setImmediate 永远先执行。因为两个代码写在 I/O 回调中,I/O 回调在 poll 阶段执行,当I/O 回调执行完毕后队列为空,发现 setImmediate 回调,所以就直接跳转到 check 阶段去执行 setImmediate 回调了。

6. close callback

执行close事件的callback。e.g:socket.on('close', ...).

process.nextTick()

这个函数其实是独立于事件循环之外的,它有一个自己的队列。当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其它 microtask 执行。

setTimeout(() => {
  console.log('setTimeout6')
  Promise.resolve().then(() => {
    console.log('Promise6')
  })
}, 0)

process.nextTick(() => {
  console.log('nextTick')
  process.nextTick(() => {
    console.log('nextTick2')
    process.nextTick(() => {
      console.log('nextTick3')
      process.nextTick(() => {
        console.log('nextTick4')
      })
    })
  })
})
// 输出:
nextTick
nextTick2
nextTick3
nextTick4
setTimeout6
Promise6  

任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前解析。

作用:

  1. 允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。
  2. 有时有让回调在栈展开后,但在事件循环继续之前运行的必要。

Promise.then

Promise.then 也是独立于事件循环之外的,有一个自己的队列,但是优先级要比 process.nextTick 要低,所以当微任务中同时存在 process.nextTick 和 Promise.then 时,会优先值 process.nextTick。

setTimeout(() => {
  console.log('setTimeout7')
  Promise.resolve().then(() => {
    console.log('Promise7')
  })
  process.nextTick(() => {
    console.log('nextTick7')
  })
}, 0)

setTimeout(() => {
  console.log('setTimeout8')
  Promise.resolve().then(() => {
    console.log('Promise8')
  })
}, 0)

// 输出:
setTimeout7
nextTick7
Promise7
setTimeout8
Promise8

Node.js 于浏览器的时间队列的差异

浏览器环境下,就两个队列,一个宏任务队列,一个微任务队列。微任务的任务队列是在每个宏任务执行完之后执行(清空微任务队列)。

在 Node.js 中,每个任务队列(6个阶段)的每个任务执行完毕之后,就会清空这个微任务队列

结语

如果这篇文章帮到了你,欢迎点赞👍和关注⭐️。

文章如有错误之处,希望在评论区指正🙏🙏。

附: