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

浏览器中的Event Loops

根据2017年新版的HTML规范HTML Standard,浏览器包含2类事件循环:browsing contexts 和 web workers。

browsing contexts中有一个或多个Task Queue,即MacroTask Queue,仅有一个Job Queue,即MicroTask Queue。

  • macrotask queue(宏任务,不妨称为A

    • setTimeout
    • setInterval
    • setImmediate(node独有
    • requestAnimationFrame
    • I/O
    • UI rendering
  • microtask queue(微任务,不妨称为I

    • process.nextTick(node独有
    • Promises
    • Object.observe(废弃)
    • MutationObserver

这两个任务队列执行顺序:

  • 取1个A中的task,执行之。
  • 把所有I顺序执行完,再取A中的下一个任务。

clipboard.png

为什么promise.then的回调比setTimeout先执行
代码开始执行时,所有这些代码在A中,形成一个执行栈(execution context stack),取出来执行之。
遇到setTimeout,则加到A中,遇到promise.then,则加到I中。
等整个执行栈执行完,取I中的任务。

(function test() {
    setTimeout(function() {console.log(4)}, 0);
    new Promise(function executor(resolve) {
        console.log(1);
        for( var i=0 ; i<10000 ; i++ ) {
            i == 9999 && resolve();
        }
        console.log(2);
    }).then(function() {
        console.log(5);
    });
    console.log(3);
})()
// 1
// 2
// 3
// 5
// 4
//浏览器渲染步骤:Structure(构建 DOM) ->Layout(排版)->Paint(绘制) 
//新的异步任务将在下一次被执行,因此就不会存在阻塞。
button.addEventListener('click', () => {
  setTimeout(fn, 0)
})

V8源码
https://github.com/v8/v8/blob...
https://github.com/v8/v8/blob...


NodeJS中的Event Loop

而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

clipboard.png

node新加了一个微任务process.nextTick和一个宏任务setImmediate.

process.nextTick

在当前"执行栈"的尾部(下一次Event Loop之前)触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED

setImmediate

setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。

setImmediate(function A() {
  console.log(1);
  setImmediate(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0);
//不确定

递归的调用process.nextTick()会导致I/O starving,官方推荐使用setImmediate()

process.nextTick(function foo() {
  process.nextTick(foo);
});
//FATAL ERROR: invalid table size Allocation failed - JavaScript heap out of memory

process.nextTick也会放入microtask quque,为什么优先级比promise.then高呢
在Node中,_tickCallback在每一次执行完TaskQueue中的一个任务后被调用,而这个_tickCallback中实质上干了两件事:

  1. nextTickQueue中所有任务执行掉(长度最大1e4,Node版本v6.9.1)
  2. 第一步执行完后执行_runMicrotasks函数,执行microtask中的部分(promise.then注册的回调)所以很明显process.nextTick > promise.then”

clipboard.png
node.js的特点是事件驱动,非阻塞单线程。当应用程序需要I/O操作的时候,线程并不会阻塞,而是把I/O操作交给底层库(LIBUV)。此时node线程会去处理其他任务,当底层库处理完I/O操作后,会将主动权交还给Node线程,所以Event Loop的用处是调度线程,例如:当底层库处理I/O操作后调度Node线程处理后续工作,所以虽然node是单线程,但是底层库处理操作依然是多线程。

根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示
clipboard.png

timers :这个阶段执行timer(setTimeout、setInterval)的回调
I/O callbacks:执行一些系统调用错误,比如网络通信的错误回调
idle, prepare :仅node内部使用
poll :获取新的I/O事件, 适当的条件下node将阻塞在这里
check :执行 setImmediate() 的回调
close callbacks :执行 socket 的 close 事件回调

timers阶段

timers 是事件循环的第一个阶段,Node 会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node 并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。

I/O callbacks 阶段

这个阶段主要执行一些系统操作带来的回调函数,如 TCP 错误,如果 TCP 尝试链接时出现 ECONNREFUSED 错误 ,一些 *nix 会把这个错误报告给 Node.js。而这个错误报告会先进入队列中,然后在 I/O callbacks 阶段执行。

poll 阶段

poll 阶段主要有2个功能:

  • 处理 poll 队列的事件
  • 当有已超时的 timer,执行它的回调函数
    even loop将同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来even loop会去检查有无预设的setImmediate(),分两种情况:
  1. 若有预设的setImmediate(), event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列
  2. 若没有预设的setImmediate(),event loop将阻塞在该阶段等待
    注意一个细节,没有setImmediate()会导致event loop阻塞在poll阶段,这样之前设置的timer岂不是执行不了了?所以咧,在poll阶段event loop会有一个检查机制,检查timer队列是否为空,如果timer队列非空,event loop就开始下一轮事件循环,即重新进入到timer阶段。

check 阶段

setImmediate()的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。

close 阶段

突然结束的事件的回调函数会在这里触发,如果 socket.destroy(),那么 close 会被触发在这个阶段,也有可能通过 process.nextTick() 来触发。

示例

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
promise1
timer2
promise2
*/
/*node中
timer1
timer2
promise1
promise2
*/
const fs = require('fs')

fs.readFile('test.txt', () => {
  console.log('readFile')
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})
/*
readFile
immediate
timeout
*/

更多示例
libuv源码
https://github.com/libuv/libu...

其他

requestAnimationFrame

HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()的效果要好于setTimeout()

客户端可能实现了一个包含鼠标键盘事件的任务队列,还有其他的任务队列,而给鼠标键盘事件的任务队列更高优先级,例如75%的可能性执行它。这样就能保证流畅的交互性,而且别的任务也能执行到了。但是,同一个任务队列中的任务必须按先进先出的顺序执行。

用户点击与button.click()的区别:
用户点击:依次执行listener。浏览器并不实现知道有几个 listener,因此它发现一个执行一个,执行完了再看后面还有没有。
click:同步执行listener。 click方法会先采集有哪些 listener,再依次触发。
示例详情

参考资料
Promise的队列与setTimeout的队列有何关联?
浏览器的 Event Loop
Event Loops
深入理解js事件循环机制(Node.js篇)
JavaScript 运行机制详解:再谈Event Loop
Node.js 事件循环,定时器和 process.nextTick()


seasonley
607 声望693 粉丝

一切皆数据