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()
相关文章
|
27天前
|
Web App开发 JavaScript 前端开发
Node.js 是一种基于 Chrome V8 引擎的后端开发技术,以其高效、灵活著称。本文将介绍 Node.js 的基础概念
Node.js 是一种基于 Chrome V8 引擎的后端开发技术,以其高效、灵活著称。本文将介绍 Node.js 的基础概念,包括事件驱动、单线程模型和模块系统;探讨其安装配置、核心模块使用、实战应用如搭建 Web 服务器、文件操作及实时通信;分析项目结构与开发流程,讨论其优势与挑战,并通过案例展示 Node.js 在实际项目中的应用,旨在帮助开发者更好地掌握这一强大工具。
44 1
|
14天前
|
存储 JavaScript NoSQL
Node.js新作《循序渐进Node.js企业级开发实践》简介
《循序渐进Node.js企业级开发实践》由清华大学出版社出版,基于Node.js 22.3.0编写,包含26个实战案例和43个上机练习,旨在帮助读者从基础到进阶全面掌握Node.js技术,适用于初学者、进阶开发者及全栈工程师。
42 9
|
24天前
|
JavaScript 前端开发 API
深入理解Node.js事件循环及其在后端开发中的应用
本文旨在揭示Node.js的核心特性之一——事件循环,并探讨其对后端开发实践的深远影响。通过剖析事件循环的工作原理和关键组件,我们不仅能够更好地理解Node.js的非阻塞I/O模型,还能学会如何优化我们的后端应用以提高性能和响应能力。文章将结合实例分析事件循环在处理大量并发请求时的优势,以及如何避免常见的编程陷阱,从而为读者提供从理论到实践的全面指导。
|
1月前
|
JavaScript API 开发者
深入理解Node.js中的事件循环和异步编程
【10月更文挑战第41天】本文将通过浅显易懂的语言,带领读者探索Node.js背后的核心机制之一——事件循环。我们将从一个简单的故事开始,逐步揭示事件循环的奥秘,并通过实际代码示例展示如何在Node.js中利用这一特性进行高效的异步编程。无论你是初学者还是有经验的开发者,这篇文章都能让你对Node.js有更深刻的认识。
|
1月前
|
JavaScript 前端开发 中间件
JS服务端技术—Node.js知识点
本文介绍了Node.js中的几个重要模块,包括NPM、Buffer、fs模块、path模块、express模块、http模块以及mysql模块。每部分不仅提供了基础概念,还推荐了相关博文供深入学习。特别强调了express模块的使用,包括响应相关函数、中间件、Router和请求体数据解析等内容。文章还讨论了静态资源无法访问的问题及其解决方案,并总结了一些通用设置。适合Node.js初学者参考学习。
39 1
|
1月前
|
开发框架 JavaScript 前端开发
Node.js日记:客户端和服务端介绍、Node.js介绍
Node.js日记:客户端和服务端介绍、Node.js介绍
|
1月前
|
JavaScript 前端开发 开发者
JavaScript的事件循环
【10月更文挑战第27天】理解JavaScript的事件循环机制对于正确编写和理解JavaScript中的异步代码至关重要,它是JavaScript能够高效处理各种异步任务的关键所在。
36 1
|
1月前
|
JavaScript 前端开发 开发工具
Node.js——初识Node.js
Node.js——初识Node.js
34 4
|
29天前
|
JSON JavaScript 前端开发
使用JavaScript和Node.js构建简单的RESTful API
使用JavaScript和Node.js构建简单的RESTful API
下一篇
DataWorks