浏览器和Node.js中的Event Loop

2,931 阅读7分钟

前言

众所周知,javascript是一门单线程语言,而当我们使用ajax和服务端进行通信的时候是需要一定时间的,这样当前线程就会被阻塞,使浏览器失去相应。因此,当js执行执行一些长时间的任务时,我们希望有一种异步的方式处理这种任务。事件循环(event loop)就是如何处理异步执行顺序的一种机制。

$.get(url, function (data) {
    //do something
});

浏览器中的事件循环

接下来会一一介绍,事件循环中的执行栈事件队列宏任务微任务等概念

什么是执行栈

执行栈就是js代码运行的地方,上图call stack所示。当下面程序运行时,会推送的调用栈中被执行。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 500);
console.log('Bye');

什么是事件队列

当浏览器中的事件监听函数被触发(DOM)、网络请求的相应(ajax)、定时器被触发(setTimeout)相对应的回调函数就会被推送到事件队列中,等待执行;如上图中的Callback Queue。

什么是事件循环

事件循环是一个这样的过程:当执行栈中的任务结束之后,会将事件队列中的第一个任务推入到执行栈中执行,当任务处理完毕,又会取事件队列中的第一个任务,如此往复,便构成了事件循环。

对应到下面代码中。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 500);
console.log('Bye');
  • 程序推送到执行栈中被执行
  • 执行console语句、输出Hi
  • 执行setTimeou语句
  • 执行console语句、输出Bye
  • 500ms的时候,setTimeout的回调函数被推送到事件队列中
  • 此时事件队列中只有setTimeout的回调函数这一个任务,会被推到执行栈中执行
  • console语句执行、输出cb1

通过上面的例子会对执行栈和事件队列有个基本的认识。由于JS是单线程的,同步任务会造成浏览器阻塞,我们把任务分成一个一个的异步任务,通过事件循环来执行事件队列中的任务。这就使得当我们挂起某一个任务的时候可以去做一些其他的事情,而不需要等待这个任务执行完毕。所以事件循环的运行机制大致分为以下步骤:

1、检查事件队列是否为空,如果为空,则继续检查;如不为空,则执行 2;

2、取出事件队列的首部,压入执行栈;

3、执行任务;

4、检查执行栈,如果执行栈为空,则跳回第 1 步;如不为空,则继续检查;

浏览器渲染时机

我们知道DOM操作会触发浏览器渲染,如增、删节点,改变背景颜色。那么这类操作是如何在浏览器当中奏效的?

至此我们已经知道了事件循环是如何执行的,事件循环器会不停的检查事件队列,如果不为空,则取出队首压入执行栈执行。当一个任务执行完毕之后,事件循环器又会继续不停的检查事件队列,不过在这间,浏览器会对页面进行渲染。这就保证了用户在浏览页面的时候不会出现页面阻塞的情况,这也使 JS 动画成为可能。

function move() {
    setTimeout(() => {
        dom.style.left = dom.offsetLeft + 10 + 'px'
        move()
    }, 15);
}
move()

现在用事件循环的机制说明js动画的过程。上面代码会在执行栈中执行,move函数被调用,setTimeout的回调函数15ms之后会被推送到事件队列中。此时执行栈中的任务结束,浏览器渲染、检查事件队列不断循环。当15ms之后事件队列中有任务时,会被推送到执行栈中执行,这时dom节点向右偏移10px,move函数执行、执行栈结束,浏览渲染、检查事件队列。如此往复就形成了动画。

宏任务和微任务(microtask)

先看一段代码,是如何输出的;

console.log('script start');
setTimeout(function () {
    console.log('setTimeout');
}, 0);
Promise.resolve().then(function () {
    console.log('promise1');
}).then(function () {
    console.log('promise2');

});
console.log('script end');

答案是:'script start''script end''promise1''promise2''setTimeout'

setTimeout的回调函数是宏任务、Promise的回调函数是微任务。微任务和宏任务一样遵循事件循环机制,但是他们还是有些差别。

1、宏任务和微任务的事件队列是相互独立的;

2、微任务队列的检查时机早于宏任务。(执行栈中任务结束就会马上清空微任务事件队列)

根据上面的规则,解释代码的输出。

  • 执行栈中的代码执行,宏任务推入宏任务事件队列、微任务推入微任务事件队列,执行栈任务结束

  • 检查微任务事件队列,此时已经有Promise的回调函数,推入执行栈,输出promise1。Promise还有回调函数,推入微任务事件队列,执行栈结束。

  • 检查微任务事件队列,推入执行栈,输出promise2,执行栈结束。

  • 检查微任务事件队列,此时被清空

  • 检查宏任务事件队列,推入执行栈,输出setTimeout,执行栈结束。

      宏任务有: **setTimeout** 、**setImmediate** 、 **MessageChannel**
      微任务有: **setTimeout** 、**setImmediate** 、 **MessageChannel**
    

Node.js中的事件循环

Node中的事件循环是和浏览器有很大区别的

当Node.js启动时,会初始化event loop;每个event loop都会包含按如下顺序六个循环阶段

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────
  • timers 阶段: 这个阶段执行setTimeout(callback) and setInterval(callback)预定的callback;
  • I/O callbacks 阶段: 执行除了 close事件的callbacks、被timers(定时器,setTimeout、setInterval等)设定的callbacks、setImmediate()设定的callbacks之外的callbacks;
  • idle, prepare 阶段: 仅node内部使用;
  • poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
  • check 阶段: 执行setImmediate() 设定的callbacks;
  • close callbacks 阶段: 比如socket.on(‘close’, callback)的callback会在这个阶段执行。

每一个阶段都有一个装有callbacks的fifo queue(队列),当event loop运行到一个指定阶段时, node将执行该阶段的fifo queue(队列),当队列callback执行完或者执行callbacks数量超过该阶段的上限时,event loop会转入下一下阶段。

Node.js中的宏任务和微任务

宏任务:setTimeout和setImmediate
  • setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行;但其在timer阶段执行
  • setImmediate 设计在check阶段执行;

谁先输出,谁后输出?

setTimeout(function timeout () {
  console.log('timeout');
},0);

setImmediate(function immediate () {
  console.log('immediate');
});

答案是不确定的。有两个前提我们是需要清楚的;

  • event loop初始化是需要一定时间的
  • setTimeout有最小毫秒数的,通常是4ms。

当:event loop准备时间 > setTimeout最小毫秒数。从timers阶段检查,此时队列中已经有setTimeout的任务,所以timeout先输出;

当:event loop准备时间 < setTimeout最小毫秒数。从timers阶段检查,此时队列是空的就下检查接下来的阶段,到check阶段,已经有setImmediate的任务,所以immediate先输出;

微任务:process.nextTick()和Promise.then()

微任务不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行;nextTick比Promise.then()先执行

下面代码是如何执行的。

setImmediate(() => {
  console.log('setImmediate1')
  setTimeout(() => {
    console.log('setTimeout1')
  }, 0);
})
setTimeout(()=>{
  process.nextTick(()=>console.log('nextTick'))
  console.log('setTimeout2')
  setImmediate(()=>{
    console.log('setImmediate2')
  })
},0);
  • 从前面的知识知道,此时setTimeout和setImmediate执行顺序是不确定的。
  • 假设setImmediate先执行,输出setImmediate1,setTimeout的任务添加到timer阶段
  • 检查timer阶段,这时已经有两个任务。先执行之前的第一个任务,nextTick添加到微任务队列,输出setTimeout2,setImmediate的任务添加到check阶段。
  • timer中还有一个任务,执行输出setTimeout1
  • 切换阶段,微任务执行,输出nextTick
  • 检查check阶段,输出setImmediate2

思考题

let fs = require('fs')

fs.readFile('./1.txt', 'utf8', function (err, data) {
    setTimeout(() => {
        console.log('setTimeout')
    }, 0);
    setImmediate(() => {
        console.log('setImmediate')
    })
})

这种情况下的setTimeout和setImmediate执行的顺序确定吗?readFile的回调函数是在poll阶段执行 答案是setImmediatesetTimeout先执行

结语

浏览器中和Node.js中的事件循环可以说是两套不同的机制,做个总结,希望有所帮助。