Event Loop 的规范和实现

发布于 2024-10-31 20:58:48 字数 12481 浏览 3 评论 0

本文所有代码运行环境仅包含 Node v8.9.4 以及 Chrome v63

PART 1:规范

为什么要有 Event Loop?

因为 Javascript 设计之初就是一门单线程语言,因此为了实现主线程的不阻塞, Event Loop 这样的方案应运而生。

小测试(1)

先来看一段代码,打印结果会是?

console.log(1)

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

Promise.resolve().then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

console.log(5)

不熟悉 Event Loop 的我尝试进行如下分析:

  1. 首先,我们先排除异步代码,先把同步执行的代码找出,可以知道先打印的一定是 1、5
  2. 但是,setTimeout 和 Promise 是否有优先级?还是看执行顺序?
  3. 还有,Promise 的多级 then 之间是否会插入 setTimeout?

带着困惑,我试着运行了一下代码,正确结果是: 1、5、3、4、2

那这到底是为什么呢?

定义

看来需要先从规范定义入手,于是查阅一下 HTML 规范 ,规范着实详(luo)细(suo),我就不贴了,提炼下来关键步骤如下:

  1. 执行最旧的 task(一次)
  2. 检查是否存在 microtask,然后不停执行,直到清空队列(多次)
  3. 执行 render

好家伙,问题还没搞明白,一下子又多出来 2 个概念 taskmicrotask ,让懵逼的我更加凌乱了。。。

不慌不慌,通过仔细阅读文档得知,这两个概念属于对异步任务的分类,不同的 API 注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行。

task 主要包含: setTimeoutsetIntervalsetImmediateI/OUI 交互事件

microtask 主要包含: Promiseprocess.nextTickMutaionObserver

整个最基本的 Event Loop 如图所示:

  • queue 可以看做一种数据结构,用以存储需要执行的函数
  • timer 类型的 API(setTimeout/setInterval)注册的函数,等到期后进入 task 队列(这里不详细展开 timer 的运行机制)
  • 其余 API 注册函数直接进入自身对应的 task/microtask 队列
  • Event Loop 执行一次,从 task 队列中拉出一个 task 执行
  • Event Loop 继续检查 microtask 队列是否为空,依次执行直至清空队列

继续测试(2)

这时候,回头再看下之前的 测试(1) ,发现概念非常清晰,一下子就得出了正确答案,感觉自己萌萌哒,再也不怕 Event Loop 了~

接着,准备挑战一下更高难度的问题(本题出自 中提到的那篇文章,我先去除了 process.nextTick ):

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

setTimeout(() => {
    console.log(9)
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})

分析如下:

  1. 同步运行的代码首先输出: 1、7
  2. 接着,清空 microtask 队列: 8
  3. 第一个 task 执行: 2、4
  4. 接着,清空 microtask 队列: 5
  5. 第二个 task 执行: 9、11
  6. 接着,清空 microtask 队列: 12

chrome 下运行一下,全对!

自信的我膨胀了,准备加上 process.nextTick 后在 node 上继续测试。我先测试第一个 task,代码如下:

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    process.nextTick(() => {
        console.log(3)
    })
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

process.nextTick(() => {
    console.log(6)
})

有了之前的积累,我这回自信的写下了答案: 1、7、8、6、2、4、5、3

然而,帅不过 3 秒,正确答案是: 1、7、6、8、2、4、3、5

我陷入了困惑,不过很快明白了,这说明** process.nextTick 注册的函数优先级高于 Promise **,这样就全说的通了~

接着,我再测试第二个 task:

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    process.nextTick(() => {
        console.log(3)
    })
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

process.nextTick(() => {
    console.log(6)
})

setTimeout(() => {
    console.log(9)
    process.nextTick(() => {
        console.log(10)
    })
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})

吃一堑长一智,这次我掌握了 microtask 的优先级,所以答案应该是:

  • 第一个 task 输出: 1、7、6、8、2、4、3、5
  • 然后,第二个 task 输出: 9、11、10、12

然而,啪啪打脸。。。

我第一次执行,输出结果是: 1、7、6、8、2、4、9、11、3、10、5、12 (即两次 task 的执行混合在一起了)。我继续执行,有时候又会输出我预期的答案。

现实真的是如此莫名啊!啊!啊!

啊,不好意思,血一时止不住 所以,这到底是为什么???

PART 2:实现

俗话说得好:规范是人定的,代码是人写的。 ——无名氏

规范无法囊括所有场景,虽然 chromenode 都基于 v8 引擎,但引擎只负责管理内存堆栈,API 还是由各 runtime 自行设计并实现的。

小测试(3)

Timer 是整个 Event Loop 中非常重要的一环,我们先从 timer 切入,来切身体会下规范和实现的差异。

首先再来一个小测试,它的输出会是什么呢?

setTimeout(() => {
    console.log(2)
}, 2)

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

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

没有深入接触过 timer 的同学如果直接从代码中的延时设置来看,会回答: 0、1、2

而另一些有一定经验的同学可能会回答: 2、1、0 。因为 MDN 的 setTimeout 文档 中提到 HTML 规范最低延时为 4ms:

(补充说明:最低延时的设置是为了给 CPU 留下休息时间)

In fact, 4ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.

而真正痛过的同学会告诉你,答案是: 1、0、2 。并且,无论是 chrome 还是 node 下的运行结果都是一致的。

(错误订正:经多次验证,node 下的输出顺序依然是无法保证的,node 的 timer 真是一门玄学~)

Chrome 中的 timer

测试(3) 结果可以看出,0ms 和 1ms 的延时效果是一致的,那背后的原因是为什么呢?我们先查查 blink 的实现。

(Blink 代码托管的地方我都不知道如何进行搜索,还好文件名比较明显,没花太久,找到了答案)

(我直接贴出最底层代码,上层代码如有兴趣请自行查阅)

// https://chromium.googlesource.com/chromium/blink/+/master/Source/core/frame/DOMTimer.cpp#93

double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);

这里 interval 就是传入的数值,可以看出传入 0 和传入 1 结果都是 oneMillisecond,即 1ms。

这样解释了为何 1ms 和 0ms 行为是一致的,那 4ms 到底是怎么回事?我再次确认了 HTML 规范 ,发现虽然有 4ms 的限制,但是是存在条件的,详见规范第 11 点:

If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

并且有意思的是, MDN 英文文档 的说明也已经贴合了这个规范。

我斗胆推测,一开始 HTML5 规范确实有定最低 4ms 的规范,不过在后续修订中进行了修改,我认为甚至不排除规范在向实现看齐,即逆向影响。

Node 中的 timer

node 中,为什么 0ms 和 1ms 的延时效果一致呢?

(还是 github 托管代码看起来方便,直接搜到目标代码)

// https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456

if (!(after >= 1 && after <= TIMEOUT_MAX))
  after = 1; // schedule on next tick, follows browser behavior

代码中的注释直接说明了,设置最低 1ms 的行为是为了向浏览器行为看齐。

Node 中的 Event Loop

上文的 timer 算一个小插曲,我们现在回归本文核心—— Event Loop

让我们聚焦在 node 的实现上, blink 的实现本文不做展开,主要是因为:

  • chrome 行为目前看来和规范一致
  • 可参考的文档不多
  • 不会搜索,根本不知道核心代码从何找起。

略过所有研究过程。

补充说明:

  • NodeEvent Loop 分阶段,阶段有先后,依次是
    • expired timers and intervals ,即到期的 setTimeout/setInterval
    • I/O events ,包含文件,网络等等
    • immediates ,通过 setImmediate 注册的函数
    • close handlers ,close 事件的回调,比如 TCP 连接断开
  • 同步任务及每个阶段之后都会清空 microtask 队列
    • 优先清空 next tick queue ,即通过 process.nextTick 注册的函数
    • 再清空 other queue ,常见的如 Promise
  • 而和规范的区别,在于 node 会清空当前所处阶段的队列,即执行所有 task

重新挑战测试(2)

了解了实现,再回头看 测试(2)

// 代码简略表示
// 1
setTimeout(() => {
    // ...
})

// 2
setTimeout(() => {
    // ...
})

可以看出由于两个 setTimeout 延时相同,被合并入了同一个 expired timers queue ,而一起执行了。所以,只要将第二个 setTimeout 的延时改成超过 2ms(1ms 无效,详见上文),就可以保证这两个 setTimeout 不会同时过期,也能够保证输出结果的一致性。

那如果我把其中一个 setTimeout 改为 setImmediate ,是否也可以做到保证输出顺序?

答案是 不能 。虽然可以保证 setTimeoutsetImmediate 的回调不会混在一起执行,但无法保证的是 setTimeoutsetImmediate 的回调的执行顺序。

node 下,看一个最简单的例子,下面代码的输出结果是无法保证的:

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

setImmediate(() => {
    console.log(1)
})

// or
setImmediate(() => {
    console.log(0)
})

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

问题的关键在于 setTimeout 何时到期,只有到期的 setTimeout 才能保证在 setImmediate 之前执行。

不过如果是这样的 例子(2) ,虽然基本能保证输出的一致性,不过强烈不推荐:

// 先使用 setTimeout 注册
setTimeout(() => {
    // ...
})

// 一系列 micro tasks 执行,保证 setTimeout 顺利到期
new Promise(resolve => {
    // ...
})
process.nextTick(() => {
    // ...
})

// 再使用 setImmediate 注册,“几乎”确保后执行
setImmediate(() => {
    // ...
})

或者换种思路来保证顺序:

const fs = require('fs')

fs.readFile('/path/to/file', () => {
    setTimeout(() => {
        console.log('timeout')
    })
    setImmediate(() => {
        console.log('immediate')
    })
})

那,为何这样的代码能保证 setImmediate 的回调优先于 setTimeout 的回调执行呢?

因为当两个回调同时注册成功后,当前 nodeEvent Loop 正处于 I/O queue 阶段,而下一个阶段是 immediates queue ,所以能够保证即使 setTimeout 已经到期,也会在 setImmediate 的回调之后执行。

PART 3:应用

由于也是刚刚学习 Event Loop ,无论是依托于规范还是实现,我能想到的应用场景还比较少。那掌握 Event Loop ,我们能用在哪些地方呢?

查 Bug

正常情况下,我们不会碰到非常复杂的队列场景。不过万一碰到了,比如执行顺序无法保证的情况时,我们可以快速定位到问题。

面试

那什么时候会有复杂的队列场景呢?比如面试,保不准会有这种稀奇古怪的测试,这样就能轻松应付了~

执行优先级

说回正经的,如果从规范来看,microtask 优先于 task 执行。那如果有需要优先执行的逻辑,放入 microtask 队列会比 task 更早的被执行,这个特性可以被用于在框架中设计任务调度机制。

如果从 node 的实现来看,如果时机合适,microtask 的执行甚至可以阻塞 I/O,是一把双刃剑。

综上,高优先级的代码可以用 Promise / process.nextTick 注册执行。

执行效率

node 的实现来看, setTimeout 这种 timer 类型的 API ,需要创建定时器对象和迭代等操作,任务的处理需要操作小根堆,时间复杂度为 O(log(n)) 。而相对的, process.nextTicksetImmediate 时间复杂度为 O(1) ,效率更高。

如果对执行效率有要求,优先使用 process.nextTicksetImmediate

参考

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

沧桑㈠

暂无简介

0 文章
0 评论
24 人气
更多

推荐作者

謌踐踏愛綪

文章 0 评论 0

开始看清了

文章 0 评论 0

高速公鹿

文章 0 评论 0

alipaysp_PLnULTzf66

文章 0 评论 0

热情消退

文章 0 评论 0

白色月光

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文