详解浏览器和Node的事件循环机制及区别

关于事件循环机制(详解)

  • 前言
  • 一、浏览器的事件循环机制
  • 二、Node的事件循环机制
  • 三、两者的区别

前言

JS是单线程的脚本语言,即在同一时间只能做一件事。为了协调时间、用户交互、脚本、UI渲染和网络处理等行为,防止主线程堵塞,这才有了事件循环机制。

前面提到了JS是单线程的脚本语言,那么问题来了,为什么JS是单线程的呢?

JS作为主要运行在浏览器的脚本语言,主要的工作之一就是操作DOM。若JS有两个线程的话,那么如果这两个线程对同一DOM进行操作,这样浏览器就会错乱,无法判断这两个线程的优先级。

为了避免这个问题,JS必须是单线程的语言,而且在未来这一特性也不会改变。


因为JS是单线程语言,当遇到多个任务时,不可能一直等待任务完成,这会造成巨大的资源浪费。

在了解事件循环机制前,我们要先了解一下执行栈、任务队列和主线程。

执行栈
所有任务都在主线程上执行,形成执行栈。
当执行某个函数、用户点击一次鼠标、一个图片加载完成等事件发生时,只要指定过回调函数,这些事件发生时就会进入执行栈队列中,等待主线程读取,遵循先进后出原则。

任务队列
存放异步任务的运行结果。
只要异步任务有了运行结果,就在任务队列中放置一个事件。事件循环是唯一的,但是任务队列可以有很多个。队列遵循先进先出原则。

主线程
主线程规定了要执行执行栈中的哪个事件。主线程会不停的从执行栈中读取事件,会执行完所有栈中的同步代码,这就是主线程循环。

当主线程将执行栈中所有的同步任务执行完后,主线程将回去查看任务队列是否有任务,如果有,那些对应的异步任务会结束等待状态,进入执行栈并开始执行。


一、浏览器的事件循环机制

JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。整个执行过程,我们称为事件循环过程。

一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。

任务队列又分为macro-task(宏任务)与micro-task(微任务)

宏任务包括:scrip整体代码、setTimeout、setInterval、setImmediate、I/O操作、UI 渲染
微任务包括:process.nextTick、Promise、Async/Await、MutationObserver

顺带一提~
new Promise是同步任务,Promise.resolve().then()是微任务

详解浏览器和Node的事件循环机制及区别_第1张图片
总结: 先执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。

当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务;如果没有,会读取宏任务中排在最前面的任务。

执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后再读取微任务队列里的任务,以此类推。

个人理解: 这个事件循环机制我理了很久,先用我个人的大白话讲一下。

对于宏任务和微任务的关系,那就是微任务始终跟在当前宏任务的后面,当前宏任务还没执行完之前,遇到宏任务先扔一边,遇到微任务就跟在当前宏任务后面。

首先JS代码本身就是一个宏任务,所以在执行代码时,JS还没执行完之前,遇到宏任务(例如setTimeout)先放一边,遇到微任务就跟在当前宏任务后面(JS代码),等JS代码全部执行完之后,执行跟在JS代码后的微任务,当一个宏任务和它携带的微任务执行完后,就是一个事件循环完毕,再去找当前代码中的下一个宏任务开始第二轮循环。

总结来说,就是所有的微任务总会在下一个宏任务开始前执行完毕。

先来看一个小的例子:

setTimeout(function () {
    new Promise(function (resolve, reject) {
        console.log('异步宏任务promise');
        resolve();
    }).then(function () {
        console.log('异步微任务then')
    })
    console.log('异步宏任务');
}, 0)
new Promise(function (resolve, reject) {
    console.log('同步宏任务promise');
    resolve();
}).then(function () {
    console.log('同步微任务then')
})
console.log('同步宏任务')

控制台输出:
详解浏览器和Node的事件循环机制及区别_第2张图片

分析: 首先读取整段JS代码(宏任务),遇到第一个setTimeout时,判断是一个宏任务,先放到一边,继续往下执行。遇到一个new Promise,因为new一个对象是立即执行的,所以new Promise是一个同步任务,输出’同步宏任务promise’,接着向下执行,遇到then方法(微任务),跟在当前宏任务(JS代码)后面,继续向下,输出’同步宏任务’。

当前宏任务(JS代码)完毕,执行跟在宏任务后面的微任务(then),输出’同步微任务then‘,此时,一个宏任务带着它的微任务执行完毕,也就是一个事件循环完毕,准备开始第二次循环。

读取第二个宏任务(setTimeout),遇到new Promise,直接执行,输出’异步宏任务promise’,然后遇到一个微任务(then),跟在当前宏任务(setTimeout)后面,继续向下,输出’异步宏任务’,此时当前宏任务执行完毕,执行跟在它后面的微任务(then),输出’异步微任务then’。


Promise.resolve().then(()=>{
    console.log('Promise1')
    setTimeout(()=>{
        console.log('setTimeout2')
    },0)
})
setTimeout(()=>{
    console.log('setTimeout1')
    Promise.resolve().then(()=>{
        console.log('Promise2')
    })
})

控制台输出:
详解浏览器和Node的事件循环机制及区别_第3张图片

分析: 首先执行整段script代码,遇到Promise.resolve().then微任务,跟在当前宏任务(JS代码)之后,继续向下,遇到宏任务(setTimeout1)先放一边,执行跟在当前宏任务后面的微任务,输出Promise1,一个事件循环完成。

此时产生了一个宏任务setTimeout2,再次放到一边,找到第二个宏任务开始执行,即setTimeout1,输出setTimeout1,产生了一个微任务Promise.resolve().then,跟在当前宏任务(setTimeout1)后面,宏任务执行完后,执行微任务,输出Promise2,又一个事件循环完成。

再去找下一个宏任务,即setTimeout2,输出setTimeout2。


二、Node的事件循环机制

浏览器中有事件循环,Node中也有,但是与浏览器中的事件循环完全不一样。

宏任务包括:scrip整体代码、setTimeout、setInterval、setImmediate、I/O操作
微任务包括:process.nextTick(在微任务队列执行之前执行)、new Promise().then(回调)

Node.js采用V8作为js的解析引擎,I/O处理使用自己设计的libuv。

libuv是基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环也是它里面的 实现。

Node.js的运行机制:

  • V8引擎解析JS脚本
  • 解析后的代码,调用Node API
  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给V8引擎。
  • V8引擎再将结果返回给用户。

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

这是一张官网的node事件循环简化图:
详解浏览器和Node的事件循环机制及区别_第4张图片一共六个阶段,这六个阶段为一轮事件循环。

我们来看看这几个阶段都是做什么的吧:

  • 定时器检测阶段(timers): 在这个阶段执行timer的回调,即setTimeout、setInterval里面的回调函数。
  • I/O事件回调阶段(I/O callbacks): 此阶段会执行几乎所有的回调函数,除了close callbacks(关闭回调)和那些由timers与setImmediate()调度的回调。
  • 限制阶段(idel,prepare):仅系统内部使用。(内部处理的事务,暂时不需要关注)
  • 轮询阶段(poll): 检索新的I/O事件、执行与I/O相关的回调,其余情况nide将在适当的时候在此堵塞。
  • 检查阶段(check):setImmediate()回调函数在这里执行。
  • 关闭事件回调阶段(close callback):一些关闭的回调函数。

我们详细了解一下poll阶段,这是一个至关重要的阶段:

如果事件循环进入了poll阶段,并且代码未设定timer:

  • 如果poll 队列不为空,事件循环将同步执行队列里的callback,直至队列为空,或执行的callback达到系统上限。
  • 如果poll 队列为空:
    • 如果代码已经被setImmediate()设定了callback,事件循环将结束poll阶段进入check阶段,并执行check阶段的队列。
    • 如果代码没有设定setImmediate(callback),事件循环将阻塞在该阶段等待callbacks加入poll队列,一旦到达就立即执行。
  • 如果poll 队列进入空状态时(即poll阶段为空闲状态),事件循环将检查timers,如果有一个或多个timers事件已经到达,事件循环将按循环顺序进入timers阶段,并执行timer 队列。

接下来看一个例子便于理解:

function someAsyncOperation(callback) {
    //花费两毫秒
    fs.readFile(__dirname + '/' + __filename, callback);
}
var timeoutScheduled = Date.now();
var fileReadTime = 0;

setTimeout(function() {
    var delay = Date.now() - timeoutScheduled;
    console.log('setTimeout:' + (delay) + 'ms have passed since I was scheduled');
},10);

someAsyncOperation(function(){
    fileReadTime = Data.now();
    while(Date.now() - fileReadTime < 20) {
        
    }
})

我们只分析三个比较重点的阶段:定时器检测阶段(timers)、I/O事件回调阶段(I/O callbacks)、轮询阶段(poll)

首先从setTimeout开始,这个定时器是在10ms之后执行,所以先将setTimeout的回调添加到I/O中,此时 timer、I/O callbacks 和 poll 中还没有任何callback。

继续向下执行,遇到someAsyncOperation方法,这个方法读取一个文件,需要2ms。也就是说2ms之后会有一个callback,然后将fs.readFile回调加入到 I/O 中。此时继续进行到 poll 阶段,poll 队列为空,且代码没有设定setImmediate(callback),这时事件循环将阻塞在该阶段,并等待callbacks加入poll队列。

2ms之后,fs.readFile回调加入poll队列,此时立即执行fs.readFile的回调,但是fs.readFile的回调也就是while函数,将会等待20ms才执行,也就是说 poll 等待到22ms。但是在10ms的时候setTimeout这个定时器的10ms时间已经到了,但是无法执行,因为JS是单线程的,setTimeout的回调被阻塞。

22ms之后,fs.readFile的回调执行完毕了,此时我们来看这句话:
详解浏览器和Node的事件循环机制及区别_第5张图片
这个时候,setTimeout将会跳过poll阶段,进行下一次循环来到timer阶段。在timer阶段中,setTimeout的回调被执行,代码执行结束。


三、两者的区别

浏览器事件循环机制中,微任务的任务队列是在每个宏任务执行完之后执行。

Node事件循环机制中,微任务会在事件循环的各个阶段之间执行,也就是说,一个阶段执行完毕,就会去执行微任务队列的任务。

浏览器的事件循环机制:
详解浏览器和Node的事件循环机制及区别_第6张图片

Node的事件循环机制:

详解浏览器和Node的事件循环机制及区别_第7张图片

你可能感兴趣的:(Javascript,js,node,事件循环机制)