网易首页 > 网易号 > 正文 申请入驻

JavaScript 异步编程指南 - 聊聊 Node.js 中的事件循环

0
分享至

作者: 五月君 来源:编程界

事件循环是一种控制应用程序的运行机制,在不同的运行时环境有不同的实现,上一节讲了浏览器中的事件循环,它们有很多相似的地方,也有着各自的特点,本节讨论下 Node.js 中的事件循环。

了解 Node.js 中的事件循环

Node.js 做为 JavaScript 的服务端运行时,主要与网络、文件打交道,没有了浏览器中事件循环的渲染阶段。

在浏览器中有 HTML 规范来定义事件循环的处理模型,之后由各浏览器厂商实现。Node.js 中事件循环的定义与实现均来自于 Libuv。

Libuv 围绕事件驱动的异步 I/O 模型而设计,最初是为 Node.js 编写的,提供了一个跨平台的支持库。下图展示了它的组成部分,Network I/O 是网络处理相关的部分,右侧还有文件操作、DNS,底部 epoll、kqueue、event ports、IOCP 这些是底层不同操作系统的实现。

图片来源:http://docs.libuv.org/en/v1.x/_images/architecture.png

事件循环的六个阶段

当 Node.js 启动时,它会初始化事件循环,处理提供的脚本,同步代码入栈直接执行,异步任务(网络请求、文件操作、定时器等)在调用 API 传递回调函数后会把操作转移到后台由系统内核处理。目前大多数内核都是多线程的,当其中一个操作完成时,内核通知 Node.js 将回调函数添加到轮询队列中等待时机执行。

下图左侧是 Node.js 官网对事件循环过程的描述,右侧是 Libuv 官网对 Node.js 的描述,都是对事件循环的介绍,不是所有人上来都能去看源码的,这两个文档通常也是对事件循环更直接的学习参考文档,在 Node.js 官网介绍的也还是挺详细的,可以做为一个参考资料学习。

左侧 Node.js 官网展示的事件循环分为 6 个阶段,每个阶段都有一个 FIFO(先进先出)队列执行回调函数,这几个阶段之间执行的优先级顺序还是明确的。

右侧更详细的描述了,在事件循环迭代前,先去判断循环是否处于活动状态(有等待的异步 I/O、定时器等),如果是活动状态开始迭代,否则循环将立即退出。

下面对每个阶段分别讨论。

timers(定时器阶段)

首先事件循环进入定时器阶段,该阶段包含两个 API setTimeout(cb, ms)、setInterval(cb, ms) 前一个是仅执行一次,后一个是重复执行。

这个阶段检查是否有到期的定时器函数,如果有则执行到期的定时器回调函数,和浏览器中的一样,定时器函数传入的延迟时间总比我们预期的要晚,它会受到操作系统或其它正在运行的回调函数的影响。

例如,下例我们设置了一个定时器函数,并预期在 1000 毫秒后执行。

  • const now = Date.now();
    setTimeout(function timer1(){
    log(`delay ${Date.now() - now} ms`);
    }, 1000);
    setTimeout(function timer2(){
    log(`delay ${Date.now() - now} ms`);
    }, 5000);
    someOperation();
    function someOperation() {
    // sync operation...
    while (Date.now() - now < 3000) {}

当调用 setTimeout 异步函数后,程序紧接着执行了 someOperation() 函数,中间有些耗时操作大约消耗 3000ms,当完成这些同步操作后,进入一次事件循环,首先检查定时器阶段是否有到期的任务,定时器的脚本是按照 delay 时间升序存储在堆内存中,首先取出超时时间最小的定时器函数做检查,如果 **nowTime - timerTaskRegisterTime > delay** 取出回调函数执行,否则继续检查,当检查到一个没有到期的定时器函数或达到系统依赖的最大数量限制后,转移到下一阶段。

在我们这个示例中,假设执行完 someOperation() 函数的当前时间为 T + 3000:

  • 检查 timer1 函数,当前时间为 T + 3000 - T > 1000,已超过预期的延迟时间,取出回调函数执行,继续检查。
  • 检查 timer2 函数,当前时间为 T + 3000 - T < 5000,还没达到预期的延迟时间,此时退出定时器阶段。
pending callbacks

定时器阶段完成后,事件循环进入到 pending callbacks 阶段,在这个阶段执行上一轮事件循环遗留的 I/O 回调。根据 Libuv 文档的描述:大多数情况下,在轮询 I/O 后立即调用所有 I/O 回调,但是,某些情况下,调用此类回调会推迟到下一次循环迭代。听完更像是上一个阶段的遗留。

idle, prepare

idle, prepare 阶段是给系统内部使用,idle 这个名字很迷惑,尽管叫空闲,但是在每次的事件循环中都会被调用,当它们处于活动状态时。这一块的资料介绍也不是很多。略...

poll

poll 是一个重要的阶段,这里有一个概念观察者,有文件 I/O 观察者,网络 I/O 观察者等,它会观察是否有新的请求进入,包含读取文件等待响应,等待新的 socket 请求,这个阶段在某些情况下是会阻塞的。

阻塞 I/O 超时时间

在阻塞 I/O 之前,要计算它应该阻塞多长时间,参考 Libuv 文档上的一些描述,以下这些是它计算超时时间的规则:

  • 如果循环使用 UV_RUN_NOWAIT 标志运行、超时为 0。
  • 如果循环将要停止(uv_stop() 被调用),超时为 0。
  • 如果没有活动的 handlers 或 request,超时为 0。
  • 如果有任何 idle handlers 处于活动状态,超时为 0。
  • 如果有任何待关闭的 handlers,超时为 0。

如果以上情况都没有,则采用最近定时器的超时时间,或者如果没有活动的定时器,则超时时间为无穷大,poll 阶段会一直阻塞下去。

示例一

很简单的一段代码,我们启动一个 Server,现在事件循环的其它阶段没有要处理的任务,它会在这里等待下去,直到有新的请求进来。

  • const http = require('http');
    const server = http.createServer();
    server.on('request', req => {
    console.log(req.url);
    server.listen(3000);
示例二

结合阶段一的定时器,在看个示例,首先启动 app.js 做为服务端,模拟延迟 3000ms 响应,这个只是为了配合测试。运行 client.js 看下事件循环的执行过程:

  • 首先程序调用了一个在 1000ms 后超时的定时器。
  • 之后调用异步函数 someAsyncOperation() 从网络读取数据,我们假设这个异步网路读取需要 3000ms。
  • 当事件循环开始时先进入 timer 阶段,发现没有超时的定时器函数,继续向下执行。
  • 期间经过 pending callbacks -> idle,prepare 当进入 poll 阶段,此时的 http.get() 尚未完成,它的队列为空,参考上面 poll 阻塞超时时间规则,事件循环机制会检查最快到达阀值的计时器,而不是一直在这里等待下去。
  • 当大约过了 1000ms 后,进入下一次事件循环进入定时器,执行到期的定时器回调函数,我们会看到日志 setTimeout run after 1003 ms。
  • 在定时器阶段结束之后,会再次进入 poll 阶段,继续等待。
  • // client.js
    const now = Date.now();
    setTimeout(() => log(`setTimeout run after ${Date.now() - now} ms`), 1000);
    someAsyncOperation();
    function someAsyncOperation() {
    http.get('http://localhost:3000/api/news', () => {
    log(`fetch data success after ${Date.now() - now} ms`);

    // app.js
    const http = require('http');
    http.createServer((req, res) => {
    setTimeout(() => { res.end('OK!') }, 3000);
    }).listen(3000);

当 poll 阶段队列为空时,并且脚本被 setImmediate() 调度过,此时,事件循环也会结束 poll 阶段,进入下一个阶段 check。

check

check 阶段在 poll 阶段之后运行,这个阶段包含一个 API setImmediate(cb) 如果有被 setImmediate 触发的回调函数,就取出执行,直到队列为空或达到系统的最大限制。

setTimeout VS setImmediate

setTimeout 和 setImmediate 对比,这是一个常见的例子,基于被调用的时机和定时器可能会受到计算机上其它正在运行的应用程序影响,它们的输出顺序,不总是固定的。

  • setTimeout(() => log('setTimeout'));
    setImmediate(() => log('setImmediate'));
    // 第一次运行
    setTimeout
    setImmediate
    // 第二次运行
    setImmediate
    setTimeout
setTimeout VS setImmediate VS fs.readFile

但是一旦把这两个函数放入一个 I/O 循环内调用,setImmediate 将总是会被优先调用。因为 setImmediate 属于 check 阶段,在事件循环中总是在 poll 阶段结束后运行,这个顺序是确定的。

  • fs.readFile(__filename, () => {
    setTimeout(() => log('setTimeout'));
    setImmediate(() => log('setImmediate'));
close callbacks

在 Libuv 中,如果调用关闭句柄 uv_close(),它将调用关闭回调,也就是事件循环的最后一个阶段 close callbacks。

这个阶段的工作更像是做一些清理工作,例如,当调用 socket.destroy(),'close' 事件将在这个阶段发出,事件循环在执行完这个阶段队列里的回调函数后,检查循环是否还 alive,如果为 no 退出,否则继续下一次新的事件循环。

包含 Microtask 的事件循环流程图

在浏览器的事件循环中,把任务划分为 Task、Microtask,在 Node.js 中是按照阶段划分的,上面我们介绍了 Node.js 事件循环的 6 个阶段,给用户使用的主要是 timer、poll、check、close callback 四个阶段,剩下两个由系统内部调度。这些阶段所产生的任务,我们可以看做 Task 任务源,也就是常说的 “Macrotask 宏任务”。

通常我们在谈论一个事件循环时还会包含 Microtask,Node.js 里的微任务有 Promise、还有一个也许很少关注的函数 queueMicrotask,它是在 Node.js v11.0.0 之后被实现的,参见 PR/22951。

Node.js 中的事件循环在每一个阶段执行后,都会检查微任务队列中是否有待执行的任务。

Node.js 11.x 前后差异

Node.js 在 v11.x 前后,每个阶段如果即存在可执行的 Task 又存在 Microtask 时,会有一些差异,先看一段代码:

  • setImmediate(() => {
    log('setImmediate1');
    Promise.resolve('Promise microtask 1')
    .then(log);
    setImmediate(() => {
    log('setImmediate2');
    Promise.resolve('Promise microtask 2')
    .then(log);

在 Node.js v11.x 之前,当前阶段如果存在多个可执行的 Task,先执行完毕,再开始执行微任务。基于 v10.22.1 版本运行结果如下:

  • setImmediate1
    setImmediate2
    Promise microtask 1
    Promise microtask 2

在 Node.js v11.x 之后,当前阶段如果存在多个可执行的 Task,先取出一个 Task 执行,并清空对应的微任务队列,再次取出下一个可执行的任务,继续执行。基于 v14.15.0 版本运行结果如下:

  • setImmediate1
    Promise microtask 1
    setImmediate2
    Promise microtask 2

在 Node.js v11.x 之前的这个执行顺序问题,被认为是一个应该要修复的 Bug 在 v11.x 之后并修改了它的执行时机,和浏览器保持了一致,详细参见 issues/22257 讨论。

特别的 process.nextTick()

Node.js 中还有一个异步函数 process.nextTick(),从技术上讲它不是事件循环的一部分,它在当前操作完成后处理。如果出现递归的 process.nextTick() 调用,这将会很糟糕,它会阻断事件循环。

如下例所示,展示了一个 process.nextTick() 递归调用示例,目前事件循环位于 I/O 循环内,当同步代码执行完成后 process.nextTick() 会被立即执行,它会陷入无限循环中,与同步的递归不同的是,它不会触碰 v8 最大调用堆栈限制。但是会破坏事件循环调度,setTimeout 将永远得不到执行。

  • fs.readFile(__filename, () => {
    process.nextTick(() => {
    log('nextTick');
    run();
    function run() {
    process.nextTick(() => run());

    log('sync run');
    setTimeout(() => log('setTimeout'));
    // 输出
    sync run
    nextTick

将 process.nextTick 改为 setImmediate 虽然是递归的,但它不会影响事件循环调度,setTimeout 在下一次事件循环中被执行。

  • fs.readFile(__filename, () => {
    process.nextTick(() => {
    log('nextTick');
    run();
    function run() {
    setImmediate(() => run());

    log('sync run');
    setTimeout(() => log('setTimeout'));
    // 输出
    sync run
    nextTick
    setTimeout

process.nextTick 是立即执行,setImmediate 是在下一次事件循环的 check 阶段执行。但是,它们的名字着实让人费解,也许会想这两个名字交换下比较好,但它属于遗留问题,也不太可能会改变,因为这会破坏 NPM 上大部分的软件包。

在 Node.js 的文档中也建议开发者尽可能的使用 setImmediate(),也更容易理解。

总结

Node.js 事件循环分为 6 个阶段,每个阶段都有一个 FIFO(先进先出)队列执行回调函数,这几个阶段之间执行的优先级顺序还是明确的。

事件循环的每一个阶段,有时还会伴随着一些微任务而运行,这里以 Node.js v11.x 版本为分界线会有一些差异,文中也都有详细的介绍。

在上一篇介绍了浏览器的事件循环机制,本篇又详细的介绍了 Node.js 中的事件循环机制,留给大家一个思考问题,结合自己的理解,总结下浏览器与 Node.js 中事件循环的一些差异,这个也是常见的一个面试题,欢迎在留言区讨论。

在 Cnode 上看到的两篇事件循环相关文章,推荐给大家,文章很精彩,评论也更加精彩。

  • https://cnodejs.org/topic/5a9108d78d6e16e56bb80882
  • https://cnodejs.org/topic/57d68794cb6f605d360105bf

Reference

http://docs.libuv.org/en/v1.x/design.html

https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
香港市民怒斥:滚!垃圾!

香港市民怒斥:滚!垃圾!

三农老历
2024-05-24 19:14:46
台独分子赖清德的下场!

台独分子赖清德的下场!

新动察
2024-05-24 10:00:59
唐安琪天使颜值和魔鬼身材,令无数粉丝为之疯狂

唐安琪天使颜值和魔鬼身材,令无数粉丝为之疯狂

傲娇的马甲线
2024-04-16 17:40:03
朱元璋的下葬经过有多恐怖?明史给出的资料,令人毛骨悚然

朱元璋的下葬经过有多恐怖?明史给出的资料,令人毛骨悚然

否知的否
2024-05-21 09:16:57
传以军士兵在加沙焚书,军方展开调查

传以军士兵在加沙焚书,军方展开调查

参考消息
2024-05-25 10:50:13
哈尔科夫乌军崩溃!泽连斯基:全世界都要为他们的失败负责

哈尔科夫乌军崩溃!泽连斯基:全世界都要为他们的失败负责

虎哥说航天
2024-05-23 05:25:02
森林狼G2再丢,2人引众怒!1人助力东契奇绝杀,1核心退化成废将

森林狼G2再丢,2人引众怒!1人助力东契奇绝杀,1核心退化成废将

篮球资讯达人
2024-05-25 13:16:25
欧文:看77成长为历史最佳之一非常令人震惊 我会做他的最佳帮手

欧文:看77成长为历史最佳之一非常令人震惊 我会做他的最佳帮手

直播吧
2024-05-25 13:30:10
蔡依林演唱会,真的好敢穿,身材好好哇

蔡依林演唱会,真的好敢穿,身材好好哇

娱乐的小灶
2024-05-24 04:39:29
直击“抓嫖新科技”,看完后你还敢去特殊场所玩吗?

直击“抓嫖新科技”,看完后你还敢去特殊场所玩吗?

华庭讲美食
2024-05-22 07:10:21
商务部:1-4月全国吸收外资3602亿元人民币

商务部:1-4月全国吸收外资3602亿元人民币

界面新闻
2024-05-24 17:48:13
韩德君正式上任,亮相大连新岗位,超级外援正式回归!重返CBA

韩德君正式上任,亮相大连新岗位,超级外援正式回归!重返CBA

阿牛体育说
2024-05-25 13:04:29
事实上,大量的燃油车主并不愿意换成电车,因为大家都心知肚明

事实上,大量的燃油车主并不愿意换成电车,因为大家都心知肚明

看看娱乐与体育
2024-05-25 08:36:08
菲律宾再次心虚!仁爱礁上的破船即将进入解体,中国态度一如既往

菲律宾再次心虚!仁爱礁上的破船即将进入解体,中国态度一如既往

Hi秒懂科普
2024-05-24 12:43:48
谁见过连一个诺贝尔奖得主都没培养出的世界一流大学?

谁见过连一个诺贝尔奖得主都没培养出的世界一流大学?

霹雳炮
2024-05-21 20:55:38
郭有才宣布停播3天,此前菏泽南站附近居民称“堵到没法回家”

郭有才宣布停播3天,此前菏泽南站附近居民称“堵到没法回家”

潇湘晨报
2024-05-22 16:56:07
机场集团亏损排行榜!

机场集团亏损排行榜!

小蜜情感说
2024-05-24 18:00:53
谁说宽松服装就显邋遢,瞧汤唯的春夏穿搭,越宽松越潇洒大气

谁说宽松服装就显邋遢,瞧汤唯的春夏穿搭,越宽松越潇洒大气

宋若时尚搭
2024-05-24 11:35:42
解放军剑指“台独” 岛内舆论三问赖清德

解放军剑指“台独” 岛内舆论三问赖清德

齐鲁壹点
2024-05-25 13:29:53
解放军为何还不去武统台湾?专家透露:需满足6个条件,才能出兵

解放军为何还不去武统台湾?专家透露:需满足6个条件,才能出兵

奇点使者
2024-03-29 18:35:06
2024-05-25 13:56:49
Nodejs开发
Nodejs开发
分享只有程序员懂的干货
648文章数 824关注度
往期回顾 全部

科技要闻

刘强东:从来不拼搏的人 不是我的兄弟

头条要闻

俄加大电子干扰力度 乌军官抱怨"海马斯"系统完全失效

头条要闻

俄加大电子干扰力度 乌军官抱怨"海马斯"系统完全失效

体育要闻

华子致命失误直接离场 东契奇对戈贝尔怒吼

娱乐要闻

霍启仁求婚成功,郭晶晶要有妯娌了

财经要闻

白重恩解读中国经济的3个关键问题

汽车要闻

纯电续航205km版售12.99万起 风神L7上市交付

态度原创

本地
健康
游戏
亲子
军事航空

本地新闻

2024沈阳皇姑第二届半程马拉松

在中国,到底哪些人在吃“伟哥”?

干好事不留名!《剑星》悄悄上架两款未和谐服装

亲子要闻

妈妈给宝宝断母乳,宝宝崩溃,小眼神都透露着委屈

军事要闻

不顾国际法院判决 以军继续猛烈空袭拉法

无障碍浏览 进入关怀版