关于前端:带你详细了解-Nodejs-中的事件循环

57次阅读

共计 5882 个字符,预计需要花费 15 分钟才能阅读完成。

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

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

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

事件循环的六个阶段

当 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(),也更容易了解。

正文完
 0