首頁  >  文章  >  web前端  >  一文詳解Node.js中的事件循環

一文詳解Node.js中的事件循環

青灯夜游
青灯夜游轉載
2022-10-21 20:36:591977瀏覽

這篇文章帶大家深度理解Node中的事件循環,希望對大家有幫助!

一文詳解Node.js中的事件循環

ALL THE TIME,我們寫的的大部分javascript程式碼都是在瀏覽器環境下編譯運行的,因此可能我們對瀏覽器的事件循環機制了解比Node.JS的事件循環更深入一些,但是最近寫開始深入NodeJS學習的時候,發現NodeJS的事件循環機制和瀏覽器端有很大的區別,特此記錄來深入的學習了下,以幫助自己及小夥伴們忘記後查閱及理解。

一文詳解Node.js中的事件循環

什麼是事件循環

#首先我們需要了解最基礎的一些東西,例如這個事件循環,事件循環是指Node.js執行非阻塞I/O操作,儘管==JavaScript是單線程的==,但由於大多數==內核都是多線程==的,Node.js會盡可能將操作裝載到系統核心。因此它們可以處理在背景執行的多個操作。當其中一個操作完成時,核心會告訴Node.js,以便Node.js可以將相應的回調加入到輪詢隊列中以最終執行。 【相關教學推薦:nodejs影片教學

當Node.js啟動時會初始化event loop, 每一個event loop都會包含依下列順序六個循環階段:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
  • 1. timers 階段: 這個階段執行setTimeout(callback)setInterval(callback) 預定的callback;
  • #2. I/O callbacks 階段: 此階段執行某些系統操作的回調,例如TCP錯誤的類型。例如,如果TCP套接字在嘗試連線時收到 ECONNREFUSED,則某些* nix系統希望等待報告錯誤。這將操作將等待在==I/O回呼階段==執行;
  • 3. idle, prepare 階段: 僅node內部使用;
  • 4. poll 階段: 取得新的I/O事件, 例如操作讀取檔等等,適當的條件下node將阻塞在這裡;
  • 5. check 階段: 執行setImmediate() 設定的callbacks;
  • #6. close callbacks 階段: 例如socket.on('close', callback) 的callback會在這個階段執行;

事件循環詳解

一文詳解Node.js中的事件循環

這個圖是整個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(&#39;fs&#39;)

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

// poll阶段(等待新的事件出现)
const readFileStart =  Date.now();
fs.readFile(&#39;./Demo.txt&#39;, (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(&#39;check阶段&#39;)
})
/*控制台打印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(&#39;fs&#39;);
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 + &#39;/&#39; + __filename, callback);
}

const lastTime = Date.now();

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

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

/** * 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(&#39;fs&#39;);
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(&#39;nextTick&#39;);
    nextTick();
  });
}

const lastTime = Date.now();

setTimeout(() => {
  console.log(&#39;timers&#39;, Date.now() - lastTime + &#39;ms&#39;);
}, 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(&#39;timeout&#39;);
},0);

setImmediate(function immediate () {
  console.log(&#39;immediate&#39;);
});

执行结果:

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(&#39;fs&#39;)

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

打印结果如下:

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(&#39;setImmediate&#39;);
    });
    setTimeout(() => {
        console.log(&#39;setTimeout&#39;);
    }, 0);
}, 0);

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

综上所演示,我们可以总结如下:

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

4. nextTickPromise

概念:对于这两个,我们可以把它们理解成一个微任务。也就是说,它其实不属于事件循环的一部分。 那么他们是在什么时候执行呢? 不管在什么地方调用,他们都会在其所处的事件循环最后,事件循环进入下一个循环的阶段前执行。

setTimeout(() => {
    console.log(&#39;timeout0&#39;);
    new Promise((resolve, reject) => { resolve(&#39;resolved&#39;) }).then(res => console.log(res));
    new Promise((resolve, reject) => {
      setTimeout(()=>{
        resolve(&#39;timeout resolved&#39;)
      })
    }).then(res => console.log(res));
    process.nextTick(() => {
        console.log(&#39;nextTick1&#39;);
        process.nextTick(() => {
            console.log(&#39;nextTick2&#39;);
        });
    });
    process.nextTick(() => {
        console.log(&#39;nextTick3&#39;);
    });
    console.log(&#39;sync&#39;);
    setTimeout(() => {
        console.log(&#39;timeout2&#39;);
    }, 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(&#39;async1 start&#39;)
    await async2()
    console.log(&#39;async1 end&#39;)
  }
async function async2(){
    console.log(&#39;async2&#39;)
}
console.log(&#39;script start&#39;)
setTimeout(function(){
    console.log(&#39;setTimeout0&#39;) 
},0)  
setTimeout(function(){
    console.log(&#39;setTimeout3&#39;) 
},3)  
setImmediate(() => console.log(&#39;setImmediate&#39;));
process.nextTick(() => console.log(&#39;nextTick&#39;));
async1();
new Promise(function(resolve){
    console.log(&#39;promise1&#39;)
    resolve();
    console.log(&#39;promise2&#39;)
}).then(function(){
    console.log(&#39;promise3&#39;)
})
console.log(&#39;script end&#39;)

打印结果为:

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

大家呢,可以先看着代码,默默地在心底走一变代码,然后对比输出的结果,当然最后三位,我个人认为是有点问题的,毕竟在主模块运行,大家的答案,最后三位可能会有偏差;

更多node相关知识,请访问:nodejs 教程

以上是一文詳解Node.js中的事件循環的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除