ホームページ  >  記事  >  ウェブフロントエンド  >  Node のイベント ループについての深い理解

Node のイベント ループについての深い理解

青灯夜游
青灯夜游転載
2021-12-31 19:18:551473ブラウズ

Node.js は、イベント ループを通じてノンブロッキング I/O 操作を処理するシングルスレッド言語です。次の記事では、Node のイベント ループについて詳しく説明します。お役に立てば幸いです。

Node のイベント ループについての深い理解

Node.js JavaScript サーバー ランタイムとして、ブラウザーでのイベント ループのレンダリング フェーズを行わずに、主にネットワークとファイルを処理します。 . .

ブラウザにはイベント ループ処理モデルを定義するための HTML 仕様があり、これは各ブラウザ メーカーによって実装されます。 Node.js のイベント ループの定義と実装は Libuv から来ています。

Libuv はイベント駆動型の非同期 I/O モデルを中心に設計されており、元々は Node.js 用に書かれており、クロスプラットフォーム サポート ライブラリを提供します。下の図はそのコンポーネントを示しています。ネットワーク I/O はネットワーク処理に関連する部分です。右側にはファイル操作と DNS もあります。下部には、epoll、kqueue、イベント ポート、および IOCP がさまざまな基盤となる実装です。オペレーティングシステム。

Node のイベント ループについての深い理解

イベント ループの 6 つの段階

Node.js が起動すると、イベント ループが初期化され、提供されたスクリプトが処理されます。 、同期コードはスタックにプッシュされ、直接実行されます。非同期タスク (ネットワーク リクエスト、ファイル操作、タイマーなど) は、API を呼び出してコールバック関数を渡した後、システム カーネルによる処理のために操作をバックグラウンドに転送します。現在のほとんどのカーネルはマルチスレッドであり、操作の 1 つが完了すると、カーネルは Node.js にコールバック関数をポーリング キューに追加し、実行の機会を待つように通知します。

下の図の左側はNode.js公式サイトのイベントループ処理の説明、右側はLibuv公式サイトのNode.jsの説明です。イベント ループへのリンク. 誰もが見ることができるわけではありません. ソース コード. これら 2 つのドキュメントは通常、イベント ループについて学ぶためのより直接的な参考ドキュメントです. Node.js 公式 Web サイトの紹介は非常に詳細であり、学ぶ。

Node のイベント ループについての深い理解

Node.js 公式サイトに表示されている左側のイベント ループは 6 つのステージに分かれており、各ステージには実行する FIFO (先入れ先出し) キューがあります。コールバック関数 ステージ間の実行の優先順位は依然として明確です。

右側で詳しく説明します。イベント ループを繰り返す前に、まずループがアクティブ状態 (待機中の非同期 I/O、タイマーなどがある) かどうかを確認します。アクティブな状態、反復を開始します。それ以外の場合、ループは直ちに終了します。

各段階については、以下で個別に説明します。

timers (タイマー フェーズ)

まず、イベント ループはタイマー フェーズに入ります。このフェーズには、2 つの API setTimeout(cb, ms) と setInterval(cb, ms ) が含まれています。前者は 1 回だけ実行され、後者は繰り返し実行されます。

このステージでは、期限切れのタイマー関数があるかどうかを確認します。存在する場合は、期限切れのタイマー コールバック関数が実行されます。ブラウザと同様に、タイマー関数によって渡される遅延時間は常に予想よりも長くなります。後で、オペレーティング システムまたは他の実行中のコールバック関数の影響を受けます。

たとえば、次の例ではタイマー関数を設定し、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() 関数を実行します。途中の時間のかかる操作には約 3000 ミリ秒かかります。これらの同期操作が完了すると、イベント ループに入り、最初のチェックが行われます。タイマーフェーズ 期限切れタスクの有無に関わらず、タイマースクリプトは遅延時間の短い順にヒープメモリに保存されます まずタイムアウト時間が最小のタイマー関数を取り出してチェックします If nowTime - timerTaskRegisterTime > ; late, コールバック関数が取り出される 実行する、そうでない場合はチェックを継続する 期限切れになっていないタイマー関数が検出された場合、またはシステム依存関係の最大数に達した場合は、次の段階に進みます。

この例では、someOperation() 関数の実行後の現在時刻が T 3000 であると仮定します:

timer1 関数を確認すると、現在時刻は T 3000 - T > 1000 です。予想を超える遅延時間、コールバック関数を取り出して実行し、チェックを続けます。

タイマー 2 関数を確認します。現在時刻は T 3000 - T

保留中のコールバック

タイマー フェーズが完了すると、イベント ループは保留中のコールバック フェーズに入ります。このフェーズでは、前のラウンドで残った 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(&#39;http&#39;);
const server = http.createServer();
server.on(&#39;request&#39;, 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(&#39;http://localhost:3000/api/news&#39;, () => {
    log(`fetch data success after ${Date.now() - now} ms`);
  });
}

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

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

check

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

setTimeout VS setImmediate

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

setTimeout(() => log(&#39;setTimeout&#39;));
setImmediate(() => log(&#39;setImmediate&#39;));

// 第一次运行
setTimeout
setImmediate

// 第二次运行
setImmediate
setTimeout

setTimeout VS setImmediate VS fs.readFile

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

fs.readFile(__filename, () => {
  setTimeout(() => log(&#39;setTimeout&#39;));
  setImmediate(() => log(&#39;setImmediate&#39;));
})

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 のイベント ループについての深い理解

Node.js 11.x 前后差异

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

setImmediate(() => {
  log(&#39;setImmediate1&#39;);
  Promise.resolve(&#39;Promise microtask 1&#39;)
    .then(log);
});
setImmediate(() => {
  log(&#39;setImmediate2&#39;);
  Promise.resolve(&#39;Promise microtask 2&#39;)
    .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(&#39;nextTick&#39;);
    run();
    function run() {
      process.nextTick(() => run());
    }
  });
  log(&#39;sync run&#39;);
  setTimeout(() => log(&#39;setTimeout&#39;));
});

// 输出
sync run
nextTick

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

fs.readFile(__filename, () => {
  process.nextTick(() => {
    log(&#39;nextTick&#39;);
    run();
    function run() {
      setImmediate(() => run());
    }
  });
  log(&#39;sync run&#39;);
  setTimeout(() => log(&#39;setTimeout&#39;));
});

// 输出
sync run
nextTick
setTimeout

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

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

更多node相关知识,请访问:nodejs 教程!!

以上がNode のイベント ループについての深い理解の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.cnで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。