当前位置:主页 > 脚本语言 > NodeJS >

全面了解Node事件循环

时间:2022-11-16 09:15:45 | 栏目:NodeJS | 点击:

Node事件循环

Node底层使用的语言libuv,是一个c++语言。他用来操作底层的操作系统,封装了操作系统的接口。Node的事件循环也是用libuv来写的,所以Node生命周期和浏览器的还是有区别的。

因为Node和操作系统打交道,所以事件循环比较复杂,也有一些自己特有的API。
事件循环在不同的操作系统里有一些细微的差异。这将涉及到操作系统的知识,暂时不表。本次只介绍JS主线程中,Node的运作流程。Node的其他线程暂时也不扩展。

事件循环图

说好的一张图,也不卖关子。下边这张图搞清楚了,事件循环就学会了。

事件循环图

事件循环图-结构

为了让大家先有个大局观,先贴一张目录结构图在前边

目录

接下来详细展开说说

主线程

主线程

上图中,几个色块的含义:

事件循环 圈

事件循环 圈

图中灰色的圈跟操作系统有关系,不是本章解析重点。重点关注黄色、橙色的圈还有中间橘黄的方框。

我们把每一圈的事件循环叫做「一次循环」、又叫「一次轮询」、又叫「一次Tick」。

一次循环要经过六个阶段:

  1. timers:计时器(setTimeout、setInterval等的回调函数存放在里边)
  2. pending callback
  3. idle prepare
  4. poll:轮询队列(除timers、check之外的回调存放在这里)
  5. check:检查阶段(使用 setImmediate 的回调会直接进入这个队列
  6. close callbacks

本次我们只关注上边标红的三个重点。

工作原理

其中,timerspending callbackidle prepare等执行完毕后,到达poll队列。

timers队列的工作原理

timers并非真正意义上的队列,他内部存放的是计时器。
每次到达这个队列,会检查计时器线程内的所有计时器,计时器线程内部多个计时器按照时间顺序排序。

检查过程:将每一个计时器按顺序分别计算一遍,计算该计时器开始计时的时间到当前时间是否满足计时器的间隔参数设定(比如1000ms,计算计时器开始计时到现在是否有1m)。当某个计时器检查通过,则执行其回调函数。

poll队列的运作方式

举例梳理事件流程

setTimeout(() => {
  console.log('object');
}, 5000)
console.log('node');

以上代码的事件流程梳理

进入主线程,执行setTimeout(),回调函数作为异步任务被放入异步队列timers队列中,暂时不执行。

要理解这个问题,看下边的代码及流程解析:

setTimeout(function t1() {
  console.log('setTimeout');
}, 5000)
console.log('node 生命周期');

const http = require('http')

const server = http.createServer(function h1() {
  console.log('请求回调');
});

server.listen(8080)

代码分析如下:

六个队列都没任务,则在poll队列等待。如下图:

无限循环……

梳理事件循环流程图:

注意:下图中的“是否有任务”的说法表示“是否有本队列的任务”。

event loop流程梳理

再用一个典型的例子验证下流程:

const startTime = new Date();

setTimeout(function f1() {
  console.log('setTimeout', new Date(), new Date() - startTime);
}, 200)

console.log('node 生命周期', startTime);

const fs = require('fs')

fs.readFile('./poll.js', 'utf-8', function fsFunc(err, data) {
  const fsTime = new Date()
  console.log('fs', fsTime);
  while (new Date() - fsTime < 300) {
  }
  console.log('结束死循环', new Date());
});

连续运行三遍,打印结果如下:

执行流程解析:

  1. 执行全局上下文,打印「node 生命周期 + 时间」
  2. 询问是否有event loop
  3. 有,进入timers队列,检查没有计时器(cpu处理速度可以,这时还没到200ms)
  4. 轮询进入到poll,读文件还没读完(比如此时才用了20ms),因此poll队列是空的,也没有任务回调
  5. 在poll队列等待……不断轮询看有没有回调
  6. 文件读完,poll队列有了fsFunc回调函数,并且被执行,输出「fs + 时间」
  7. 在while死循环那里卡300毫秒,
  8. 死循环卡到200ms的时候,f1回调进入timers队列。但此时poll队列很忙,占用了线程,不会向下执行。
  9. 直到300ms后poll队列清空,输出「结束死循环 + 时间」
  10. event loop赶紧向下走
  11. 再来一轮到timers,执行timers队列里的f1回调。于是看到「setTimeout + 时间」
  12. timers队列清空,回到poll队列,没有任务,等待一会。

等待时间够长后,向下回到event loop。

event loop检查没有其他异步任务了,结束线程,整个程序over退出。

check 阶段

检查阶段(使用 setImmediate 的回调会直接进入这个队列)

check队列的实际工作原理

真正的队列,里边扔的就是待执行的回调函数的集合。类似[fn,fn]这种形式的。
每次到达check这个队列后,立即按顺序执行回调函数即可【类似于[fn1,fn2].forEach((fn)=>fn())的感觉】

所以说,setImmediate不是一个计时器的概念。

如果你去面试,涉及到Node环节,可能会遇到下边这个问题:setImmediate和setTimeout(0)谁更快。

setImmediate() 与 setTimeout(0) 的对比

综上,setImmediate的运算速度比setTimeout(0)的要快,因为setTimeout还需要开计时器线程,并增加计算的开销。

二者的效果差不多。但是执行顺序不定

观察以下代码:

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

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

多次反复运行,执行效果如下:

顺序不定

可以看到多次运行,两句console.log打印的顺序不定。
这是因为setTimeout的间隔数最小填1,虽然下边代码填了0。但实际计算机执行当1ms算。(这里注意和浏览器的计时器区分。在浏览器中,setInterval的最小间隔数为10ms,小于10ms则会被设置为10;设备供电状态下,间隔最小为16.6ms。)

以上代码,主线程运行的时候,setTimeout函数调用,计时器线程增加一个定时器任务。setImmediate函数调用后,其回调函数立即push到check队列。主线程执行完毕。

eventloop判断时,发现timers和check队列有内容,进入异步轮询:

第一种情况:等到了timers里这段时间,可能还没有1ms的时间,定时器任务间隔时间的条件不成立所以timers里还没有回调函数。继续向下到了check队列里,这时候setImmediate的回调函数早已等候多时,直接执行。而再下次eventloop到达timers队列,定时器也早已成熟,才会执行setTimeout的回调任务。于是顺序就是「setImmediate -> setTimeout」。

第二种情况:但也有可能到了timers阶段时,超过了1ms。于是计算定时器条件成立,setTimeout的回调函数被直接执行。eventloop再向下到达check队列执行setImmediate的回调。最终顺序就是「setTimeout -> setImmediate」了。

所以,只比较这两个函数的情况下,二者的执行顺序最终结果取决于当下计算机的运行环境以及运行速度

二者时间差距的对比代码

------------------setTimeout测试:-------------------
let i = 0;
console.time('setTimeout');
function test() {
  if (i < 1000) {
    setTimeout(test, 0)
    i++
  } else {
    console.timeEnd('setTimeout');
  }
}
test();

------------------setImmediate测试:-------------------
let i = 0;
console.time('setImmediate');
function test() {
  if (i < 1000) {
    setImmediate(test)
    i++
  } else {
    console.timeEnd('setImmediate');
  }
}
test();

运行观察时间差距:

setTimeout与setImmediate时间差距

可见setTimeout远比setImmediate耗时多得多

这是因为setTimeout不仅有主代码执行的时间消耗。还有在timers队列里,对于计时器线程中各个定时任务的计算时间。

结合poll队列的面试题(考察timers、poll和check的执行顺序)

如果你看懂了上边的事件循环图,下边这道题难不倒你!

// 说说下边代码的执行顺序,先打印哪个?
const fs = require('fs')
fs.readFile('./poll.js', () => {
  setTimeout(() => console.log('setTimeout'), 0)
  setImmediate(() => console.log('setImmediate'))
})

上边这种代码逻辑,不管执行多少次,肯定都是先执行setImmediate。

先执行setImmediate

因为fs各个函数的回调是放在poll队列的。当程序holding在poll队列后,出现回调立即执行。
回调内执行setTimeout和setImmediate的函数后,check队列立即增加了回调。
回调执行完毕,轮询检查其他队列有内容,程序结束poll队列的holding向下执行。
check是poll阶段的紧接着的下一个。所以在向下的过程中,先执行check阶段内的回调,也就是先打印setImmediate。
到下一轮循环,到达timers队列,检查setTimeout计时器符合条件,则定时器回调被执行。

nextTick 与 Promise

说完宏任务,接下来说下微任务

nextTick表现形式

process.nextTick(() => {})

Promise表现形式

Promise.resolve().then(() => {})

如何参与事件循环?

事件循环中,每执行一个回调前,先按序清空一次nextTick和promise。

// 先思考下列代码的执行顺序
setImmediate(() => {
  console.log('setImmediate');
});

process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => {
    console.log('nextTick 2');
  })
})

console.log('global');


Promise.resolve().then(() => {
  console.log('promise 1');
  process.nextTick(() => {
    console.log('nextTick in promise');
  })
})

最终顺序:

  1. global
  2. nextTick 1
  3. nextTick 2
  4. promise 1
  5. nextTick in promise
  6. setImmediate

两个问题:

基于上边的说法,有两个问题待思考和解决:

  1. 1.每走一个异步宏任务队列就查一遍nextTick和promise?还是每执行完 宏任务队列里的一个回调函数就查一遍呢?
  2. 2.如果在poll的holding阶段,插入一个nextTick或者Promise的回调,会立即停止poll队列的holding去执行回调吗?

上边两个问题,看下边代码的说法

setTimeout(() => {
  console.log('setTimeout 100');
  setTimeout(() => {
    console.log('setTimeout 100 - 0');
    process.nextTick(() => {
      console.log('nextTick in setTimeout 100 - 0');
    })
  }, 0)
  setImmediate(() => {
    console.log('setImmediate in setTimeout 100');
    process.nextTick(() => {
      console.log('nextTick in setImmediate in setTimeout 100');
    })
  });
  process.nextTick(() => {
    console.log('nextTick in setTimeout100');
  })
  Promise.resolve().then(() => {
    console.log('promise in setTimeout100');
  })
}, 100)

const fs = require('fs')
fs.readFile('./1.poll.js', () => {
  console.log('poll 1');
  process.nextTick(() => {
    console.log('nextTick in poll ======');
  })
})

setTimeout(() => {
  console.log('setTimeout 0');
  process.nextTick(() => {
    console.log('nextTick in setTimeout');
  })
}, 0)

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
    console.log('promise in setTimeout1');
  })
  process.nextTick(() => {
    console.log('nextTick in setTimeout1');
  })
}, 1)

setImmediate(() => {
  console.log('setImmediate');
  process.nextTick(() => {
    console.log('nextTick in setImmediate');
  })
});

process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => {
    console.log('nextTick 2');
  })
})

console.log('global ------');

Promise.resolve().then(() => {
  console.log('promise 1');
  process.nextTick(() => {
    console.log('nextTick in promise');
  })
})

/** 执行顺序如下
global ------
nextTick 1
nextTick 2
promise 1
nextTick in promise
setTimeout 0 // 解释问题1. 没有上边的nextTick和promise,setTimeout和setImmediate的顺序不一定,有了以后肯定是0先开始。
// 可见,执行一个队列之前,就先检查并执行了nextTick和promise微队列
nextTick in setTimeout
setTimeout 1
nextTick in setTimeout1
promise in setTimeout1
setImmediate
nextTick in setImmediate
poll 1
nextTick in poll ======
setTimeout 100
nextTick in setTimeout100
promise in setTimeout100
setImmediate in setTimeout 100
nextTick in setImmediate in setTimeout 100
setTimeout 100 - 0
nextTick in setTimeout 100 - 0
 */

以上代码执行多次,顺序不变,setTimeout和setImmediate的顺序都没变。

执行顺序及具体原因说明如下:

  1. global :主线程同步任务,率先执行没毛病
  2. nextTick 1:执行异步宏任务之前,清空异步微任务,nextTick优先级高,先行一步
  3. nextTick 2:执行完上边这句代码,又一个nextTick微任务,立即率先执行
  4. promise 1:执行异步宏任务之前,清空异步微任务,Promise的优先级低,所以在nextTick完了以后立即执行
  5. nextTick in promise:清空Promise队列的过程中,遇到nextTick微任务,立即执行、清空
  6. setTimeout 0: 解释第一个问题. 没有上边的nextTick和promise,只有setTimeout和setImmediate时他俩的执行顺序不一定。有了以后肯定是0先开始。可见,执行一个宏队列之前,就先按顺序检查并执行了nextTick和promise微队列。等微队列全部执行完毕,setTimeout(0)的时机也成熟了,就被执行。
  7. nextTick in setTimeout:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【这种回调函数里的微任务,我不能确定是紧随同步任务执行的;还是放到微任务队列,等下一个宏任务执行前再清空的他们。但是顺序看上去和立即执行他们一样。不过我比较倾向于是后者:先放到微任务队列等待,下一个宏任务执行前清空他们。
  8. setTimeout 1:因为执行微任务耗费时间,导致此时timers里判断两个0和1的setTimeout计时器已经结束,所以两个setTimeout回调都已加入队列并被执行
  9. nextTick in setTimeout1:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】
  10. promise in setTimeout1:执行完上边这句代码,又一个Promise微任务,立即紧随执行 【可能是下一个宏任务前清空微任务】
  11. setImmediate:poll队列回调时机未到,先行向下到check队列,清空队列,立即执行setImmediate回调
  12. nextTick in setImmediate:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】
  13. poll 1:poll队列实际成熟,回调触发,同步任务执行。
  14. nextTick in poll :执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】
  15. setTimeout 100:定时器任务到达时间,执行回调。并在回调里往微任务推入了nextTick、Promise,往宏任务的check里推入了setImmediate的回调。并且也开启了计时器线程,往timers里增加了下一轮回调的可能。
  16. nextTick in setTimeout100:宏任务向下前,率先执行定时器回调内新增的微任务-nextTick 【这里就能确定了,是下一个宏任务前清空微任务的流程】
  17. promise in setTimeout100:紧接着执行定时器回调内新增的微任务-Promise 【清空完nextTick清空Promise的顺序】
  18. setImmediate in setTimeout 100:这次setImmediate比setTimeout(0)先执行的原因是:流程从timers向后走到check队列,已经有了setImmediate的回调,立即执行。
  19. nextTick in setImmediate in setTimeout 100:执行完上边这句代码,又一个nextTick微任务,下一个宏任务前率先清空微任务
  20. setTimeout 100 - 0:轮询又一次回到timers,执行100-0的回调。
  21. nextTick in setTimeout 100 - 0:执行完上边这句代码,又一个nextTick微任务,下一个宏任务前率先清空微任务。

扩展:为什么有了setImmediate还要有nextTick和Promise?

一开始设计的时候,setImmediate充当了微队列的作用(虽然他不是)。设计者希望执行完poll后立即执行setImmediate(当然现在也确实是这么表现的)。所以起的名字叫Immediate,表示立即的意思。但是后来问题是,poll里可能有N个任务连续执行,在执行期间想要执行setImmediate是不可能的。因为poll队列不停,流程不向下执行。

于是出现nextTick,真正的微队列概念。但此时,immediate的名字被占用了,所以名字叫nextTick(下一瞬间)。事件循环期间,执行任何一个队列之前,都要检查他是否被清空。其次是Promise。

面试题

最后,检验学习成果的面试题来了

async function async1() {
  console.log('async start');
  await async2();
  console.log('async end');
}

async function async2(){
  console.log('async2');
}
console.log('script start');

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

setTimeout(() => {
  console.log('setTimeout 3');
}, 3)

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

process.nextTick(() => {
  console.log('nextTick');
})

async1();

new Promise((res) => {
  console.log('promise1');
  res();
  console.log('promise2');
}).then(() => {
  console.log('promise 3');
});

console.log('script end');

// 答案如下
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
/**
script start
async start
async2
promise1
promise2
script end

nextTick
async end
promise 3

// 后边这仨的运行顺序就是验证你电脑运算速度的时候了。
速度最好(执行上边的同步代码 + 微任务 + 计时器运算用了不到0ms):
setImmediate
setTimeout 0
setTimeout 3

速度中等(执行上边的同步代码 + 微任务 + 计时器运算用了0~3ms以上):
setTimeout 0
setImmediate
setTimeout 3

速度较差(执行上边的同步代码 + 微任务 + 计时器运算用了3ms以上):
setTimeout 0
setTimeout 3
setImmediate
*/

思维脑图

Node生命周期核心阶段

您可能感兴趣的文章:

相关文章