Node与浏览器的事件循环有什么区别?

前面的话

小柒前面总结了几篇关于浏览器的事件循环,这篇文章主要总结Node的事件循环。

Node中的Event Loop
什么是Node.js

Node.js是一个事件驱动I/O服务端JavaScript环境,基于Google的V8引擎,V8引擎执行Javascript的速度非常快,性能非常好。

而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。

Node.js运行机制
  • V8引擎解析JavaScript脚本
  • 解析之后的代码调用Node API
  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个
    Event Loop,以异步的方式将任务的执行结果返回给V8引擎
  • V8引擎再将结果返回给用户
libuv中的6个阶段

libuv引擎中的事件循环分为6个阶段,它们会按顺序反复执行。每当进入某个阶段,都会从对应的回调队列中取出函数执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

Node与浏览器的事件循环有什么区别?_第1张图片

从图中可以看出Node中事件循环的顺序:

外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段(按照该顺序反复运行)…

  • timers阶段: 执行timer(setTimeout 、setInterval)的回调

  • I/O callbacks阶段: 处理一些上一轮循环中少数未执行的I/O回调

  • idle,prepare阶段: 仅Node内部使用

  • poll阶段: 获取新的I/O事件,适当的条件下node将阻塞在这里。(发生阻塞的情况为:poll队列为空,且没有代码设定为setImmediate())

  • check阶段: 执行setImmediate()的回调。(如果poll阶段空闲,并且有被setImmediate()设定的回调,那么事件循环直接跳到check执行,而不是阻塞在poll阶段等待回调被加入)

    [注意]: setImmediate()在这个阶段具有最高优先级,只要poll队列为空,代码被setImmediate(),无论是否有timers达到下限时间,setImmediate()的代码都先执行。 (下面有例子会解释)

  • close callbacks阶段:执行socket的close事件回调

[注意]: 以上阶段不包括process.nextTick()。日常开发中的绝大部分异步任务都是在timers、poll、check这3个阶段处理的

Micro-Task 与 Macro-Task

Node端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列。

  • 常见的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等。

  • 常见的 micro-task 比如: process.nextTick、new Promise().then(回调)、mutationObserver等。

[关于setTimeout 和 setImmediate]

两者非常相似,区别在于调用时机不同:

  • setImmediate 在check阶段
  • setTimeout在timers阶段
setTimeout(function timeout () {
  console.log('timeout');
},0);
setImmediate(function immediate () {
  console.log('immediate');
});
 

这段代码中setTimeout可能执行在前,也可能执行在后(在node中是“随缘的”)。原因:

  • 首先进入timers阶段,如果我们的机械性能一般,那么进入timers阶段,一毫秒已经过去了,相当于(setTimeout(fn, 0) === setTimeout(fn, 1) ,那么setTimeout的回调会首先执行。
  • 如果没有到一毫秒,那么times阶段的时候,下限时间没到,setTimeout回调不执行,事件循环到了poll阶段,这个时候队列为空,此时代码有setImmediate(),于是先执行了setImmediate()的回调函数,之后再下一个事件循环再执行setTimeout的回调函数。

而我们在执行代码的时候,进入timers的时间延迟其实是随机的,并不是确定的,所以会出现两个函数执行顺序随机的情况。

再来看一段代码:

var fs = require('fs')

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

我们会发现,setImmediate永远先于setTimeout执行。

原因如下:

  • fs.readFile的回调是在poll阶段执行的,当其回调执行完毕之后,poll队列为空,而setTimeout进入了timers的队列,此时又有代码被setImmediate(),于是事件循环先进入check阶段 执行回调,之后再下一个事件循环再在timers阶段中执行回调。

下面的代码也是同样的道理:

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

在timers阶段执行外部的setTimeout之后,内层setTimeout与setImmediate入队,时间循环继续往下走,到poll阶段发现队列为空,此时代码有setImmediate(),所以直接进入check阶段执行setimmediate()的回调。之后再第二次时间循环的timers中再执行相应的回调。

总结

  • 如果两者都在主模块中调用,那么执行先后取决于进程性能,也就是随机。
  • 如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate的回调永远先执行。
关于[process.nextTick() ]

这是一个微任务,node的事件循环不包括process.nextTick() 。 它有自己的队列,当事件循环的每一个阶段完成之后,如果存在nextTick队列,就会清空队列中的所有回调函数,并且优先于其他microtask执行

例1:

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})

 // nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

例2:

setTimeout(() => {
    console.log('timeout0');
    process.nextTick(() => {
        console.log('nextTick1');
        process.nextTick(() => {
            console.log('nextTick2');
        });
    });
    process.nextTick(() => {
        console.log('nextTick3');
    });
    console.log('sync');
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
}, 0);

// timeout0 -> sync->nextTick1->nextTick3->nextTick2->timeout2

解释:
timers阶段执行外层setTimeout的回调,遇到同步代码先执行,也就有timeout0、sync的输出。遇到process.nextTick后入微任务队列,依次nextTick1、nextTick3、nextTick2入队后出队输出。之后,在下一个事件循环的timers阶段,执行setTimeout回调输出timeout2。

例3:

setImmediate(function(){
  console.log("setImmediate");
  setImmediate(function(){
    console.log("嵌套setImmediate");
  });
  process.nextTick(function(){
    console.log("nextTick");
  })
});

// setImmediate
// nextTick
// 嵌套setImmediate

解释: 事件循环进入check阶段执行回调函数setImmediate,执行同步任务,输出setImmediate,然后check阶段完成。有process.nextTick队列,则输出nextTick。嵌套的setImmediate在下一次事件循环的check阶段执行。

Node与浏览器的 Event Loop 差异
  • 浏览器环境下,microtask的任务队列是每个macrotask执行完成之后执行。
  • 而在node.js中,microtask会在事件循环的各个阶段之间执行,也就是说,每一个阶段执行完毕,就会去执行microtask队列中的任务。
    Node与浏览器的事件循环有什么区别?_第2张图片
    举例说明:
setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)

对于浏览器:

执行第一个宏任务时,输出同步代码timer1之后,发现微任务then(),执行微任务输出promise1;接着执行第二个宏任务,输出同步任务timer2,发现微任务,执行微任务,输出promise2。
即:timer1 ->promise1->timer2->promise2

浏览器端的处理过程如下:

Node与浏览器的事件循环有什么区别?_第3张图片

对于Node情况分两种:

  • 如果是node11版本,与浏览器执行结果一样

  • 如果是node10及以下:要看第一个定时器执行完,第二个定时器是否在完成队列中。

    • 如果是第二个定时器还未在完成队列中,最后的结果为 timer1=>promise1=>timer2=>promise2
    • 如果是第二个定时器已经在完成队列中,则最后的结果为timer1=>timer2=>promise1=>promise2(下文过程解释基于这种情况下).

1.全局脚本(main())执行,将2个timer依次放入timer队列,main()执行完毕,调用栈空闲,任务队列开始执行;

2.首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2;

3.至此,timer阶段执行结束,event loop进入下一个阶段之前,执行microtask队列的所有任务,依次打印promise1、promise2

node端执行过程:

Node与浏览器的事件循环有什么区别?_第4张图片

总结

浏览器和Node 环境下,microtask 任务队列的执行时机不同

  • Node端,microtask 在事件循环的各个阶段之间执行
  • 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行

参考链接:

  • https://juejin.im/post/5c337ae06fb9a049bc4cd218
  • https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

你可能感兴趣的:(js进阶篇,node的事件循环)