ホームページ  >  記事  >  ウェブフロントエンド  >  ノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょう

ノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょう

青灯夜游
青灯夜游転載
2022-07-20 20:22:222164ブラウズ

ノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょう

Node は、もともと高パフォーマンスの Web サーバーを構築するために生まれました。JavaScript サーバー ランタイムとして、イベント駆動型、非同期 I/O、および単一の-ねじ切り、その他の特性。イベント ループに基づく非同期プログラミング モデルにより、Node は高い同時実行性を処理できるようになり、サーバーのパフォーマンスが大幅に向上すると同時に、JavaScript のシングルスレッド特性が維持されるため、状態の同期や状態の同期などの問題に対処する必要がなくなります。マルチスレッドでのデッドロック スレッド コンテキストの切り替えによるパフォーマンスのオーバーヘッドはありません。これらの特性に基づいて、Node は高いパフォーマンスと高い同時実行性という固有の利点を備えており、これをベースにしてさまざまな高速かつスケーラブルなネットワーク アプリケーション プラットフォームを構築できます。

この記事では、ノードの非同期とイベント ループの基礎となる実装と実行メカニズムについて詳しく説明します。お役に立てば幸いです。

なぜ非同期なのか?

Node がコア プログラミング モデルとして非同期を使用するのはなぜですか?

前に述べたように、Node はもともと高性能 Web サーバーを構築するために生まれました。ビジネス シナリオで完了する必要のある無関係なタスクのセットがいくつかあると仮定すると、主流の最新ソリューションは 2 つあります:

  • シングルスレッドのシリアル実行。

  • 複数のスレッドが並行して完了します。

シングル スレッド シリアル実行は同期プログラミング モデルです。プログラマの考え方に沿った順番で実行すると、より便利なコードを作成しやすくなりますが、次のような理由があります。 I/O は同期的に実行され、同時に 1 つのリクエストしか処理できないため、サーバーの応答が遅くなり、同時実行性の高いアプリケーション シナリオには適用できません。さらに、I/O がブロックされるため、CPU は常にI/O が完了するまで待ちます。他のことができず、CPU の処理能力を最大限に活用できず、最終的には効率の低下につながります。

マルチスレッド プログラミング モデルは、次の理由により開発者にも問題を引き起こします。状態の同期やプログラミングのデッドロックなどの問題が発生します。ただし、マルチスレッドはマルチコア CPU 上の CPU 使用率を効果的に向上させることができます。

シングルスレッドのシリアル実行とマルチスレッドの並列完了のプログラミング モデルには独自の利点がありますが、パフォーマンスや開発の難しさなどの点で欠点もあります。

また、クライアントのリクエストに対する応答速度から見て、クライアントが同時に 2 つのリソースを取得した場合、同期メソッドの応答速度は 2 つのリソースの応答速度の合計になります。一方、非同期メソッドの応答速度は 2 つのリソースの応答速度の合計となり、速度は 2 つのうちの最大となり、同期と比較してパフォーマンス上の利点が非常に明白です。アプリケーションの複雑さが増すにつれて、このシナリオは n 個のリクエストに同時に応答するように発展し、同期と比較した非同期の利点が強調されるようになります。

要約すると、Node はその答えを示します: シングル スレッドを使用してマルチスレッドのデッドロック、状態の同期、その他の問題を回避し、非同期 I/O を使用してシングル スレッドのブロックを回避します。 CPUをより良く使用するために。これが、Node がコア プログラミング モデルとして async を使用する理由です。

さらに、マルチコア CPU を利用できないシングル スレッドの欠点を補うために、Node はブラウザ内で Web ワーカーと同様のサブプロセスを提供し、CPU を効率的に利用できます。ワーカープロセスを通じて。

非同期を実装するにはどうすればよいですか?

非同期を使用する必要がある理由について説明した後、非同期を実装するにはどうすればよいでしょうか?

私たちが通常呼ぶ非同期操作には 2 つのタイプがあります: 1 つはファイル I/O やネットワーク I/O などの I/O 関連の操作で、もう 1 つは setTimeOut 、 # のような操作です。 ##setInterval このタイプの操作は I/O とは関係ありません。ここで言う非同期とは、明らかに I/O に関連する操作、つまり非同期 I/O を指します。

非同期 I/O は、I/O 呼び出しによって後続のプログラムの実行がブロックされないことを期待して提案され、I/O の完了を待つ本来の時間が、実行に必要な他のビジネスに割り当てられます。 。この目標を達成するには、ノンブロッキング I/O が必要です。

I/O のブロックとは、CPU が I/O 呼び出しを開始した後、I/O が完了するまでブロックされることを意味します。ブロッキング I/O とノンブロッキング I/O について理解するのは簡単です。CPU は、ブロックして待機するのではなく、I/O 呼び出しを開始するとすぐに戻ります。CPU は、I/O が完了する前に他のトランザクションを処理できます。明らかに、ブロッキング I/O と比較して、ノンブロッキング I/O の方がパフォーマンスが向上します。

では、ノンブロッキング I/O が使用されており、CPU は I/O 呼び出しの開始後にすぐに戻ることができるのですが、I/O が完了したことはどのようにしてわかるのでしょうか?答えは投票です。

I/O 呼び出しの状態をタイムリーに取得するために、CPU は I/O オペレーションを繰り返し呼び出し続け、I/O が完了したかどうかを確認します。操作が完了したかどうかをポーリングと呼びます。

当然ですが、ポーリングを行うとCPUはステータス判定を繰り返し行うことになり、CPUリソースを無駄に消費してしまいます。また、ポーリング間隔は制御が難しく、間隔が長すぎると I/O 操作の完了に対する応答がタイムリーに得られず、間接的にアプリケーションの応答速度が低下します。間隔が短すぎると、ポーリングには必然的に CPU が消費され、時間がかかり、CPU リソースの使用率が低下します。

したがって、ポーリングは、ノンブロッキング I/O が後続のプログラムの実行をブロックしないという要件を満たしていますが、アプリケーションにとっては、依然として一種の同期としてのみ見なすことができます。 I/O は完全に戻りますが、依然として待機に時間がかかります。

私たちが期待する完璧な非同期 I/O は、アプリケーションがノンブロッキング呼び出しを開始することです。ポーリングを通じて I/O 呼び出しのステータスを継続的にクエリする必要はありません。代わりに、次のタスクを直接処理できます。 .O 完了後、セマフォまたはコールバックを介してデータをアプリケーションに渡すことができます。

この種の非同期 I/O を実装するにはどうすればよいでしょうか?答えはスレッドプールです。

この記事では常に Node がシングル スレッドで実行されると述べてきましたが、ここでのシングル スレッドとは、JavaScript コードがシングル スレッドで実行されることを意味します。ビジネスロジックの一部を他のスレッドで動作させて実装することで、メインスレッドの動作に影響を与えたり、阻害したりすることなく、むしろメインスレッドの実行効率を向上させ、非同期I/Oを実現することができます。

スレッド プールを通じて、メイン スレッドに I/O 呼び出しのみを行わせ、他のスレッドにブロッキング I/O またはノンブロッキング I/O とポーリング テクノロジを実行させてデータ取得を完了し、データ取得を完了するためのスレッドプール I/O で取得したデータを通信で転送することで非同期 I/O を簡単に実現:

ノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょう

メインスレッドが I/O 呼び出しを行い、 /O 操作でデータの取得が完了し、スレッド間の通信を通じてデータをメイン スレッドに渡して I/O 呼び出しを完了します。その後、メイン スレッドはコールバック関数を使用して、データをユーザーに公開し、ユーザーはこれらを使用します。データは、ビジネス ロジック レベルでの操作を完了するために使用されます。これは、Node の完全な非同期 I/O プロセスです。ユーザーにとっては、基礎となる層の面倒な実装の詳細について心配する必要はなく、以下に示すように、Node によってカプセル化された非同期 API を呼び出し、ビジネス ロジックを処理するコールバック関数を渡すだけで済みます。

Nodejs の基礎となる非同期実装メカニズムは、プラットフォームごとに異なります。Windows では、IOCP は主に I/O 呼び出しをシステム カーネルに送信し、カーネルから完了した I/O 操作を取得するために使用されます。これで非同期 I/O のプロセスが完了します。このプロセスは、Linux では epoll、FreeBSD では kqueue、Solaris ではイベント ポートを通じて実装されます。スレッド プールは Windows 上のカーネル (IOCP) によって直接提供され、*nix シリーズは libuv 自体によって実装されます。

Windows プラットフォームと

*nix プラットフォームの違いにより、Node は抽象カプセル化層として libuv を提供するため、すべてのプラットフォーム互換性の判断はこの層によって完了し、上位 Nodeこれは、基礎となるカスタム スレッド プールおよび IOCP から独立しています。 Node はコンパイル中にプラットフォームの条件を決定し、unix ディレクトリまたは win ディレクトリ内のソース ファイルを選択的にターゲット プログラムにコンパイルします。

ノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょう

上記は、Node の非同期の実装です。

(スレッド プールのサイズは環境変数

UV_THREADPOOL_SIZE で設定できます。デフォルト値は 4 です。ユーザーは実際の状況に基づいてこの値のサイズを調整できます。)

それでは、スレッド プールから渡されたデータを取得した後、メインスレッドはいつどのようにコールバック関数を呼び出すのかという質問です。答えはイベントループです。

イベントループに基づく非同期プログラミングモデル

I/Oデータの処理にはコールバック関数が使用されるため、コールバック関数をいつどのように呼び出すかという問題が必然的に発生します。実際の開発では、複数の種類の非同期 I/O 呼び出しシナリオが含まれることが多く、これらの非同期 I/O コールバックの呼び出しをどのように合理的に配置し、非同期コールバックを秩序正しく進行させるかは難しい問題です。非同期 I/O /O に加えて、タイマーなどの非 I/O 非同期呼び出しもあります。このような API はリアルタイム性が高く、それに応じて優先順位も高くなります。異なる優先順位でコールバックをスケジュールするにはどうすればよいですか?

したがって、さまざまな優先順位とタイプの非同期タスクを調整して、これらのタスクがメインスレッドで順序どおりに実行されるようにするためのスケジューリング メカニズムが必要です。ブラウザと同様に、Node はこの面倒な作業を行うためにイベント ループを選択しました。

Node は、タスクをタイプと優先度に応じて 7 つのカテゴリ (タイマー、保留中、アイドル、準備、ポーリング、チェック、クローズ) に分類します。タスクの種類ごとに、タスクとそのコールバックを格納する先入れ先出しタスク キューがあります (タイマーは上部の小さなヒープに格納されます)。これら 7 つのタイプに基づいて、Node はイベント ループの実行を次の 7 つのステージに分割します。

timers

このステージの実行優先度は最も高くなります。

この段階で、イベント ループはタイマーを格納するデータ構造 (最小ヒープ) をチェックし、その中のタイマーを走査し、現在の時刻と有効期限を 1 つずつ比較し、タイマーが有効かどうかを判断します。有効期限が切れている場合は、タイマーのコールバック関数を取り出して実行します。

pending

このステージは、ネットワーク、IO、その他の例外が発生したときにコールバックを実行します。

*nix によって報告された一部のエラーは、この段階で処理されます。さらに、前のサイクルのポーリング フェーズで実行される必要がある一部の I/O コールバックは、このフェーズに延期されます。

idle、prepare

これら 2 つのステージはイベント ループ内でのみ使用されます。

poll

检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器调度的回调和 之外几乎所有回调setImmediate());节点会在适当的时候阻塞在这里。

poll,即轮询阶段是事件循环最重要的阶段,网络 I/O、文件 I/O 的回调都主要在这个阶段被处理。该阶段有两个主要功能:

  • 计算该阶段应该阻塞和轮询 I/O 的时间。

  • 处理 I/O 队列中的回调。

当事件循环进入 poll 阶段并且没有设置定时器时:

  • 如果轮询队列不为空,则事件循环将遍历该队列,同步地执行它们,直到队列为空或达到可执行的最大数量。

  • 如果轮询队列为空,则会发生另外两种情况之一:

    • 如果有 setImmediate() 回调需要执行,则立即结束 poll 阶段,并进入 check 阶段以执行回调。

    • 如果没有 setImmediate() 回调需要执行,事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待。之所以选择停留在这里是因为 Node 主要是处理 IO 的,这样可以更及时地响应 IO。

一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果有一个或多个定时器达到时间阈值,事件循环将回到 timers 阶段以执行这些定时器的回调。

check

该阶段会依次执行 setImmediate() 的回调。

close

该阶段会执行一些关闭资源的回调,如 socket.on('close', ...)。该阶段晚点执行也影响不大,优先级最低。

当 Node 进程启动时,它会初始化事件循环,执行用户的输入代码,进行相应异步 API 的调用、计时器的调度等等,然后开始进入事件循环:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<p>事件循环的每一轮循环(通常被称为 tick),会按照如上给定的优先级顺序进入七个阶段的执行,每个阶段会执行一定数量的队列中的回调,之所以只执行一定数量而不全部执行完,是为了防止当前阶段执行时间过长,避免下一个阶段得不到执行。</p><p>OK,以上就是事件循环的基本执行流程。现在让我们来看另外一个问题。</p><p>对于以下这个场景:</p><pre class="brush:php;toolbar:false">const server = net.createServer(() => {}).listen(8080);

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

当服务成功绑定到 8000 端口,即 listen() 成功调用时,此时 listening 事件的回调还没有绑定,因此端口成功绑定后,我们所传入的 listening 事件的回调并不会执行。

再思考另外一个问题,我们在开发中可能会有一些需求,如处理错误、清理不需要的资源等等优先级不是那么高的任务,如果以同步的方式执行这些逻辑,就会影响当前任务的执行效率;如果以异步的方式,比如以回调的形式传入 setImmediate() 又无法保证它们的执行时机,实时性不高。那么要如何处理这些逻辑呢?

基于这几个问题,Node 参考了浏览器,也实现了一套微任务的机制。在 Node 中,除了调用 new Promise().then() 所传入的回调函数会被封装成微任务外,process.nextTick() 的回调也会被封装成微任务,并且后者的执行优先级比前者高。

有了微任务后,事件循环的执行流程又是怎么样的呢?换句话说,微任务的执行时机在什么时候?

  • 在 node 11 及 11 之后的版本,一旦执行完一个阶段里的一个任务就立刻执行微任务队列,清空该队列。

  • 在 node11 之前执行完一个阶段后才开始执行微任务。

因此,有了微任务后,事件循环的每一轮循环,会先执行 timers 阶段的一个任务,然后按照先后顺序清空 process.nextTick()new Promise().then() 的微任务队列,接着继续执行 timers 阶段的下一个任务或者下一个阶段,即 pending 阶段的一个任务,按照这样的顺序以此类推。

利用 process.nextTick(),Node 就可以解决上面的端口绑定问题:在 listen() 方法内部,listening 事件的发出会被封装成回调传入 process.nextTick() 中,如下伪代码所示:

function listen() {
    // 进行监听端口的操作
    ...
    // 将 `listening` 事件的发出封装成回调传入 `process.nextTick()` 中
    process.nextTick(() => {
        emit('listening');
    });
};

在当前代码执行完毕后便会开始执行微任务,从而发出 listening 事件,触发该事件回调的调用。

一些注意事项

由于异步本身的不可预知性和复杂性,在使用 Node 提供的异步 API 的过程中,尽管我们已经掌握了事件循环的执行原理,但是仍可能会有一些不符合直觉或预期的现象产生。

比如定时器(setTimeoutsetImmediate)的执行顺序会因为调用它们的上下文而有所不同。如果两者都是从顶层上下文中调用的,那么它们的执行时间取决于进程或机器的性能。

我们来看以下这个例子:

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

setImmediate(() => {
  console.log('immediate');
});

以上代码的执行结果是什么呢?按照我们刚才对事件循环的描述,你可能会有这样的答案:由于 timers 阶段会比 check 阶段先执行,因此 setTimeout() 的回调会先执行,然后再执行 setImmediate() 的回调。

实际上,这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate。这是因为这两个定时器都是在全局上下文中调用的,当事件循环开始运行并执行到 timers 阶段时,当前时间可能大于 1 ms,也可能不足 1 ms,具体取决于机器的执行性能,因此 setTimeout() 在第一个 timers 阶段是否会被执行实际上是不确定的,因此才会出现不同的输出结果。

(当 delaysetTimeout 的第二个参数)的值大于 2147483647 或小于 1 时, delay 会被设置为 1。)

我们接着看下面这段代码:

const fs = require('fs');

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

可以看到,在这段代码中两个定时器都被封装成回调函数传入 readFile 中,很明显当该回调被调用时当前时间肯定大于 1 ms 了,所以 setTimeout 的回调会比 setImmediate 的回调先得到调用,因此打印结果为:timeout immediate

以上是在使用 Node 时需要注意的与定时器相关的事项。除此之外,还需注意 process.nextTick()new Promise().then() 还有 setImmediate() 的执行顺序,由于这部分比较简单,前面已经提到过,就不再赘述了。

总结

文章开篇从为什么要异步、如何实现异步两个角度出发,较详细地阐述了 Node 事件循环的实现原理,并提到一些需要注意的相关事项,希望对你有所帮助。

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

以上がノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょうの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

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