Node中的EventLoop

网上有很多关于Node中的EventLoop的文章和视频,但是往往都要看很多才能明白,那么看了我这篇后相信一定是最后一篇,一定会搞明白Node中的EventLoop是个什么东东。

了解CPU 与 存储器的关系

程序运行过程中 CPU 和存储器起到了什么作用?
CPU
中央处理器,计算机核心部件,负责运算和指令调用。
开发者编写的 JavaScript 代码在被编译为机器码以后就是通过 CPU 执行的

存储器
内存:用于临时存储数据,断电后数据丢失。由于数据读写速度快,计算机中的应用都是在内存中运行的。
磁盘:用于持久存储数据,断电后数据不丢失。内部有磁头依靠马达转动在盘片上读写数据,速度比内存慢。
计算机应用程序在没有运行时是存储在磁盘中的,当我们启动应用程序后,应用程序会被加载到内存中运行,应用程序中的指令会被中央处理器CPU来执行。

了解I/O操作

什么事I/O操作
|就是 input 表示输入,O 就是 Output 表示输出,I/O 操作就是输入输出操作。什么样的操作属于 I/O 操作呢 ?

比如数据库的读写操作就是I/O操作,因为数据库文件是存储在磁盘中的,而我们编写的程序是运行在内存中的,将内存中的数据写入数据库对于内存来说就是输出,查询数据库中的数据就是将磁盘中的数据读取到内存中,对于内存来说就是输入。

I/O 模型
从数据库中查询数据,也就是将磁盘中的文件内容读取到内存中,由于磁盘的读写速度比较慢,查询内容越多花费时间越多。
无论I/O操作需要花费多少时间,在I/O操作执行完成后,CPU 都是需要获取到操作结果的,那么问题就来了,CPU 在发出 I/O操作指令后是否要等待 I/O 操作执行完成呢 ?
这就涉及到I/O操作模型了,I/O 操作模型有两种:

1、CPU 等待 I/O操作执行完成获取到操作结果后再去执行其他指令,这是同步I/O 操作(阻塞 I/O)
2、CPU 不等待I/O操作执行完成,CPU 在发出I/O指令后,内存和磁盘开始工作,CPU 继续执行其他指令。当I/O 操作完成后再通知 CPU I/O操作的结果是什么。这是异步 I/O 操作 (非阻塞 I/O)。

同步I/O在代码中的表现就是代码暂停执行等待I/O操作,I/O操作执行完成后再执行后续代码。

let data = fs.readFileSync('./test/hello.txt')
console.log('文件内容是:',data.toString())

异步I/O在代码中的表现就是代码不暂停执行,I/O操作后面的代码可以继续执行。

fs.readFile("./test/hello.txt",function (err,data){
  if (err) throw err;
  console.log("成功",data.toString())
})
console.log('hello')

当I/O操作执行完成后通过回调函数的方式通知CPU,I/O操作已经完成了,可以执行基于操作结果的其他操作了。

Node环境中JavaScript是单线程还是多线程

在 Node.js 代码运行环境中,它为 JavaScript 代码的执行提供了一个主线程,通常我们所说的单线程指的就是这个主线程, 主线程是用来执行所有的同步代码。

在 Node.js 内部它依赖了一个叫做 libuv 的 C++库,nodejs使用libuv库调用内核,实现多线程的操作,JavaScript 中的异步代码就是在这些线程中执行的,所以说 JavaScript代码的运行依靠了不止一个线程,所以 JavaScript 本质上还是多线程的。

宏任务与微任务

Node中也有宏任务与微任务,微任务的回调函数是放置在微任务队列中,宏任务的回调函数是放置在宏任务队列中。它跟浏览器中的js一样,只是它们之间的执行顺序有区别,
它们的执行顺序后面会说到,先了解Node中有哪些宏任务和微任务。

宏任务
1、setlnterval
2、setimeout
3、setlmmediate
4、I/O
微任务
1、Promise.then
2、Promise.catch
3、Promise.finally
4、process.nextTick

Nodejs的事件循环

Node的事件循环与JavaScript的略有不同。
Node可以通过将操作转移到系统内核中来执行非阻塞 I/O操作。由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。
当这些操作之一完成时,内核会告诉node这个操作完成了,可以把操作后的回调添加到轮询队列中执行了。
事件循环图解
在Node应用程序启动后,并不会立即进入事件循环,而是先执行同步代码,从上到下开始执行,同步API立即执行,异步API交给C++维护的线程执行,
异步API的回调函数被注册到对应的事件队列中。当所有同步代码执行完成后,才会进入事件循环。

下图显示了整个事件循环操作顺序的概述
Node中的事件循环流程

Node.js采用的是异步IO模型。同步API在主线程中执行,异步API在底层的C++维护的线程中执行,异步API的回调函数也会在主线程中执行。
在Javascript应用运行时,众多异步API的回调函数什么时候能回到主线程中调用呢?这就是事件循环机制做的事情,管理异步API的回调函数什么时候回到主线程中执行。

主线程
start:初始化、启动入口文件
mainline:要处理以下几件事;

  • 1、执行同步代码。
  • 2、执行微任务(Promise、process.nextTick)。
  • 3、检查是否有要进入事件循环的任务,比如:setTimeout,setInterval,setImmediate,I/O操作。

end:所有的事情都完毕,结束。
下面我们来看看这段代码:

console.log('start')

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

Promise.resolve().then(()=>{
  console.log('Promise.resolve')
})
process.nextTick(()  =>{
  console.log('process.nextTick')
})

console.log('end')

输出

start
end
process.nextTick
Promise.resolve
setTimeout
setImmediate

可以看出,先执行同步代码,然后执行了微任务(process.nextTick,Promise.resolve),最后再执行宏任务(setTimeout,setImmediate),那么为啥会输出这样的结果呢?

事件循环 圈
Node的 Event Loop 分为六个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

timers:在这个阶段执行timer的回调,即setTimeout、setInterval里面的回调函数。
I/O callbacks:执行与操作系统相关的回调函数,比如启动服务器端应用时监听端口操作的回调函数就在这里调用。
idle prepare:仅系统内部使用。
poll:检索新的I/O事件、执行与I/O相关的回调函数队列,比如文件读写操作的回调函数。

在这个阶段需要特别注意:

  • 如果poll队列不为空,会执行事件队列中的回调函数,直到队列为空或者达到系统限制。
  • 如果poll队列为空,事件循环将阻塞在这个阶段等待新的回调函数进入。
  • 如果poll队列为空,会有两件事发生:
    • 如果setlmmediate队列(check阶段)中存在要执行的回调函数,将结束poll阶段进入check阶段,并执行check阶段的队列(setlmmediate)。
    • 如果timers队列中存在要执行的回调函数且时间已经到达,在这种情况下也不会等待。事件循环将移至check阶段,然后移至Close callbacks阶段,并最终从timers阶段进入下一次循环。

Check:setImmediate() 设置的回调会在此阶段被调用
close callbacks:执行与关闭事件相关的回调,例如关闭数据库连接的回调函数、socket.on(‘close’, …)等。

微任务和宏任务的执行顺序

上面的代码输出中可以看出,先执行了微任务(process.nextTick,Promise.resolve),再执行了宏任务(setTimeout,setImmediate)。

从上面Node中的事件循环流程图中可以看出,微任务优先级高于宏任务。
在Node环境中,当微任务事件队列中存在可以执行的回调函数时,事件循环在执行完当前阶段的回调函数后会暂停进入事件循环的下一个阶段,而会立即进入微任务的事件队列中开始执行回调函数,当微任务队列中的回调函数执行完成后,事件循环才会进入到下一个段开始执行回调函数。

看代码:

console.log('start')
Promise.resolve().then(()=>{
  console.log('Promise.resolve1')
  Promise.resolve().then(()=>{
    console.log('Promise.resolve2')
  })
})
process.nextTick(()  =>{
  console.log('process.nextTick1')
  process.nextTick(()  =>{
    console.log('process.nextTick2')
    process.nextTick(()  =>{
      console.log('process.nextTick3')
    })
  })
})
console.log('end')

通过上面代码可以知道:process.nextTick优先Promise.resolve执行。那就是process.nextTick同属于微任务,但是它的优先级是高于其它微任务,
在执行微任务时,只有nextlick中的所有回调函数执行完成后才会开始执行其它微任务。

总结:主线程同步代码执行完毕后会优先清空微任务,然后再到下个事件循环阶段,并且微任务的执行是穿插在事件循环六个阶段中间的,也就是每次事件循环进入下个阶段前会判断微任务队列是否为空,为空才会进入下个阶段,否则先清空微任务队列。

这里注意一下,Node与浏览器Event Loop的不同:

浏览器环境下,微任务的任务队列是每个宏任务执行完之后执行。而在 Node.js中,微任务会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行微任务队列的任务。

setTimeout和setImmediate谁先执行

我们知道setTimeout是在timers阶段执行,setImmediate是在check阶段执行。并且事件循环是从timers阶段开始的。所以会先执行setTimeout再执行setImmediate

注意:

setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms。

看代码:

console.log('start')
setImmediate(()=>{
  console.log('setImmediate')
})
setTimeout(function(){
  console.log('setTimeout')
},0)
console.log('end')

输出
Node中的事件循环流程
可以看出setTimeoutsetImmediate执行的顺序是不固定的。
这就要看进入事件循环的时间,如果进入timers阶段时间大于1ms,就会先执行setTimeout,如果进入timers的时间小于1ms
这个时候setTimeout的回调函数还没准备好,就会先到check阶段执行setImmediate回调函,再到下一次事件循环的timers阶段来执行setTimeout的回调。

那在什么情况下同样的延迟时间,setImmediate回调函数一定会优先于setTimeout的回调呢?
只要将这两者放到timers阶段和check阶段之间的Pendingcallbacks、idle,prepare、poll阶段中任意一个阶段就可以了。

看代码:

console.log('start')
fs.readFile("./test/hello2.txt",function (err,data){
  if (err) throw err;
  setTimeout(() => {
    console.log("setTimeout");
  });

  setImmediate(() => {
    console.log("setImmediate");
  });
})
console.log('end')

输出:

start
end
setImmediate
setTimeout

可以看到,setImmediate先执行

最后以上就是我看了很多文章视频,自己对Nodejs中的Event Loop的理解整理。


  转载请注明: 小浩之随笔 Node中的EventLoop

 上一篇
Node.js数据采集 Node.js数据采集
Node的强大永远都想不到,今天用Node做个简单的数据采集。 cheerio Cheerio是一个快速、灵活且精益的jQuery核心实现,用于在Node.js环境中解析HTML文档。它可以帮助您在服务器端轻松地从HTML文档中提取数据,比
2023-11-04
下一篇 
浏览器事件循环EventLoop 浏览器事件循环EventLoop
看过很多关于JS Event Loop的文章和视频,在面试的时候,这个问题也逃不脱的,事件循环涉及的内容还是有点多的,这里自己在梳理下,以便加深自己的理解和记忆。 了解进程和线程 每当我们运行应用程序时,操作系统都会创建该应用程序的实例对象
2023-10-18
  目录