ホームページ > 記事 > ウェブフロントエンド > ノードの非同期ループとイベントループの基礎となる実装と実行メカニズムについて詳しく話しましょう
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 呼び出しのステータスを継続的にクエリする必要はありません。代わりに、次のタスクを直接処理できます。 .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 自体によって実装されます。
*nix プラットフォームの違いにより、Node は抽象カプセル化層として libuv を提供するため、すべてのプラットフォーム互換性の判断はこの層によって完了し、上位 Nodeこれは、基礎となるカスタム スレッド プールおよび IOCP から独立しています。 Node はコンパイル中にプラットフォームの条件を決定し、unix ディレクトリまたは win ディレクトリ内のソース ファイルを選択的にターゲット プログラムにコンパイルします。
UV_THREADPOOL_SIZE で設定できます。デフォルト値は 4 です。ユーザーは実際の状況に基づいてこの値のサイズを調整できます。)
*nix によって報告された一部のエラーは、この段階で処理されます。さらに、前のサイクルのポーリング フェーズで実行される必要がある一部の I/O コールバックは、このフェーズに延期されます。
检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器调度的回调和 之外几乎所有回调setImmediate()
);节点会在适当的时候阻塞在这里。
poll,即轮询阶段是事件循环最重要的阶段,网络 I/O、文件 I/O 的回调都主要在这个阶段被处理。该阶段有两个主要功能:
计算该阶段应该阻塞和轮询 I/O 的时间。
处理 I/O 队列中的回调。
当事件循环进入 poll 阶段并且没有设置定时器时:
如果轮询队列不为空,则事件循环将遍历该队列,同步地执行它们,直到队列为空或达到可执行的最大数量。
如果轮询队列为空,则会发生另外两种情况之一:
如果有 setImmediate()
回调需要执行,则立即结束 poll 阶段,并进入 check 阶段以执行回调。
如果没有 setImmediate()
回调需要执行,事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待。之所以选择停留在这里是因为 Node 主要是处理 IO 的,这样可以更及时地响应 IO。
一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果有一个或多个定时器达到时间阈值,事件循环将回到 timers 阶段以执行这些定时器的回调。
该阶段会依次执行 setImmediate()
的回调。
该阶段会执行一些关闭资源的回调,如 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 的过程中,尽管我们已经掌握了事件循环的执行原理,但是仍可能会有一些不符合直觉或预期的现象产生。
比如定时器(setTimeout
、setImmediate
)的执行顺序会因为调用它们的上下文而有所不同。如果两者都是从顶层上下文中调用的,那么它们的执行时间取决于进程或机器的性能。
我们来看以下这个例子:
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
以上代码的执行结果是什么呢?按照我们刚才对事件循环的描述,你可能会有这样的答案:由于 timers 阶段会比 check 阶段先执行,因此 setTimeout()
的回调会先执行,然后再执行 setImmediate()
的回调。
实际上,这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate。这是因为这两个定时器都是在全局上下文中调用的,当事件循环开始运行并执行到 timers 阶段时,当前时间可能大于 1 ms,也可能不足 1 ms,具体取决于机器的执行性能,因此 setTimeout()
在第一个 timers 阶段是否会被执行实际上是不确定的,因此才会出现不同的输出结果。
(当 delay
(setTimeout
的第二个参数)的值大于 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 サイトの他の関連記事を参照してください。