深入详解JS事件循环机制(Event loop)

Javascript的Event Loop

我们都知道JavaScript是单线程,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

在JavaScript中,异步任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务(MicroTask)

  • Task(宏任务)
    script全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setImmediate )、I/O、UI Rendering。
  • MicroTask(微任务)
    Process.nextTick(Node独有)、Promise.then()、Object.observe(废弃)、MutationObserver(具体使用方式查看这里http://javascript.ruanyifeng.com/dom/mutationobserver.html )。
    在微任务中 process.nextTick 优先级高于Promise

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。
深入详解JS事件循环机制(Event loop)_第1张图片
queue.jpg

到这问题就来了,当异步任务进入栈执行时,是宏任务还是微任务呢?

  • 由于执行代码入口都是全局任务 script,而全局任务属于宏任务,所以当栈为空,同步任务任务执行完毕时,会先执行微任务队列里的任务。
  • 微任务队列里的任务全部执行完毕后,会读取下一轮宏任务队列中排最前的任务。
  • 执行宏任务的过程中,遇到微任务,依次加入微任务队列,微任务在本轮宏人任务执行完后依次执行
  • 栈空后,再次读取微任务队列里的任务,依次类推。
深入详解JS事件循环机制(Event loop)_第2张图片
task.png
  • 如果执行中遇到 setTimeout 之类宏任务,则新增一个宏任务队列,把这个 setTimeout 内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。任务队列中的宏任务可以有多个来源。
  • 如果执行中遇到 promise.then() 之类的微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码执行都完成后,依次执行所有的微任务1、2、3。

这里有几个经典的题目帮助大家深入理解一下异步任务的执行机制:

题目一:

const promise = new Promise((resolve, reject) => {
    console.log(1);
    resolve();
    console.log(2);
})

promise.then(() => {
    console.log(3);
})

console.log(4);
//输出结果:
1
2
4
3

题目二:

Promise.resolve(1)
  .then(2)
  .then(Promise.resolve(3))
  .then(console.log)
//输出结果:1

解析:
Promise.resolve 方法的参数如果是一个原始值,或者是一个不具有 then 方法的对象,则 Promise.resolve 方法返回一个新的 Promise 对象,状态为resolved,Promise.resolve 方法的参数,会同时传给回调函数。 then 方法接受的参数是函数,而如果传递的并非是一个函数,它实际上会将其解释为 then(null),这就会导致前一个 Promise 的结果会穿透下面。

题目三:

setTimeout(function(){
    console.log(1);   
    Promise.resolve().then(function(){
        console.log(2);
    });
}, 0);
setTimeout(function (){
    console.log(3);
},0);
Promise.resolve().then(function (){
    console.log(4);
});
console.log(5);
// 输出结果:5、4、1、2、3

题目四:

async function async1() {
    console.log( 'async1 start')
    await async2()
    console.log( 'async1 end')
}

async function async2() {
    console.log( 'async2')
}

console.log( 'script start')

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

async1();
    
new Promise( function( resolve ) {
    console.log( 'promise1')
    resolve();
} ).then( function() {
    console.log( 'promise2')
 } )

console.log( 'script end')
//输出结果:
script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout

Node.js的Event Loop

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。


深入详解JS事件循环机制(Event loop)_第3张图片
nodejs.png

根据上图,Node.js的运行机制如下:

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

除了setTimeout和setInterval这两个方法,Node.js还提供了另外两个与"任务队列"有关的方法:process.nextTick和setImmediate。它们可以帮助我们加深对"任务队列"的理解。

process.nextTick方法可以在当前"执行栈"的尾部----下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。请看下面的例子(via StackOverflow)。

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

上面代码中,由于process.nextTick方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行。

现在,再看setImmediate。

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

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

上面代码中,setImmediate与setTimeout(fn,0)各自添加了一个回调函数A和timeout,都是在下一次Event Loop触发。那么,哪个回调函数先执行呢?答案是不确定。运行结果可能是1--TIMEOUT FIRED--2,也可能是TIMEOUT FIRED--1--2。

令人困惑的是,Node.js文档中称,setImmediate指定的回调函数,总是排在setTimeout前面。实际上,这种情况只发生在递归调用的时候。

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

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

上面代码中,setImmediate和setTimeout被封装在一个setImmediate里面,它的运行结果总是1--TIMEOUT FIRED--2,这时函数A一定在timeout前面触发。至于2排在TIMEOUT FIRED的后面(即函数B在timeout后面触发),是因为setImmediate总是将事件注册到下一轮Event Loop,所以函数A和timeout是在同一轮Loop执行,而函数B在下一轮Loop执行。

我们由此得到了process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前"执行栈"一次执行完,多个setImmediate可能则需要多次loop才能执行完。事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取"事件队列"!

process.nextTick(function foo() {
    process.nextTick(foo);
});

事实上,现在要是你写出上面递归的process.nextTick,Node.js会抛出一个警告,要求你改成setImmediate。

另外,由于process.nextTick指定的回调函数是在本次"事件循环"触发,而setImmediate指定的是在下次"事件循环"触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查"任务队列")。

你可能感兴趣的:(深入详解JS事件循环机制(Event loop))