JS 事件循环 Node 篇

简介: JS 事件循环 Node 篇

JS 事件循环 Node 篇

之前介绍过浏览器中的事件循环,本文将详细介绍 Node 中的事件循环。

Node 中的事件循环比起浏览器中的 JavaScript 还是有一些区别的,各个浏览器在底层的实现上可能有些细微的出入;而 Node 只有一种实现,相对起来就少了一些理解上的麻烦。

首先要明确的是,事件循环同样运行在单线程环境下,JavaScript 的事件循环是依靠浏览器实现的,而Node 作为另一种运行时,事件循环由底层的 libuv 实现。

根据 Node.js 官方介绍,每次事件循环都包含了6个阶段,如下图所示

1a2cb99516a4d39491d67e1dfc05765b.png

「注意」:每个框被称为事件循环机制的一个阶段。

每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。

阶段概述

  • 「timers 阶段」:这个阶段执行timer(setTimeoutsetInterval)的回调
  • 「I/O callbacks 阶段」:执行一些系统调用错误,比如网络通信的错误回调
  • 「idle, prepare 阶段」:仅node内部使用
  • 「poll 阶段」:获取新的I/O事件, 适当的条件下node将阻塞在这里
  • 「check 阶段」:执行 setImmediate() 的回调
  • 「close callbacks 阶段」:执行一些关闭的回调函数,如:socket.on('close', ...)

阶段的详细概述

timers 阶段

timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer,如果有则把它的回调压入 timer的任务队列中等待执行,事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout()setImmediate() 的执行顺序是不确定的。

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

但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。

I/O callbacks 阶段

官方文档对这个阶段的描述为除了timers、setImmediate,以及 close 操作之外的大多数的回调方法都位于这个阶段执行。事实上从源码来看,该阶段只是用来执行pending callback,例如一个TCP socket执行出现了错误,在一些*nix系统下可能希望稍后再处理这里的错误,那么这个回调就会放在「IO callback阶段」来执行。

一些常见的回调,例如 fs.readFile 的回调是放在 「poll 阶段」来执行的。

poll 阶段

poll 阶段主要有2个功能:

  • 处理 poll 队列的事件
  • 当有已超时的 timer,执行它的回调函数

even loop 将同步执行 poll 队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来even loop会去检查有无预设的setImmediate(),分两种情况:

  1. 若有预设的setImmediate(), event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列
  2. 若没有预设的setImmediate(),event loop将阻塞在该阶段等待,等待新的事件出现,这也是该阶段为什么会被命名为 poll(轮询) 的原因。

注意一个细节,没有setImmediate()会导致event loop阻塞在「poll阶段」,这样之前设置的timer岂不是执行不了了?所以咧,在poll阶段event loop会有一个检查机制,检查timer队列是否为空,如果timer队列非空,event loop就开始下一轮事件循环,即重新进入到「timers阶段」

check 阶段

setImmediate是一个特殊的定时器方法,它占据了事件循环的一个阶段,整个「check 阶段」就是为setImmediate方法而设置的。

setImmediate()的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。

close callbacks 阶段

如果一个 socket 或者一个句柄被关闭,那么就会产生一个close事件,该事件会被加入到对应的队列中。clos阶段执行完毕后,本轮事件循环结束,循环进入到下一轮。

小结

看完了上面的描述,我们明白了 Node 中的event loop 是分阶段处理的,对于每一阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一阶段的event loop 都对应着不同的队列。当 event loop 到达某个阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段。当所有阶段被顺序执行一次后,称 event loop 完成了一个 「tick」

Node.js 与浏览器的 Event Loop 差异

浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。

210be1e6910921b838427f6794f09e4c.png浏览器端

而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。7b91295b49d5dc5f4ead1080ffa2a326.png

setImmediate 对比 setTimeout

setImmediate()setTimeout() 很类似,但是基于被调用的时机,他们也有不同表现。

  • setImmediate() 设计为一旦在当前 「poll 阶段」 阶段完成,就执行脚本。
  • setTimeout() 在最小阈值(ms 单位)过后运行脚本。

执行计时器的顺序将根据调用它们的上下文而异。如果二者都从主模块内调用,则计时器将受进程性能的约束(这可能会受到计算机 上其他正在运行应用程序的影响)。

例如,如果运行以下不在 I/O 周期(即主模块)内的脚本,则执行两个计时器的顺序是非确定性的,因为它受进程性能的约束:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});
// timeout
// immediate
// or
// immediate
// timeout

但是,如果你把这两个函数放入一个 「I/O 循环」内调用,setImmediate 总是被优先调用:

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

使用 setImmediate() 相对于setTimeout() 的主要优势是,如果setImmediate()是在 I/O 周期内被调度的,那它将会在其中任何的定时器之前执行,跟这里存在多少个定时器无关。

process.nextTick

process.nextTick 的意思就是定义出一个异步动作,并且让这个动作在事件循环当前阶段结束后执行。

例如下面的代码,将打印first的操作放在nextTick的回调中执行,最后先打印出next,再打印first

process.nextTick(function() {
  console.log('first');
});
console.log('next');
// next
// first

process.nextTick其实并不是事件循环的一部分,但它的回调方法也是由事件循环调用的,该方法定义的回调方法会被加入到名为nextTickQueue的队列中。在事件循环的任何阶段,如果nextTickQueue不为空,都会在当前阶段操作结束后优先执行nextTickQueue中的回调函数,当nextTickQueue中的回调方法被执行完毕后,事件循环才会继续向下执行。

Node 限制了nextTickQueue的大小,如果递归调用了process..nextTick,那么当nextTickQueue达到最大限制后会抛出一个错误,我们可以写一段代码来证实这一点。

function recurse(i) {
  while(i < 9999) {
    process.nextTick(recurse(i++));
  }
}
recurse(0);

运行上面代码会报错:

RangeError: Maximum call stack size exceeded

既然nextTickQueue也是一个队列,那么先被加入队列的回调会先执行,我们可以定义多个process.nextTick,然后观察他们的执行顺序:

process.nextTick(function () {
  console.log('first');
});
process.nextTick(function() {
  console.log('second');
});
console.log('next');
// next
// first
// second

和其他回调函数一样,nextTick定义的回调也是由事件循环执行的,如果nextTick的回调方法中出现了阻塞操作,后面的要执行的回调同样会被阻塞。

process.nextTick(function () {
  console.log('first');
  // 由于死循环的存在,之后的事件被阻塞
  while(true) { }
});
process.nextTick(function() {
  console.log('second');
});
console.log('next');
// 依次打印 next first,不会打印 second

nextTick VS setlmmediate

setImmediate方法不属于ECMAScript标准,而是Node提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeoutsetIntervalsetlmmediate并不接受一个时间作为参数,setlmmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环末尾「check 阶段」执行。

setImmediate方法和process.nextTick方法很相似,二者经常被拿来放在一起比较,从语义角度看,setImmediate() 应该比 process.nextTick() 先执行才对,而事实相反,由于process.nextTick会在当前操作完成后立刻执行,因此总会在setImmediate之前执行。

此外,当有递归的异步操作时只能使用setlmmediate,不能使用process.nextTick,前面已经展示过了递归调用nextTick会出现的错误,下面使用setlmmediate来试试看:

function recurse(i, end) {
  if (i < end) {
    console.log('Done');
  } else {
    console.log(i);
    setImmediate(recurse, i + 1, end);
  }
}
recurse(0, 9999999);

完全没问题!这是因为setImmediate不会生成call stack

总结

  1. Node.js 的事件循环分为6个阶段
  2. 浏览器和Node 环境下,microtask任务队列的执行时机不同
  • Node.js中,microtask 在事件循环的各个阶段之间执行
  • 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
  1. 递归的调用process.nextTick()会导致I/O starving,官方推荐使用setImmediate()
相关文章
|
28天前
|
存储 JavaScript 前端开发
深入理解JavaScript中的事件循环(Event Loop):机制与实现
【10月更文挑战第12天】深入理解JavaScript中的事件循环(Event Loop):机制与实现
72 3
|
9天前
|
JavaScript 前端开发 中间件
JS服务端技术—Node.js知识点
本文介绍了Node.js中的几个重要模块,包括NPM、Buffer、fs模块、path模块、express模块、http模块以及mysql模块。每部分不仅提供了基础概念,还推荐了相关博文供深入学习。特别强调了express模块的使用,包括响应相关函数、中间件、Router和请求体数据解析等内容。文章还讨论了静态资源无法访问的问题及其解决方案,并总结了一些通用设置。适合Node.js初学者参考学习。
24 1
|
14天前
|
开发框架 JavaScript 前端开发
Node.js日记:客户端和服务端介绍、Node.js介绍
Node.js日记:客户端和服务端介绍、Node.js介绍
|
13天前
|
JavaScript 前端开发 开发者
JavaScript的事件循环
【10月更文挑战第27天】理解JavaScript的事件循环机制对于正确编写和理解JavaScript中的异步代码至关重要,它是JavaScript能够高效处理各种异步任务的关键所在。
27 1
|
18天前
|
JavaScript 前端开发 开发工具
Node.js——初识Node.js
Node.js——初识Node.js
18 4
|
18天前
|
JavaScript 前端开发 持续交付
构建现代Web应用:Vue.js与Node.js的完美结合
【10月更文挑战第22天】随着互联网技术的快速发展,Web应用已经成为了人们日常生活和工作的重要组成部分。前端技术和后端技术的不断创新,为Web应用的构建提供了更多可能。在本篇文章中,我们将探讨Vue.js和Node.js这两大热门技术如何完美结合,构建现代Web应用。
18 4
|
28天前
|
前端开发 JavaScript
深入理解JavaScript中的事件循环(Event Loop):从原理到实践
【10月更文挑战第12天】 深入理解JavaScript中的事件循环(Event Loop):从原理到实践
34 1
|
28天前
|
缓存 监控 JavaScript
Node.js中基于node-schedule实现定时任务之详解
Node.js中基于node-schedule实现定时任务之详解
79 0
|
28天前
|
Web App开发 JavaScript 前端开发
Node.js:JavaScript世界的全能工具
Node.js:JavaScript世界的全能工具