一篇文章教会你 Event loop——浏览器和 Node

(点击上方公众号,可快速关注)


作者:tomc

https://segmentfault.com/a/1190000013861128


最近对Event loop比较感兴趣,所以了解了一下。但是发现整个Event loop尽管有很多篇文章,但是没有一篇可以看完就对它所有内容都了解的文章。大部分的文章都只阐述了浏览器或者Node二者之一,没有对比的去看的话,认识总是浅一点。所以才有了这篇整理了百家之长的文章。

1. 定义

Event loop:即事件循环,是JavaScript引擎处理异步任务的方式。说人话就是为了让单线程的JavaScript通畅的跑起来,所有的异步操作都要被合适的处理,这个处理逻辑就叫做Event loop。

我们在之前的文章中提到过,JavaScript引擎又称为JavaScript解释器,是JavaScript解释为机器码的工具,分别运行在浏览器和Node中。而根据上下文的不同,Event loop也有不同的实现:其中Node使用了libuv库来实现Event loop; 而在浏览器中,html规范定义了Event loop,具体的实现则交给不同的厂商去完成。

所以,浏览器的Event loop和Node的Event loop是两个概念,下面分别来看一下。

2. 意义

在实际工作中,了解Event loop的意义能帮助你分析一些异步次序的问题(当然,随着es7 async和await的流行,这样的机会越来越少了)。除此以外,它还对你了解浏览器和Node的内部机制有积极的作用;对于参加面试,被问到一堆异步操作的执行顺序时,也不至于两眼抓瞎。

3. 浏览器上的实现

在JavaScript中,任务被分为Task(又称为MacroTask,宏任务)和MicroTask(微任务)两种。它们分别包含以下内容:

MacroTask: script(整体代码), setTimeout, setInterval, setImmediate(node独有), I/O, UI renderingMicroTask: process.nextTick(node独有), Promises, Object.observe(废弃), MutationObserver

需要注意的一点是:在同一个上下文中,总的执行顺序为同步代码—>microTask—>macroTask6。这一块我们在下文中会讲。

浏览器中,一个事件循环里有很多个来自不同任务源的任务队列(task queues),每一个任务队列里的任务是严格按照先进先出的顺序执行的。但是,因为浏览器自己调度的关系,不同任务队列的任务的执行顺序是不确定的。

具体来说,浏览器会不断从task队列中按顺序取task执行,每执行完一个task都会检查microtask队列是否为空(执行完一个task的具体标志是函数执行栈为空),如果不为空则会一次性执行完所有microtask。然后再进入下一个循环去task队列中取下一个task执行,以此类推。

注意:图中橙色的MacroTask任务队列也应该是在不断被切换着的。

本段大批量引用了《什么是浏览器的事件循环(Event Loop)》的相关内容,想看更加详细的描述可以自行取用。

4. Node上的实现

nodejs的event loop分为6个阶段,它们会按照顺序反复运行,分别如下:

  1. timers:执行setTimeout() 和 setInterval()中到期的callback。

  2. I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行

  3. idle, prepare:队列的移动,仅内部使用

  4. poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段

  5. check:执行setImmediate的callback

  6. close callbacks:执行close事件的callback,例如socket.on("close",func)

不同于浏览器的是,在每个阶段完成后,而不是MacroTask任务完成后,microTask队列就会被执行。这就导致了同样的代码在不同的上下文环境下会出现不同的结果。我们在下文中会探讨。

另外需要注意的是,如果在timers阶段执行时创建了setImmediate则会在此轮循环的check阶段执行,如果在timers阶段创建了setTimeout,由于timers已取出完毕,则会进入下轮循环,check阶段创建timers任务同理。

5. 示例
5.1 浏览器与Node执行顺序的区别
  
  
  1. setTimeout(()=>{

  2.    console.log('timer1')

  3.    Promise.resolve().then(function() {

  4.        console.log('promise1')

  5.    })

  6. }, 0)

  7. setTimeout(()=>{

  8.    console.log('timer2')

  9.    Promise.resolve().then(function() {

  10.        console.log('promise2')

  11.    })

  12. }, 0)

  13. //浏览器输出:

  14. time1

  15. promise1

  16. time2

  17. promise2

  18. //Node输出:

  19. time1

  20. time2

  21. promise1

  22. promise2

在这个例子中,Node的逻辑如下:

最初timer1和timer2就在timers阶段中。开始时首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2;

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

而浏览器则因为两个setTimeout作为两个MacroTask, 所以先输出timer1, promise1,再输出timer2,promise2。

更加详细的信息可以查阅《深入理解js事件循环机制(Node.js篇)》

为了证明我们的理论,把代码改成下面的样子:

  
  
  1. setImmediate(() => {

  2.  console.log('timer1')

  3.  Promise.resolve().then(function () {

  4.    console.log('promise1')

  5.  })

  6. })

  7. setTimeout(() => {

  8.  console.log('timer2')

  9.  Promise.resolve().then(function () {

  10.    console.log('promise2')

  11.  })

  12. }, 0)

  13. //Node输出:

  14. timer1               timer2

  15. promise1    或者     promise2

  16. timer2               timer1

  17. promise2             promise1

按理说 setTimeout(fn,0)应该比 setImmediate(fn)快,应该只有第二种结果,为什么会出现两种结果呢?

这是因为Node 做不到0毫秒,最少也需要1毫秒。实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数。

另外,如果已经过了Timer阶段,那么setImmediate会比setTimeout更快,例如:

  
  
  1. const fs = require('fs');

  2. fs.readFile('test.js', () => {

  3.  setTimeout(() => console.log(1));

  4.  setImmediate(() => console.log(2));

  5. });

上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate才会早于setTimeout执行。

具体可以看《Node 定时器详解》。

5.2 不同异步任务执行的快慢
  
  
  1. setTimeout(() => console.log(1));

  2. setImmediate(() => console.log(2));

  3. Promise.resolve().then(() => console.log(3));

  4. process.nextTick(() => console.log(4));

  5. //输出结果:


  6. 4 3 1 2或者4 3 2 1

因为我们上文说过microTask会优于macroTask运行,所以先输出下面两个,而在Node中process.nextTick比Promise更加优先3,所以4在3前。而根据我们之前所说的Node没有绝对意义上的0ms,所以1,2的顺序不固定。

5.3 MicroTask队列与MacroTask队列
  
  
  1.   setTimeout(function () {

  2.       console.log(1);

  3.   },0);

  4.   console.log(2);

  5.   process.nextTick(() => {

  6.       console.log(3);

  7.   });

  8.   new Promise(function (resolve, rejected) {

  9.       console.log(4);

  10.       resolve()

  11.   }).then(res=>{

  12.       console.log(5);

  13.   })

  14.   setImmediate(function () {

  15.       console.log(6)

  16.   })

  17.   console.log('end');

  18. //Node输出:

  19. 2 4 end 3 5 1 6

这个例子来源于《JavaScript中的执行机制》。Promise的代码是同步代码,then和catch才是异步的,所以4要同步输出,然后Promise的then位于microTask中,优于其他位于macroTask队列中的任务,所以5会优于1,6输出,而Timer优于Check阶段,所以1,6。

6. 总结

综上,关于最关键的顺序,我们要依据以下几条规则:

  1. 同一个上下文下,MicroTask会比MacroTask先运行

  2. 然后浏览器按照一个MacroTask任务,所有MicroTask的顺序运行,Node按照六个阶段的顺序运行,并在每个阶段后面都会运行MicroTask队列

  3. 同个MicroTask队列下 process.tick()会优于 Promise

Event loop还是比较深奥的,深入进去会有很多有意思的东西,有任何问题还望不吝指出。

参考文档
  1. 什么是浏览器的事件循环(Event Loop)》

  2. 不要混淆nodejs和浏览器中的event loop》

  3. Node 定时器详解》

  4. 浏览器和Node不同的事件循环(Event Loop)》

  5. 深入理解js事件循环机制(Node.js篇)》

  6. JavaScript中的执行机制》



觉得本文对你有帮助?请分享给更多人

关注「前端大全」,提升前端技能

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值