一、前言
Javascript从诞生之日起,就一直是一门单线程的非阻塞的语言。即便后面出现了WebWorker,但是其本质上还是主线程的子线程,帮助主线程分担一部分计算任务,并非真正意义上的多线程。由于浏览器环境和Node环境在处理Event Loop上大致相同,但又有所区别,所以本文将区分开讲解
二、浏览器环境中的事件循环
1、事件循环
在浏览器中,事件循环过程可如下图所示:
在JavaScript脚本的执行过程中,遇到异步操作时:
主线程不会挂起等待异步操作执行完毕,而是将其挂起,然后继续执行之后的任务
当异步事件完成后,会将该事件加入到事件队列中
当主线程空闲时,会去查看事件队列,队列不为空时,主线程会逐个取出放入执行栈中执行
2、微任务(microtask)与宏任务(macro task)
异步任务之间并不是完全等同的,它们存在一个执行优先级。按照执行优先级,可区分为两类任务:微任务和宏任务,即:
微任务:promise、Object.observe、MutationObserver
宏任务:script、setTimeout、setInterval、I/O、UI 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:执行定时器队列中的回调(如setTimeout、setInterval)
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中的回调
如果setImmediate和timer的队列都是空的,则事件循环停留在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 queue和immediate queue都为空,所以在读取文件的操作完成后,进入到I/O callbacks阶段,执行I/O回调
I/O回调执行完成后,setImmediate和setTimeout先后加入到了timer queue和immediate 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
四、参考资料