关于 Node 中的 Event Loop

Event Loop 顾名思义是一个事件派发环,类似于 iOS/Mac 中的 Runloop。
最近有空仔细的学习了下相关的知识,这篇文章讲的 Event Loop 主要针对 Node 环境, 最后也概述了浏览器环境下的 Event Loop 。

什么是 Event Loop

Event Loop 允许 Node.js 执行非阻塞 I / O 操作 - 尽管 JavaScript 是单线程的 - 它会尽可能将操作卸载到系统内核。由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。 当其中一个操作完成时,内核会告诉 Node.js,以便可以将相应的回调添加到轮询队列中以最终执行。

具体描述

当 Node.js 启动时,它初始化 Event Loop,处理提供的输入脚本,这可能会进行异步API调用,调度计时器或调用 process.nextTick(), 然后开始处理事件循环。
下图显示了事件循环操作顺序的简要概述。

图中的每个框都称为 Event Loop 中的 phase (后面以阶段代替).

每个阶段都有一个要执行的 FIFO 先入先出队列。虽然每个阶段都有自己独特的方式,但通常情况下,当 Event Loop 进入到相应阶段时,它将执行该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或已执行最大回调数。当待执行队列为空或达到回调限制时,Event Loop 将移动至下一阶段,以此类推。

由于任何一个操作可以调度更多的操作并且在轮询阶段执行的新事件是由内核排队,因此轮询事件可以在处理轮询事件时排队。所以,长时间运行的回调可以允许轮询阶段运行的时间比计时器的阈值长得多。

Phase 概述

Timers: 此阶段执行 setTimeout()setInterval() 的回调
Pending Callbacks: 执行延迟到下一个循环迭代的 I / O 回调
idle, prepare: 仅为内部使用
poll: 检索新的 I / O 事件;执行相关的回调节点将在这个阶段合适的时机关闭。
check: setImmediate() 的回调在此阶段执行
close callbacks: 一些关闭的回调,比如 socket.on('close', ...) .

在 Event Loop 的每次运行之间,Node.js 会检查它是否在等待任何异步 I / O 或者定时器,如果没有则关闭。

Phase 细节

timers

计时器指定阈值,在该阈值之后可以执行提供的回调而不是人们想要执行它的确切时间。 定时器回调将在指定的时间过去后尽早安排; 但是,操作系统调度或其他回调的运行可能会延迟它们。

从技术上讲,轮询阶段控制何时执行计时器。

例如,假设你计划在 100 毫秒后执行 timeout 回调,然后您的脚本将异步读取一个需要 95 毫秒的文件:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

当 Event Loop 进入到 poll 阶段,它有一个空的队列(fs.readFile()还没有执行完成), 所以它将等待剩余的 ms 数,直到达到最快的计时器阈值。当等待了 95 ms 后,fs.readFile() 完成读取文件,它的回调加入到轮询阶段的队列以及执行需要 10 ms 。 当回调执行完成, 没有其他的回调在队列中,所以 Event Loop 将看到已达到最快计时器的阈值,然后回到 timers 阶段以执行计时器的回调。在这个示例中,你将看到在触发定时任务以及执行回调之间有大约 105 ms 的间隔。

pending callbacks

这个阶段执行一些系统操作的回调,比如各种类型的 TCP 错误。举例来讲,如果 TCP socket 在尝试连接时收到 ECONNREFUSED , 一些 *nix 系统希望等待上报错误。这些操作会排队在 pending callbacks 这个阶段执行。

poll

轮询阶段主要做两类事情:

1.计算它该阻塞和轮询 I / O 的时间,然后
2.处理轮询队列中的事件

当 Event Loop 进入到 poll 阶段并且没有计划定时器,将发生下面两种情况之一:

1.如果轮询队列不为空,则 Event Loop 会遍历并同步执行它的回调队列,直到队列为空或者达到系统硬件的限制。
2.如果轮询队列为空,会有两种可能:
1)如果是 setImmediate() 调度的脚本,则 Event Loop 会结束 poll 阶段并继续到 check 阶段来执行这些调度脚本。
2) 如果 setImmediate() 尚未调度脚本,事件循环将会等待回调添加到队列,并立即执行它们。

轮询队列为空后,Event Loop 会检查已经到达阈值的计时器。如果一个或多个计时器准备就绪,Event Loop 会回跳到 timers 阶段以执行这些计时器的回调。

check

这个阶段允许在 poll 阶段完成后立刻执行回调。如果 poll 阶段已经变为空闲并且脚本已使用 setImmediate() 排队,则 Event Loop 可以继续到 check 阶段而不是等待。

setImmediate() 实际上是一种特殊的计时器,它在 Event Loop 一个单独的阶段运行,它使用 libuv API 来调度执行回调在 poll 阶段完成后。

通常在执行代码时,Event Loop 终究会到达 poll 阶段,在此阶段它将等待连接,请求等等。但是如果一个回调是通过 setImmediate() 调度的,并且 poll 阶段已经变味空闲,则将会结束 poll 阶段并进入 check 阶段,而不是继续等待轮询事件。

close callbacks

如果套接字或句柄突然关闭(例如 socket.destroy()), 则这个阶段会触发 'close' 事件。否则它将通过 process.nextTick() 发出。

setImmediate() vs setTimeout()

setImmediate()setTimeout() 看起来相似,但根据调用时间他们以不同的方式运行:

setImmediate() 设计来在当前 poll 阶段完成后执行脚本。
setTimeout() 调度脚本在经过一个最小的阈值时间后执行。

执行计时器的顺序将根据调度它们的上下文而有所不同。如果从主模块中调用两者,那么时间将受到进程性能的限制(可能受到计算机上运行的其他应用程序的影响)。

例如,如果我们运行不在 I / O 周期内的以下脚本(即主模块),则执行两个定时器的顺序是不确定的,因为它受进程性能的约束:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是,如果在I / O周期内移动两个调用,则始终首先执行 'immediate' 回调:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用 setImmediate() 优于 setTimeout() 的主要优点是 setImmediate() 将始终在任何定时器之前执行(如果在 I / O 周期内调度),与存在多少定时器无关。

process.nextTick()

了解 process.nextTick()

你可能已经注意到,process.nextTick() 未显示在 Event Loop 的阶段图中,即使它是异步 API 的一部分。这是因为 process.nextTick() 在技术上不是 Event Loop 的一部分。相反,nextTickQueue 将在当前操作完成后处理,而不管 Event Loop 当前的阶段如何。

回顾一下阶段图,在给定阶段任意时刻你调用 process.nextTick() 时,所有的回调都会在 Event Loop 继续之前解决。这可能会产生一些不良情况,因为它允许你通过递归 process.nextTick() 来饿死你的 I / O , 这会阻止 Event Loop 达到 poll 阶段。

为什么会被允许?

这样的东西需要包含在 Node.js 中吗?其中一部分是一种设计理念,即 API 本身始终应该是异步的,即使不必须。以下面这段代码为例:

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback,
                            new TypeError('argument should be string'));
}

这段代码片段会进行参数检查,如果不正确,它会将错误传递给回调。最近更新的 API 允许将参数传递给 process.nextTick(),允许它将回调后传递的任何参数作为参数传递到回调,因此你不必嵌套函数。

我们正在做的是将错误传回给用户,但需要在其余的用户代码执行之后。通过使用 process.nextTick(),我们保证 apiCall() 始终在用户代码的其余部分之后和允许 Event Loop 继续之前运行其回调。为了实现这点,JS 调用栈可以展开并立即执行提供的回调,这允许一个人可以对 process.nextTick() 进行递归调用而不会达到 RangeError: 超出 V8 引擎的最大调用栈大小。

这种理念可能会导致一些潜在的问题。 以此片段为例:

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) { callback(); }

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall has completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});

bar = 1;

用户将 someAsyncApiCall() 定义为具有异步签名,但它实际上是同步操作的。 调用它时,在 Event Loop 的同一阶段调用提供给someAsyncApiCall() 的回调,因为 someAsyncApiCall() 实际上不会异步执行任何操作。 因此,回调尝试引用 bar,即使它在范围内可能没有该变量,因为该脚本无法运行完成。

通过将回调放在 process.nextTick() 中,脚本仍然能够运行完成,允许在调用回调之前初始化所有变量,函数等。 它还具有不允许 Event Loop 继续的优点。 在允许事件循环继续之前,向用户警告错误可能是有用的。 以下是使用 process.nextTick() 的上一个示例:

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

这还有另外一个真实的例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

仅传递端口时,端口立即绑定。 因此,可以立即调用 'listen' 回调。 问题是那时候不会设置 .on('listen')回调。

为了解决这个问题,'listening' 事件在 nextTick()中排队,以允许脚本运行完成。 这允许用户设置他们想要的任何事件处理程序。

process.nextTick() vs setImmediate()

就用户而言,我们有两个类似的调用,但它们的名称令人困惑。

process.nextTick() 立即在同一阶段触发
setImmediate() 触发事件循环的后续迭代或 'tick'

实质上,它们俩应该交换名称。process.nextTick()setImmediate() 更快地触发,但这是过去的工件,不太可能改变。 进行此切换会破坏 npm 上的大部分包。 每天都会添加更多新模块,这意味着我们每天都在等待,更多的潜在破损发生。 虽然它们令人困惑,但名称本身不会改变。

我们建议开发人员在所有情况下都使用 setImmediate(),因为它更容易推理(并且它与更广泛的环境兼容,如浏览器 JS)。

为什么使用 process.nextTick() ?

有两个主要的原因:
1.允许用户处理错误,清除任何不需要的资源,或者在 Event Loop 继续之前再次尝试请求。
2.有时需要允许回调在调用堆栈展开后但在 Event Loop 继续之前运行。

一个例子是匹配用户的期望。 简单的例子:

const server = net.createServer();
server.on('connection', (conn) => { });

server.listen(8080);
server.on('listening', () => { });

假设 listen() 在 Event Loop 开始时运行,但是监听回调放在setImmediate() 中。 除非传递主机名,否则将立即绑定到端口。 要使 Event Loop 继续,它必须达到轮询 (poll) 阶段,这意味着可能已经接收到连接的非零概率允许在监听事件之前触发连接事件。

另一个例子是运行一个函数构造函数,比如继承自 EventEmitter,它想在构造函数中调用一个事件:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

您无法立即从构造函数中发出事件,因为脚本将不会处理到用户为该事件分配回调的位置。 因此,在构造函数本身中,您可以使用 process.nextTick() 设置回调以在构造函数完成后发出事件,从而提供预期的结果:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(() => {
    this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

也看下浏览器环境的 Event Loop

macrotask 以及 microtask

浏览器运行环境下, Event Loop 一般会先执行 macrotask 队列的任务,然后是 microtask。

一般通用规范下的 macrotask 包括:

1.DOM 操作任务源:
此任务源被用来相应 dom 操作,例如一个元素以非阻塞的方式插入文档。

2.用户交互任务源:
此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如 click )必须使用 task 队列。

3.网络任务源:
网络任务源被用来响应网络活动。

4.history traversal 任务源:
当调用 history.back() 等类似的api时,将任务插进task队列。

从方法角度来说则包括:setTimeout, setInterval, setImmediate, I/O, UI rendering 等。

HTML Standard 没有具体指明哪些是 microtask 任务源,通常认为是 microtask 任务源有:process.nextTick, promises, Object.observe, MutationObserver

Processing model

在规范的 Processing model 定义了event loop的循环过程:

一个event loop只要存在,就会不断执行下边的步骤:
1.在 macro tasks 队列中选择最老的一个 task, 用户代理可以选择任何 task 队列,如果没有可选的任务,则跳到下边的 microtasks 步骤。
2.将上边选择的 task 设置为正在运行的 task。
3.Run: 运行被选择的 task。
4.将 event loop 的 currently running task 变为 null。
5.从 task 队列里移除前边运行的 task。
6.Microtasks: 执行 microtasks 任务检查点。(也就是执行 microtasks 队列里的任务)
7.更新渲染(Update the rendering)...
8.如果这是一个 worker event loop,但是没有任务在 task 队列中,并且WorkerGlobalScope 对象的 closing 标识为 true,则销毁 event loop,中止这些步骤,然后进行定义在 Web workers 章节的 run a worker。
9.返回到第一步。

上面了解过 macro task 和 micro task ,下面来看下 Update the rendering :

这是 event loop 中很重要部分,在第7步会进行 Update the rendering(更新渲染),规范允许浏览器自己选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。

渲染的基本流程:
render process

处理 HTML 标记并构建 DOM 树。
处理 CSS 标记并构建 CSSOM 树, 将 DOM 与 CSSOM 合并成一个渲染树。
根据渲染树来布局,以计算每个节点的几何信息。
将各个节点绘制到屏幕上。

-- EOF --
以上就是这篇文章全部内容,
Node.js 部分翻译自官方文档:The Node.js Event Loop, Timers, and process.nextTick()
浏览器部分截取自:从event loop规范探究javaScript异步及浏览器更新渲染时机
欢迎提出建议和指正,个人联系方式详见关于

Comments
Write a Comment