愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

彻底理解JavaScript中的Event Loop

一、前言

Javascript从诞生之日起,就一直是一门单线程的非阻塞的语言。即便后面出现了WebWorker,但是其本质上还是主线程的子线程,帮助主线程分担一部分计算任务,并非真正意义上的多线程。由于浏览器环境Node环境在处理Event Loop上大致相同,但又有所区别,所以本文将区分开讲解


二、浏览器环境中的事件循环

1、事件循环

在浏览器中,事件循环过程可如下图所示:

在JavaScript脚本的执行过程中,遇到异步操作时:

  1. 主线程不会挂起等待异步操作执行完毕,而是将其挂起,然后继续执行之后的任务
  2. 当异步事件完成后,会将该事件加入到事件队列
  3. 当主线程空闲时,会去查看事件队列,队列不为空时,主线程会逐个取出放入执行栈中执行

2、微任务(microtask)与宏任务(macro task)

异步任务之间并不是完全等同的,它们存在一个执行优先级。按照执行优先级,可区分为两类任务:微任务宏任务,即:

  • 微任务:promiseObject.observeMutationObserver
  • 宏任务:scriptsetTimeoutsetIntervalI/OUI rendering

在一次事件循环中,异步事件返回的结果会被放入到一个任务队列中,但是根据异步事件的类型,需要把事件放入到对应的微任务队列宏任务队列中。
当主线程空闲时(执行栈为空),主线程会先查看微任务队列,执行清空后再查看宏任务队列,并执行清空,如此反复循环
总结而言,浏览器中事件循环就一句话:当前执行栈执行完成时,立即优先处理微任务,再去处理宏任务,同一次事件循环中,微任务先于宏任务执行,例子如下:

setTimeout(() => console.log('setTimeout'))
new Promise((resolve) => {
    console.log('Promise')
    resolve()
}).then(() => {
    console.log('Then')
})
/*
输出:
Promise
Then
setTimeout
*/

例子2:

setTimeout(() => console.log(1), 0)
new Promise((resolve, reject) => {
    console.log(2)
    for (let i = 0; i < 1e4; i++) {
        i === 9999 && resolve()
    }
    console.log(3)
}).then(() => {
    console.log(4)
})
console.log(5)
/*
输出:2 3 5 4 1
*/


三、Node中的事件循环

相比浏览器中的事件循环,Node中的事件循环要复杂一些。在Node中,V8解析后的代码会通过Libuv执行,所以要理解Node中的事件循环,则就需要先理解Node的事件循环模型,如下:

不过,在外部输入数据到来时,是从poll阶段开始的,即:

外部数据 -> poll -> check -> close callbacks -> timer -> I/O callbacks
-> idle,prepare -> poll ...

而这些阶段的功能大致如下:

  • timer:执行定时器队列中的回调(如setTimeoutsetInterval
  • I/O callbacks:执行除了close事件、定时器、setImmediate之外的所有的回调
  • idel,prepare:仅内部使用,无需理会
  • poll:等待新的I/O事件,在一些特殊情况下,会阻塞在这里
  • check:专门执行setImmediate中的回调
  • close callbacks:执行close事件的回调,如socket.on('close', ...)

各阶段的详细介绍如下:

1、各个阶段详解

timer阶段
这个阶段会以先进先出的顺序执行所有到期后加入到timer queue里的回调(这些回调是通过setTimeout或者setInterval设置的)

I/O callback阶段
这个阶段主要执行大部分的I/O事件回调,包括为操作系统执行的一些回调(如TCP连接出错时,通过callback拿到错误信息)

poll阶段
V8引擎将JS代码解析后传入libuv,循环首先进入poll阶段,此后:

  • 检查poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调
  • poll queue为空时,检查是否有setImmediate的callback,有则进入check阶段执行setImmediate的回调
  • 检查是否有到期的timer,如果有,按timer的到期顺序放到timer queue中,此后进入到timer阶段时,执行timer queue中的回调
  • 如果setImmediatetimer的队列都是空的,则事件循环停留在poll阶段,直到有I/O事件返回,事件循环才立即进入I/O callbacks阶段,并且立即执行回调

check阶段
本阶段专门用来执行setImmediate()的回调,当poll进入空闲状态,且setImmediate queue不为空时,事件循环会进入该阶段

close阶段
当一个socket连接或一个handle被突然关闭时(如调用了socket.destroy()),close事件会被发送到这个阶段执行回调,否则事件会用process.nextTick()方法发送出去

2、process.nextTick

虽然没有专门一个阶段来执行nextTick,但是存在一个nextTick queue,在事件循环 准备进入下一个阶段 前会先检查nextTick queue是否为空,不为空则需要等执行清空。
所以,如果错误地使用process.nextTick,会导致node进入死循环

3、例子讲解

例子1:

setTimeout(() => console.log(1), 0)
setImmediate(() => console.log(2))
// 输出:不确定,可能是1 2,也可能是2 1

主要看执行时环境:

  • 定时器的时间取值范围为[1, (2^31)-1],所以setTimeout(fn, 0)等同于setTimeout(fn, 1)
  • 如果timer阶段前的准备时间大于1ms,则setTimeout中的回调便能够进入timer queue,从而得以执行。之后,进入poll阶段,setImmediate的回调进入setImmediate queue,并在check阶段得以执行,故最终输出:1 2
  • 如果timer阶段前的准备时间小于1ms,则初始timer阶段timer queue为空。之后,进入poll阶段,setTimeout和setImmediate各自的回调进入各自的队列,之后check阶段执行setImmediate,timer阶段执行setTimeout,故输出:2 1

例子2:

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});
// 输出:immediate timeout

解析:

  • 首先,fs.readFile里的回调属于I/O回调,在poll阶段时,由于timer queueimmediate queue都为空,所以在读取文件的操作完成后,进入到I/O callbacks阶段,执行I/O回调
  • I/O回调执行完成后,setImmediatesetTimeout先后加入到了timer queueimmediate queue里,之后先进入check阶段,后进入timer阶段,故输出:immediate timeout

例子3:

setInterval(() => {
  console.log('setInterval')
}, 100)

process.nextTick(function tick () {
  process.nextTick(tick)
})

// 死循环

解析:

  • process.nextTick会在每个阶段之后执行,并且队列不清空就不会进入下一个阶段
  • setInterval的执行时间为100ms,所以一开始的timer阶段中,timer queue为空,进入process.nextTick的清空过程
  • 由于process.nextTick执行后不断地往nextTick queue加入回调,故永远无法清空,永远无法进入下一个阶段

例子4:

setInterval(() => {
  console.log('setInterval')
}, 100)

setImmediate(function immediate () {
  setImmediate(immediate)
})
// 每隔100ms输出一次setInterval

解析:在check阶段执行后,新设置的setImmediate回调会被放入到setImmediate queue中,而这个queue只能在下一次事件循环的check阶段进行清空,而在本次check阶段到下一次check阶段之间,timer阶段都会得以执行

例子5:

setImmediate((/* 回调1 */) => {
    console.log('setImmediate1')
    setImmediate(() => {
        console.log('setImmediate2')
    })
    process.nextTick(() => {
        console.log('nextTick')
    })
})
setImmediate((/* 回调2 */) => {
    console.log('setImmediate3')
})
/*
输出:
setImmediate1
setImmediate3
nextTick
setImmediate2
*/

解析:

  • 第一次执行后,setImmediate queue中会依次设置两个回调:[回调1, 回调2]
  • check阶段执行两个回调,首先执行回调1:
    • 首先,输出setImmediate1
    • 其次,执行setImmediate(() => { console.log('setImmediate2') }),设置到下一次事件循环的setImmediate queue
    • 最后,将() => console.log('nextTick')设置到nextTick queue
  • 接着,执行回调2,输出:setImmediate3
  • check阶段结束,执行nextTick queue的清空,输出:nextTick,此后进入下一个事件循环
  • 在新的事件循环的check阶段里,清空setImmediate queue,输出:setImmediate2

例子6:

const promise = Promise.resolve()
.then(() => {
    return promise
})
promise.catch(console.error)
// 死循环

解析:在每个阶段的末尾,会清空microtasks queue

例子7:

const promise = Promise.resolve()
promise.then(() => {
    console.log('promise')
})
process.nextTick(() => {
    console.log('nextTick')
})
// 输出:nextTick promise

解析:promise.then 虽然和 process.nextTick 一样,都将回调函数注册到 microtask,但 process.nextTick 的 microtask queue 总是优先于 promise 的 microtask queue 执行

例子8:

setTimeout(() => {
    console.log(1)
}, 0)

new Promise((resolve, reject) => {
    console.log(2)
    for (let i = 0; i < 10000; i++) {
        i === 9999 && resolve()
    }
    console.log(3)
}).then(() => {
    console.log(4)
})
console.log(5)
/*
输出:2 3 5 4 1
*/

例子9:

setImmediate((/* 回调1 */) => {
    console.log(1)
    setTimeout(() => {
        console.log(2)
    }, 100)
    setImmediate(() => {
        console.log(3)
    })
    process.nextTick(() => {
        console.log(4)
    })
})
process.nextTick((/* 回调2 */) => {
    console.log(5)
    setTimeout(() => {
        console.log(6)
    }, 100)
    setImmediate(() => {
        console.log(7)
    })
    process.nextTick(() => {
        console.log(8)
    })
})
console.log(9)

解析:

  • 首先输出9,然后清空nextTick queue,此时的nextTick为:回调2
  • 执行回调2:
    • 输出:5
    • 100ms后将() => console.log(6)加入timer queue
    • () => console.log(7)加入setImmediate queue
    • 清空nextTick queue,输出:8
  • 进入check阶段,此时setImmediate queue为:[回调1, () => console.log(7)]
  • 执行回调1:
    • 输出:1
    • 100ms后将() => console.log(2)加入timer queue
    • () => console.log(3)加入setImmediate queue
  • 执行() => console.log(7),输出:7
  • 清空nextTick queue,输出:4
  • 进入timer阶段,100ms未超时,进入下一阶段
  • 进入check阶段,此时setImmediate queue为:[() => console.log(3)],输出:3
  • 进入timer阶段,100ms到,此时timer queue为:[() => console.log(6), () => console.log(2)],依次清空,输出:6、2

所以最终输出:9 5 8 1 7 4 3 6 2


四、参考资料