上次我们分析过嵌套的process.nextTick的问题,会导致死循环,今天我们来看一下嵌套的setTimeout问题。分析之前我们先简单了解一下nodejs中定时器的架构。
1 相对超时时间一样的定时器放在同一个队列,比如刚开始时执行setTimeout(cb1, 5000)和过2秒后执行setTimeout(cb2, 5000);他们会在同一个队列中。即上图的List。
2 同一队列中,尾节点比头结点先到期。即cb1对应上图的节点1,cb2对应节点2。
3 同一队列中,每个节点记录了该定时节点的开始时间和相对超时时间,比如{开始时间:1,相对过期时间:5}和{开始时间:3,相对过期时间:5}。
4 每个List对象记录了当前队列最快到期的节点的绝对时间。即1+5=6。
了解了nodejs中定时器的大致实现后,我们开始看问题。假设我们有以下一段代码
function main() {
setTimeout(() => {
main();
}, 1)
}
main();
setTimeout(() => {
console.log(2)
},2);
以上代码的2会输出吗?我们开始分析这个问题,假设我们开始时间是0秒。那么以上代码执行完后有下图。
当1ms过去的时候,第一个队列先过期,这时候就会遍历队列1的节点执行对应的回调。根据代码这时候,会执行队列1的节点1。因为节点1的回调里又插入了一个新的节点。所以有下图。
当执行完节点的回调后,该节点就会被删除。有下图。
因为第一个队列非空,nodejs会继续遍历该队列,这时候有两种情况。执行队列1的节点1时消耗了1ms,那么这时候节点2也超时,如果消耗小于1ms,则节点2还没有超时。我们只讨论消耗了1ms的情况(因为如果消耗小于1ms,就会停止遍历队列,自然不会导致死循环)。我们把代码改一下
function main() {
setTimeout(() => {
main();
// 做一些消耗cpu的操作。
}, 1)
}
main();
setTimeout(() => {
console.log(2)
},2);
我们在插入新节点之后,遍历下一个节点之前,做一些耗cpu的操作,保证遍历下一个节点时,他已经超时。但是执行以上代码,发现,2仍然会输出。即不会一直在遍历队列1导致死循环。为什么呢?看过《嵌套的process.nextTick问题》一文的同学可能想到,插入的队列和遍历的队列不是同一个。我们翻开nodejs的源码。看看到底是怎么回事。我们看一下nodejs是如何处理定时器的。
// now为执行procesTime
function processTimers(now) {
nextExpiry = Infinity;
let list;
// 取出优先队列的根节点,即最快到期的节点
while (list = timerListQueue.peek()) {
// 最快到期的节点还没到期则,则全部节点都没有到期,不需要遍历了
if (list.expiry > now) {
nextExpiry = list.expiry;
// 返回下一次过期的时间
return refCount > 0 ? nextExpiry : -nextExpiry;
}
// 遍历队列的定时器
listOnTimeout(list, now);
}
return 0;
}
processTimers首先从优先队列中取出最快到期的节点所在队列。然后遍历该队列,假设这时候已经到期,nodejs会执行listOnTimeout遍历队列。我们看看listOnTimeout。
function listOnTimeout(list, now) {
// 队列对应的相对过期时间,比如文中的1和2
const msecs = list.msecs;
let timer;
// 遍历具有统一相对过期时间的队列
while (timer = L.peek(list)) {
// _idleStart为节点开始的时间,diff是从节点被插入开始已经过去的时间
const diff = now - timer._idleStart;
// 过期的时间比超时时间小,还没过期
if (diff < msecs) {
// 整个链表节点的最快过期时间等于当前还没过期节点的值,链表是有序的
// 如果相差小于1ms过期,则取1ms后过期,比如setTimeout(cb,1.5)
list.expiry = MathMax(timer._idleStart + msecs, now + 1);
// 更新id,用于决定在优先队列里的位置
list.id = timerListId++;
// 调整过期时间后,当前链表对应的节点不一定是优先队列里的根节点了,可能有他更快到期,即当前链表需要往下沉
timerListQueue.percolateDown(1);
return;
}
// 准备执行用户设置的回调,删除这个节点
L.remove(timer);
// 执行回调
}
}
listOnTimeout遍历队列中的每一个节点,如果超时则先从队列中移除,然后执行回调。如果没有超时则修改该队列最快到期的时间为该节点的绝对超时时间。重点在于diff < msecs的判断和now。我们nodejs在处理定时器的过程中,now的值是不会变的,他的值是开始处理定时器任务时的时间点。所以如果我们在setTimeout中又插入一个新的节点时,他的开始时间会比now还大。这导致了const diff = now - timer._idleStart的值为负数。从而if (diff < msecs) 成立。所以就不会遍历后面的节点了。从而不会形成死循环。我们看到在setTimeout里即使插入了新节点,并且新节点也过期了,也不会在本轮事件循环执行,因为nodejs在处理定时器阶段的时候,不会实时更新now的值,从而导致他只会执行开启时间在now之前的定时器。在执行期间插入的节点无论是否已经过期。最快也只会在下一轮事件循环执行。