Node はもともと、JavaScript のサーバー側ランタイムとして、高性能 Web サーバーを構築するために作成され、イベント駆動型、非同期 I/O、シングル スレッドなどの機能を備えています。イベント ループに基づく非同期プログラミング モデルにより、Node は高い同時実行性を処理できるようになり、サーバーのパフォーマンスが大幅に向上します。同時に、JavaScript のシングルスレッド特性が維持されるため、Node は状態の同期や問題などの問題に対処する必要がありません。マルチスレッド下でのデッドロック スレッド コンテキストの切り替えによるパフォーマンスのオーバーヘッドはありません。これらの特性に基づいて、Node は高いパフォーマンスと高い同時実行性という固有の利点を備えており、これをベースにしてさまざまな高速かつスケーラブルなネットワーク アプリケーション プラットフォームを構築できます。
この記事では、Node の非同期ループとイベント ループの基礎となる実装と実行メカニズムについて詳しく説明します。お役に立てば幸いです。
Node がコア プログラミング モデルとして非同期を使用するのはなぜですか?
前述したように、Node は元々、高パフォーマンスの Web サーバーを構築するために作成されました。ビジネス シナリオで完了する必要のある無関係なタスクがいくつかあると仮定すると、最新の主流のソリューションは 2 つあります。
シングル スレッドのシリアル実行です。
複数のスレッドで並行して完了します。
シングルスレッド シリアル実行は同期プログラミング モデルです。これはプログラマーの考え方に沿っており、より便利なコードを作成しやすくなりますが、I/O は同期的に実行されるため、処理できるのは I/O のみです。同時に、単一のリクエストではサーバーの応答が遅くなり、同時実行性の高いアプリケーションのシナリオには適用できません。また、I/O がブロックされるため、CPU は常に I/O が完了するのを待機することになります。 CPU の処理能力を最大限に活用するには、最終的に効率が低下します。
また、マルチスレッド プログラミング モデルは、プログラミングにおける状態の同期やデッドロックなどの問題により、開発者に頭痛の種を与えます。ただし、マルチスレッドはマルチコア CPU 上の CPU 使用率を効果的に向上させることができます。
シングルスレッドのシリアル実行とマルチスレッドの並列実行のプログラミング モデルには独自の利点がありますが、パフォーマンスと開発の難しさの点で欠点もあります。
また、クライアントのリクエストに対する応答速度から見て、クライアントが同時に2つのリソースを取得した場合、同期メソッドの応答速度は、2つのリソースの応答速度と、クライアントのリクエストに対する応答速度の合計となります。非同期メソッドは 2 つのメソッドの中間に位置し、同期に比べてパフォーマンス上の利点が非常に明白です。アプリケーションの複雑さが増すにつれて、このシナリオは n 個のリクエストに同時に応答するように発展し、同期と比較した非同期の利点が強調されるようになります。
要約すると、Node はその答えを示します。単一スレッドを使用してマルチスレッドのデッドロック、状態同期、その他の問題を回避し、非同期 I/O を使用して単一スレッドのブロックを回避し、CPU を効率的に使用します。これが、Node がコア プログラミング モデルとして非同期を使用する理由です。
さらに、マルチコア 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 呼び出しをブロックして待機するのではなく、すぐに戻ります。明らかに、ブロッキング 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 を待つ必要があるため、一種の同期としてのみみなすことができます。 Oは完全に戻りましたが、それでもかなりの時間を費やしました。
私たちが期待する完璧な非同期 I/O は、アプリケーションが非ブロッキング呼び出しを開始することです。ポーリングを通じて I/O 呼び出しのステータスを継続的にクエリする必要はなく、その代わりに次のタスクを直接処理できます。 I/O が完了したら、セマフォまたはコールバックを介してデータをアプリケーションに渡すだけです。
この非同期 I/O を実装するにはどうすればよいでしょうか?答えはスレッドプールです。
この記事では常にNodeがシングルスレッドで実行されると述べてきましたが、ここでのシングルスレッドとは、I/O操作などのメインのビジネスロジックに関係のない部分については、シングルスレッドで実行されることを意味します。スレッド形式で他の実装で実行することにより、メインスレッドの実行に影響を与えたりブロックしたりすることはなく、逆にメインスレッドの実行効率を向上させ、非同期 I/O を実現できます。
スレッド プールを通じて、メイン スレッドに I/O 呼び出しのみを行わせ、他のスレッドにブロッキング I/O またはノンブロッキング I/O とポーリング テクノロジを実行させてデータ取得を完了させ、スレッド間の通信を使用して I/O 呼び出しを完了します。 /O 取得したデータが渡され、非同期 I/O が簡単に実装されます。
メインスレッドは I/O 呼び出しを実行し、スレッド プールは I/O 操作を実行してデータの取得を完了し、スレッド間の通信を通じてデータをメインスレッドに渡して I/O 呼び出しを完了し、メインスレッド再利用 コールバック関数はデータをユーザーに公開し、ユーザーはそのデータを使用してビジネス ロジック レベルでの操作を完了します。これは、Node の完全な非同期 I/O プロセスです。
ユーザーにとっては、下に示すように、Node によってカプセル化された非同期 APIを
呼び出し、ビジネス ロジックを処理するコールバック関数に渡すだけで、面倒な実装の詳細を気にする必要はありません。
("fs") ; fs.readFile('example.js', (データ) => { // ビジネス ロジックを処理します。});
Nodejs の基礎となる非同期実装メカニズムはプラットフォームによって異なります。Windows では、IOCP は主にシステム カーネルに I/O 呼び出しを送信し、カーネルから完了した I/O 操作を取得するために使用されます。非同期 I/O プロセスを完了するためのイベント ループを使用します。このプロセスは、Linux では epoll を通じて、FreeBSD では kqueue を通じて、Solaris ではイベント ポートを通じて実装されます。スレッド プールは Windows ではカーネル (IOCP) によって直接提供されますが、 *nix
シリーズは libuv 自体によって実装されます。
Windows プラットフォームと*nix
プラットフォームの違いにより、Node は抽象カプセル化層として libuv を提供するため、すべてのプラットフォームの互換性判断はこの層によって完了し、上位層の Node と下位層のカスタム スレッド プールと IOCP が保証されます。互いに独立しています。ノードはコンパイル中にプラットフォームの条件を判断し、unix ディレクトリまたは win ディレクトリ内のソース ファイルを選択的にターゲット プログラムにコンパイルします。
上記はNodeの非同期実装です。
(スレッド プールのサイズは、環境変数UV_THREADPOOL_SIZE
を通じて設定できます。デフォルト値は 4 です。ユーザーは実際の状況に基づいてこの値のサイズを調整できます。)
次に、問題は、スレッド プール、メインスレッドはいつコールバック関数を呼び出すのでしょうか?答えはイベントループです。
、コールバック関数を使用してI/Oデータを処理するため、コールバック関数をいつどのように呼び出すかという問題が必然的に発生します。実際の開発では、複数の種類の非同期 I/O 呼び出しシナリオが含まれることがよくありますが、これらの非同期 I/O コールバックの呼び出しをどのように合理的に配置し、非同期コールバックの秩序ある進行を保証するかは難しい問題です。非同期 I/O /O に加えて、タイマーなどの非 I/O 非同期呼び出しもあります。このような API はリアルタイム性が高く、それに応じて優先順位が高くなります。
したがって、さまざまな優先度とタイプの非同期タスクを調整して、これらのタスクがメインスレッド上で順序どおりに実行されるようにするためのスケジューリング メカニズムが必要です。ブラウザと同様に、Node はこの面倒な作業を行うためにイベント ループを選択しました。
ノードは、タスクをタイプと優先度に応じて 7 つのカテゴリ (タイマー、保留中、アイドル、準備、ポーリング、チェック、クローズ) に分類します。タスクの種類ごとに、タスクとそのコールバックを格納する先入れ先出しタスク キューがあります (タイマーは上部の小さなヒープに格納されます)。これら 7 つのタイプに基づいて、Node はイベント ループの実行を次の 7 つのステージに分割します。
このステージのの実行優先順位
が最も高くなります。この段階で、イベント ループはタイマーを格納するデータ構造 (最小ヒープ) をチェックし、その中のタイマーを走査し、現在時刻と有効期限を 1 つずつ比較し、タイマーが期限切れになっているかどうかを判断します。 、タイマーはコールバック関数を取り出して実行します。
フェーズでは、ネットワーク、IO、その他の例外が発生したときにコールバックが実行されます。 *nix
によって報告された一部のエラーはこの段階で処理されます。さらに、前のサイクルのポーリング フェーズで実行される必要がある一部の I/O コールバックは、このフェーズに延期されます。
フェーズはイベント ループ内でのみ使用されます。
新しい I/O イベントを取得し、I/O 関連のコールバックを実行します (シャットダウン コールバック、タイマー スケジュールされたコールバック、およびsetImmediate()
を除くほぼすべてのコールバック)。ノードは適切なタイミングでここでブロックされます。
ポーリング、つまりポーリング段階は、イベント ループの最も重要な段階であり、主にネットワーク I/O とファイル I/O のコールバックがこの段階で処理されます。このステージには 2 つの主な機能があります。1 つは、
このステージがブロックする時間を計算することと、I/O をポーリングすることです。
I/O キュー内のコールバックを処理します。
イベント ループがポーリング フェーズに入り、タイマーが設定されていない場合:
ポーリング キューが空でない場合、イベント ループはキューを横断し、キューが空になるか、実行可能な最大数に達するまで同期して実行されます。
ポーリング キューが空の場合は、他の 2 つのうちのいずれかが起こります。
実行する必要があるsetImmediate()
コールバックがある場合、ポーリング フェーズは直ちに終了し、コールバックを実行するためにチェック フェーズに入ります。
実行するsetImmediate()
コールバックがない場合、イベント ループはこのフェーズに留まり、コールバックがキューに追加されるのを待ち、すぐに実行されます。イベント ループは、タイムアウトが経過するまで待機します。ここで停止する理由は、Node が主に IO を処理するため、よりタイムリーに IO に応答できるようにするためです。
ポーリング キューが空になると、イベント ループは時間しきい値に達したタイマーをチェックします。 1 つ以上のタイマーが時間しきい値に達すると、イベント ループはタイマー フェーズに戻り、これらのタイマーのコールバックを実行します。
フェーズでは、 setImmediate()
のコールバックが順番に実行されます。
このフェーズではsocket.on('close', ...)
など、リソースを閉じるためのいくつかのコールバックが実行されます。このステージの実行が遅れても影響はほとんどなく、優先度は最も低くなります。
ノード プロセスが開始されると、イベント ループが初期化され、ユーザーの入力コードが実行され、対応する非同期 API 呼び出し、タイマー スケジュールなどが行われ、イベント ループへの入りが開始されます
。 ── ─ ─ ─ ─ ─ ┐ ┌─>│ タイマー │ │ └─────────┬─────────┘ │ ┌─────────┴─────────┐ │ │ 保留中のコールバック │ │ └─────────┬─────────┘ │ ┌─────────┴─────────┐ │ │ アイドル、準備 │ │ ━─────────┬───────┘ ┌───────┐ │ ┌─────────┴───────┐ │ 受信: │ │ │ ポーリング │<─────┤ 接続、 │ │ └─────────┬─────────┘ │ データ等 │ │ ┌─────────┴───────┐ └─────────┘ │ │ 確認する │ │ └─────────┬─────────┘ │ ┌─────────┴─────────┐ └─┤ コールバックを閉じる │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┘
イベント ループ (ティックと呼ばれることが多い) の各繰り返しは上記のとおりです。順序は 7 つの実行ステージに入ります。各ステージでは、キュー内の特定の数のコールバックが実行されます。すべてが実行されるわけではないのは、現在のステージの実行時間が長すぎるのを防ぐためです。次のステージの失敗を回避します。
以上がイベント ループの基本的な実行フローです。では、別の質問を見てみましょう。
次のシナリオの場合:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
サービスがポート 8000 に正常にバインドされたとき、つまりlisten()
が正常に呼び出されたとき、 listening
イベントのコールバックはまだバインドされていません。ポートが正常にバインドされた後は、渡したlistening
イベントのコールバックは実行されません。
別の質問について考えてみると、開発中に、エラーの処理、不要なリソースのクリーンアップ、その他の優先度の低いタスクなどのニーズが発生する可能性があります。これらのロジックが同期的に実行されると、現在のタスクの実行効率に影響します。 setImmediate()
がコールバックなどの非同期で渡される場合、実行タイミングが保証されず、リアルタイム性が高くありません。では、これらのロジックにどのように対処すればよいのでしょうか?
これらの問題に基づいて、Node はブラウザから参照を取得し、一連のマイクロタスク メカニズムを実装しました。 Node では、 new Promise().then()
渡されたコールバック関数もマイクロタスクにカプセル化され、 process.nextTick()
のコールバックもマイクロタスクにカプセル化され、その実行優先順位もマイクロタスクにカプセル化されます。後者の方が前者よりも高くなります。
マイクロタスクでは、イベントループの実行プロセスはどのようなものになるでしょうか?言い換えれば、マイクロタスクはいつ実行されるのでしょうか?
ノード 11 以降のバージョンでは、ステージ内のタスクが実行されると、マイクロタスク キューがすぐに実行され、キューはクリアされます。
マイクロタスクの実行は、ノード 11 の前にステージが実行された後に開始されます。
したがって、マイクロタスクでは、イベント ループの各サイクルはまずタイマー ステージでタスクを実行し、次にprocess.nextTick()
とnew Promise().then()
のマイクロタスク キューを順番にクリアしてから実行を継続します。タイマー ステージの次のタスク、または次のステージ、つまり保留ステージのタスクなどの順序で続きます。
process.nextTick()
を使用すると、ノードは上記のポート バインディングの問題を解決できます。次の擬似図に示すように、 listen()
メソッド内で、 listening
イベントの発行がコールバックにカプセル化され、 process.nextTick()
に渡されます。コード:
関数 listen() { // リスニングポート操作を実行します... // `listening` イベントの発行をコールバックにカプセル化し、それを process.nextTick(() => { の `process.nextTick()` に渡します) Emit('listening'); });現在のコードが実行された後
、
マイクロタスクの実行が開始され、それによってlistening
イベントが発行され、イベント コールバックの呼び出しがトリガーされます。
非同期自体の予測不可能性と複雑さのため、Node が提供する非同期 API を使用する過程で、イベント ループの実行原理は習得しましたが、直感的ではない、または予期しない現象が依然として発生する可能性があります。 。
たとえば、タイマー ( setTimeout
、 setImmediate
) の実行順序は、呼び出されるコンテキストによって異なります。両方がトップレベルのコンテキストから呼び出される場合、その実行時間はプロセスまたはマシンのパフォーマンスによって異なります。
次の例を見てみましょう:
setTimeout(() => { console.log('タイムアウト'); }, 0); setImmediate(() => { console.log('即時'); });
上記のコードの実行結果はどうなるでしょうか?イベント ループの説明によると、次のような答えが得られるかもしれません。タイマー フェーズはチェック フェーズの前に実行されるため、 setTimeout()
のコールバックが最初に実行され、次にsetImmediate()
のコールバックが実行されます。実行されました。
実際には、このコードの出力結果はタイムアウトが先に出力される場合もあれば、即時が先に出力される場合もあります。これは、両方のタイマーがグローバル コンテキストで呼び出され、イベント ループが実行を開始してタイマー ステージまで実行されるとき、マシンの実行パフォーマンスに応じて、現在の時間が 1 ミリ秒より大きくなる場合もあれば、1 ミリ秒より小さくなる場合があるためです。実際には、タイマーの最初の段階でsetTimeout()
不確実であるため、異なる出力結果が表示されます。
( delay
の値 ( setTimeout
の 2 番目のパラメーター) が2147483647
より大きいか1
より小さい場合、 delay
1
に設定されます。)
次のコードを見てみましょう。
const fs = require('fs'); fs.readFile(__ファイル名, () => { setTimeout(() => { console.log('タイムアウト'); }, 0); setImmediate(() => { console.log('即時'); });このコードでは、両方のタイマーがコールバック関数にカプセル化され、 readFile に渡されること
が
readFile
ます。コールバックが呼び出されるとき、現在の時刻は 1 ミリ秒より大きくなければならないため、 setTimeout
のコールバックは次のようになります。 setImmediate
コールバックが最初に呼び出されるため、出力される結果はtimeout immediate
なります。
以上がNode.jsを利用する際に注意すべきタイマーに関する事項です。さらに、 process.nextTick()
、 new Promise().then()
、 setImmediate()
の実行順序にも注意する必要があります。この部分は比較的単純なので、前に説明したので繰り返しません。 。
: この記事では、非同期が必要な理由と非同期を実装する方法の 2 つの観点から Node イベント ループの実装原理をより詳細に説明し、関連する注意が必要な事項についても言及しています。あなた。