イベント ループとは何ですか?
イベント ループを使用すると、JavaScript がシングルスレッドであるにもかかわらず、システムによって実装された場合に操作をオペレーターに引き渡すことによって、Node.js がノンブロッキング I/O 操作を実行できるようになります。カーネル。
最新のシステム カーネルのほとんどはマルチスレッドであるため、カーネルはバックグラウンドで実行される複数の操作を処理できます。いずれかの操作が完了すると、カーネルは Node.js に通知し、対応するコールバックがポーリング キューに追加され、最終的に実行されます。このトピックでは、これについて後ほど詳しく説明します。
イベント ループ
Node.js は最初にイベント ループを初期化し、スクリプトは非同期 API 呼び出し、スケジュールされたタスク、または process.nextTick() を実行してから、イベントループ。
次の表は、イベント ループの一連の操作を簡単に説明しています。
注: 各ボックスはイベント ループのステージを表します。
各ステージには、実行する必要があるコールバック関数の先入れ先出し (FIFO) キューがあります。同時に、各ステージは特別です。基本的に、イベント ループが特定のステージに到達すると、そのステージに固有の操作が実行され、キューが空になるまでそのステージのキュー内のコールバックが実行されます。実行制限に達しました。この時点で、イベント ループは次の段階に入り、サイクルが繰り返されます。
これらの操作によりスケジュールされたタスク操作がさらに生成される可能性があり、ポーリング フェーズで処理された新しいイベントがカーネル キューに追加されるため、ポーリング イベントが処理されるときに新しいポーリング イベントが追加されます。したがって、コールバック タスクが長期間続くと、ポーリング フェーズがタイマーのしきい値を超えることになります。 詳細については、タイマーと投票のセクションを参照してください。
注: Windows と Unix/Linux の実装には若干の矛盾がありますが、今の説明には影響しません。 最も重要な部分がそこにあります。実際には 7 つまたは 8 つのステージがありますが、私たちが注目しているのは、Node.js が実際に使用しているものであり、上記のステージです。
フェーズの概要
タイマー (タイマー): このステージは、計画された setTimeout() および setInterval() コールバックを実行します
I/O コールバック: 発生するほぼすべてのクローズ例外を実行します コールバック、タイマーによってスケジュールされたコールバックsetImmediate();
idle、prepare (アイドル、準備): 内部使用のみ;
polling (ポーリング): この時点で、nodejs は適切に処理されます。
check: setImmediate へのコールバック();
close コールバック: たとえば、socket.on('close', ... );
イベント ループの実行の間に、待機中の非同期 I/O またはタイマーがあるかどうかを確認します。そうではなく、クリアして終了します。
フェーズの詳細
タイマー (タイマー)
タイマーの目的は、特定のしきい値の後に指定されたコールバック関数を実行できるようにすることです。特定の実行時間は、必ずしも正確なしきい値である必要はありません。タイマー コールバックは、指定された時間が経過するとすぐに実行されますが、オペレーティング システムのスケジュールや他のコールバックの実行により、コールバックの実行が遅れる場合があります。
注: 技術的には、ポーリング フェーズはタイマーの実行タイミングを制御します。
たとえば、100 ミリ秒後に実行される操作を設定すると、スクリプトは 95 ミリ秒かかるファイル読み取り操作の実行を開始します。
var fs = require('fs'); function someAsyncOperation (callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } var timeoutScheduled = Date.now(); setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled"); }, 100); // do someAsyncOperation which takes 95 ms to completesomeAsyncOperation(function () { var startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { ; // do nothing } });
イベント ループがポーリング フェーズに入ると、キューは空になります (fs .readFile() はまだ完了していないため、最速のタイマーを実行する必要があるまで時間は経過し続けます。 95 ミリ秒後、fs.readFile() はファイルの読み取りを完了し、そのコールバックがポーリング キューに追加されます。このコールバックは 10 ミリ秒間実行する必要があります。このコールバックが実行され、キューにコールバックがなくなると、イベント ループは最後に期限切れになったタイマーを確認し、タイマー フェーズに戻って前のタイマー コールバックを実行します。
この例では、タイマーの定義とコールバックの実行の間に 105 ミリ秒が経過しました。
注: ポーリングフェーズが長くなりすぎないように、libuv はオペレーティングシステムに応じてポーリングの上限を設定します。
I/O コールバック
このステージでは、TCP エラーなどのシステム操作のコールバックを実行します。たとえば、TCP ソケットが接続しようとしたときに ECONNREFUSED エラーを受け取った場合、一部の *nix システムはこのエラーの報告を待機します。これはこの段階でキューに入れられます。
ポーリング
ポーリングフェーズには 2 つの主な機能があります:
1、期限切れになったタイマー スクリプトを実行し、次に
2、ポーリング キュー内のイベントを処理します。
イベント ループがポーリング フェーズに入ったが、タイマーが見つからない場合:
ポーリング キューが空でない場合、イベント ループはコールバック キューを繰り返し、キューが空になるか上限に達するまでコールバックを同期的に実行します。制限 (前述のとおり、上限はオペレーティング システムごとに設定されます)。
如果轮询队列是空的:
如果有setImmediate()定义了回调,那么事件循环会终止轮询阶段并进入检查阶段去执行定时器回调;
如果没有setImmediate(),事件回调会等待回调被加入队列并立即执行。
一旦轮询队列空了,事件循环会查找已经到时的定时器。如果找到了,事件循环就回到定时器阶段去执行回调。
检查(check)
这个阶段允许回调函数在轮询阶段完成后立即执行。如果轮询阶段空闲了,并且有回调已经被 setImmediate() 加入队列,事件循环会进入检查阶段而不是在轮询阶段等待。
setImmediate() 是个特殊的定时器,在事件循环中一个单独的阶段运行。它使用libuv的API 来使得回调函数在轮询阶段完成后执行。
基本上,随着代码的执行,事件循环会最终进入到等待状态的轮询阶段,可能是等待一个连接、请求等。然而,如果有一个setImmediate() 设置了一个回调并且轮询阶段空闲了,那么事件循环会进入到检查阶段而不是等待轮询事件。 ---- 这车轱辘话说来说去的
关闭事件的回调(close callbacks)
如果一个 socket 或句柄(handle)被突然关闭(is closed abruptly),例如 socket.destroy(), 'close' 事件会被发出到这个阶段。否则这种事件会通过 process.nextTick() 被发出。
setImmediate() vs setTimeout()
这两个很相似,但调用时机会的不同会导致它们不同的表现。
setImmediate() 被设计成一旦轮询阶段完成就执行回调函数;
setTimeout() 规划了在某个时间值过后执行回调函数;
这两个执行的顺序会因为它们被调用时的上下文而有所不同。如果都是在主模块调用,那么它们会受到进程性能的影响(运行在本机的其他程序会影响它们)。
例如,如果我们在非 I/O 循环中运行下面的脚本(即在主模块中),他俩的顺序是不固定的,因为会受到进程性能的影响:
// timeout_vs_immediate.jssetTimeout(function timeout () { console.log('timeout'); },0); setImmediate(function immediate () { console.log('immediate'); });
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是如果把它们放进 I/O 循环中,setImmediate() 的回调总是先执行:
// timeout_vs_immediate.jsvar 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() 没有在上面那个表格里出现,虽然它确实是一个异步API。这是因为它技术上不属于事件循环。然而,nextTickQueue 会在当前操作结束后被处理,不管是在事件循环的哪个阶段。
回头看看之前那个表格,你在某个阶段的任何时候调用它,它的所有回调函数都会在事件循环继续进行之前得到处理。有时候这会导致比较糟糕的情况,因为它允许你用递归调用的方式去“阻塞” I/O,这会让事件循环无法进入到轮询阶段。
为什么要允许这样
部分是因为 Node.js 的设计哲学:API 应该总是异步的,即使本不需要是异步。
blablabla,后面几段看的我有点尴尬+晕。既尴尬又晕是觉得这几段说的有点啰嗦,而且举的例子不合适。例子要么是同步的,不是异步的。要么是例子里的写法完全可以避免,比如应该先添加 'connect' 事件监听再进行 .connect() 操作;又或者变量声明最好放在变量使用之前,可以避免变量的提前声明和当时赋值的麻烦。
难道是我没理解里面的秘辛?
process.nextTick() vs setTimeout()
这两个函数有些相似但是名字让人困惑:
process.netxtTick() 在事件循环的当前阶段立即生效;
setImmediate() 生效是在接下来的迭代或者事件循环的下一次tick;
本质上,它们的名字应该互换一下。process.nextTick() 比 setImmediate() 更“立刻”执行,但这是个历史问题没法改变。如果改了,npm上大堆的包就要挂了。
我们推荐开发者在所有情况下都使用 setImmediate() 因为它更显而易见(reason about),另外兼容性也更广,例如浏览器端。
为什么使用 process.nextTick()
有两大原因:
允许用户处理错误,清理不需要的资源,或许在事件循环结束前再次尝试发送请求;
必须让回调函数在调用栈已经清除(unwound)后并且事件循环继续下去之前执行;
下面的两个例子都是类似的,即在 line1 派发事件,却在 line2 才添加监听,因此监听的回调是不可能被执行到的。
于是可以用 process.nextTick() 使得当前调用栈先执行完毕,也即先执行 line2 注册事件监听,然后在 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(function () { this.emit('event'); }.bind(this)); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', function() { console.log('an event occurred!'); });
翻译总结:
这篇文章写的不太简练,也可能为了有更多的受众吧,我感觉车轱辘话比较多,一个意思要说好几遍。
从编程应用的角度简单来说:
Node.js 中的事件循环大概有七八个阶段,每个阶段都有自己的队列(queue),需要等本阶段的队列处理完成后才进入其他阶段。阶段之间会互相转换,循环顺序并不是完全固定的 ,因为很多阶段是由外部的事件触发的。
其中比较重要的是三个:
定时器阶段 timers:
定时器阶段执行定时器任务(setTimeOut(), setInterval())。
轮询阶段 poll:
轮询阶段由 I/O 事件触发,例如 'connect','data' 等。这是比较重/重要的阶段,因为大部分程序功能就是为了 I/O 数据。
本阶段会处理定时器任务和 poll 队列中的任务,具体逻辑:
如果有 setImmediate(),终止轮询阶段并进入检查阶段去执行;
如果没有 setImmediate(),那么就查看有没有到期的定时器,有的话就回到定时器阶段执行回调函数;
处理到期的定时器任务,然后
处理队列任务,直到队列空了或者达到上限
如果队列任务没了:
检查阶段 check:
当轮询阶段空闲并且已经有 setImmediate() 的时候,会进入检查阶段并执行。
比较次要但也列在表格中的两个:
I/O 阶段:
本阶段处理 I/O 异常错误;
'close'事件回调:
本阶段处理各种 'close' 事件回调;
关于 setTimeout(), setImmediate(), process.nextTick():
setTimeout() 在某个时间值过后尽快执行回调函数;
setImmediate() 一旦轮询阶段完成就执行回调函数;
process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;
优先级顺序从高到低: process.nextTick() > setImmediate() > setTimeout()
注:这里只是多数情况下,即轮询阶段(I/O 回调中)。比如之前比较 setImmediate() 和 setTimeout() 的时候就区分了所处阶段/上下文。
另:
关于调用栈,事件循环还可以参考这篇文章:
https://blog.risingstack.com/node-js-at-scale-understanding-node-js-event-loop/
这篇文章里对事件任务区分了大任务(macro task) 、小任务(micro task),每个事件循环只处理一个大任务 ,但会处理完所有小任务。
这一点和前面的文章说的不同。
examples of microtasks:
process.nextTick
promises
Object.observe
examples of macrotasks:
setTimeout
setInterval
setImmediate
I/O