Node.js 中的 Event Loop
nodejs.org
Last updated
nodejs.org
Last updated
Node.js 使用 event loop 来执行 non-blocking I/O 操作,尽管 JavaScript 是 single-threaded。event loop 会尽可能地将操作 offload(卸载/转移/减轻负担)到 system kernel(系统内核)来执行。
由于大多数现代 kernels(内核)都是 multi-threaded(多线程的),因此它们可以在后台执行多个操作。当其中一个操作完成时,kernel 会告诉 Node.js 以便它将合适的 callback 添加到 poll queue 中等待最终执行。
当 Node.js 启动时,会初始化 event loop 以处理提供的 input script,这些输入脚本会进行 async API calls(异步 API 调用), schedule timers(调度计时器)或调用 process.nextTick()
。
下图简要地概况了 event loop 的操作顺序:
上图中的每个 box 都被称为 event loop 的一个 phase(阶段)。
timers:该 phase 执行由 setTimeout()
和 setInterval()
安排的 callbacks
pending callbacks:执行延迟到下一个循环迭代的 I/O callbacks
idle, prepare:仅在内部使用
poll(选举投票/计票/轮询):检索新的 I/O events、执行 I/O 相关的 callbacks(除了 1-5-6 之外的所有 callbacks)、node 会在适当的时候 block(阻塞)在这里
check:该 phase 执行由 setImmediate()
安排的 callbacks
close callbacks:一些关闭回调,比如 socket.on('close', ...)
每个 phase 都有一个待执行的 FIFO(先进先出)的 callbacks queue。通常,当 event loop 进入给定的 phase,就会执行该 phase 对应的 queue 里的 callbacks,直到 queue 为空或执行的 callbacks 数量达到最大值。然后 event loop 会进入下一个 phase,依此类推。
由于这些操作都可能调度更多的操作,而且在 poll phase 处理的新 events 是由 kernel 排进来的,当处理 polling events 的时候 poll events 还能再排进来。因此,long running callbacks 可以允许 poll phase 的运行时间比 timer 的阈值长得多。
timer 会指定一个 threshold(阈值),在该 threshold 之后,提供的 callback “可能”被执行,而不是它被执行的 exact time。
timers callbacks 会在指定的时间之后尽早运行,但是,Operating System scheduling(操作系统调度)或其它 callbacks running(回调运行)都有可能会延迟它们。从技术上讲,poll phase 控制着 timers 什么时候执行。
看个例子:
上面的代码,当 event loop 进入 poll phase,其 queue 为空(因为 fs.readFile
还没执行完)。在等了 95ms 之后,fs.readFile()
完成了文件读取,同时将需要 10ms 才能完成的 callback 添加到 poll queue 并被执行。当执行完 callback 的时候,queue 中再没其它 callbacks 了,因此 event loop 会看到 the soonest timer 的 threshold 已经达到了,然后 wrap back(返回到)timers phase 去执行 timer 的 callback。在这个例子中,我们将会看到从 timer 被 scheduled(调度)到它的 callback 被 executed(执行)一共 delay(延迟)了 105 毫秒。
为了防止 poll phase 让 event loop 挨饿(starving),libuv
(实现 Node.js event loop 和平台所有异步行为的 C 库)也有一个 hard maximum(硬最大值,取决于系统)在它停止 polling(轮询)更多 events 之前。
此 phase 执行某些 system operations 的 callbacks,比如 TCP 错误类型。举个例子:如果 TCP socket 在尝试连接时收到了 ECONNREFUSED
,一些 *nix 系统想要等待报告错误,就会在 pending callbacks 阶段排队等待执行。
poll phase 有两个主要功能:计算它会 block 并轮询 I/O 多长时间,然后处理 poll queue 中的 events。
当 event loop 进入 poll phase 并且没有 timers scheduled(定时器被调度)时,会发生以下两种情况之一:
如果 poll queue 不空,event loop 将遍历其 callbacks queue 并同步执行它们,直到 queue 执行完了或者达到 system-dependent hard limit(系统相关的硬限制)
如果 poll queue 为空,会发生以下两种情况之一
如果 scripts 已经被 setImmediate()
scheduled(调度),那么 event loop 将结束 poll phase 并继续 check phase 以执行相关 scripts
如果 scripts 还没有被 setImmediate()
scheduled(调度),那么 event loop 将等待被添加到 queue 中的 callbacks,然后立即执行它们
一旦 poll queue 为空,event loop 将检查 timers,看谁的 time thresholds 已经达到了。如果一个或多个 timers 已经准备就绪,那么 event loop 将 wrap back(返回)到 timers phase 然后执行那些 timers 的 callbacks。
该 phase 允许开发者在 poll phase 完成之后立即执行 callbacks。 如果 poll phase 变成 idle(空闲的了)并且 scripts 已经用 setImmediate()
排上队了,那么 event loop 可能会继续 check phase 而不是等待。
setImmediate()
实际上是一个特殊的 timer,运行在 event loop 中的一个独立阶段。它使用 libuv API 来 schedule callbacks(安排回调)在 poll phase 完成后执行。
通常,随着代码的执行,event loop 最终会进入 poll phase,在那里等待传入的 connection 和 request 等。但是,如果由 setImmediate()
安排了个回调且 poll phase 空闲,那么它会停止并进入 check phase 而不是继续等待 poll events。
如果 socket(套接字)或 handle(句柄)突然关闭(比如 socket.destroy()
), close
event 就会在此 phase 发出,否则会通过 process.nextTick()
触发。
setImmediate()
vs setTimeout()
setImmediate()
和 setTimeout()
类似,但是被调用的时间不同:
setImmediate()
:一旦当前的 poll phase 完成之后,就执行该 script
setTimeout()
:在以 ms 为单位的 minimum threshold 过去之后,才运行
timers 的执行顺序会根据调用它们的 context 而有所不同。如果它两都是从 main module 中调用的,那么 timing 将受到 process(进程)性能的限制(这可能会受到机器上运行的其它应用程序的影响)。
比如,如果下面的代码是在 main module(即不在 I/O 周期内),那么这两个 timers 的执行顺序是不确定的,因为它受 process(进程)性能的约束。
但是,如果是在 I/O cycle(周期)里调用,那么 immediate callback 总是会先执行。
与 setTimeout()
相比,setImmediate()
的主要优点是:如果是在 I/O cycle 内调度,setImmediate()
将始终在任何 timers 之前执行,与存在多少个 timers 无关。
process.nextTick()
上面的图中没有显示 process.nextTick()
,尽管它是 asynchronous API 的一部分。这是因为 process.nextTick()
在技术上不是 event loop 的一部分,nextTickQueue
是在当前 operation(操作)完成之后处理的,而不管 event loop 的 current phase。在这里,operation 被定义为从 underlying(底层)C/C++ handler(处理程序)的转换,并处理要执行的 JavaScript。
任何时候在 given phase 调用 process.nextTick()
时,传递给 process.nextTick()
的所有 callbacks 都将在event loop 继续之前得到 resolved(解决)。这会造成一些糟糕的情况,因为在递归 process.nextTick()
call 时可能会饿死(starve)I/O,而这会阻止 event loop 到达 poll phase。
Node.js 中之所以包含这样的逻辑,它的一部分是一种 design philosophy(设计理念),即 API 应该始终是 asynchronous 的,即使在不必要的地方。比如以下代码片段:
通过使用 process.nextTick()
,我们保证 apiCall()
回调的执行时机始终在用户代码的其余部分之后、event loop 继续之前。为实现这一点,允许 JS call stack(调用堆栈)展开,然后立即执行提供的 callback——允许对 process.nextTick()
进行 recursive calls(递归调用)而不会达到 RangeError: Maximum call stack size exceeded from v8
。
这种理念可能会导致一些潜在的问题情况(potentially problematic situations)。比如以下代码:
第 8 行,提供给 someAsyncApiCall
的 callback 会在 event loop 的 same phase 中调用,因为 someAsyncApiCall()
实际上并不是异步执行的。结果就是第 9 行会输出 undefined
,因为它后面的脚本还没执行。
做个修改:把 callback 放在 process.nextTick()
中。如下:
好处是不允许 event loop 继续(或是回调会在 event loop 继续之前执行)。我们可以在 event loop 继续之前 alert error 给用户。
??? 没懂这个例子的意思,如果换成 setImmediate 或 setTimeout 或其它异步形式,不也是可以输出 1 吗?所以根本是在哪里?..... 更快地执行?那举的例子也不恰当啊....
还有一个真实世界的例子:
当只传了一个 port 参数时,会立即绑定该 port,所以可以立即调用 listening
callback,但是问题是 .on('listening')
callback 那时还没有设置。为了解决这个问题,listening
event 在 nextTick()
中排队,以允许脚本运行完成。
??? 即便是在 nextTick() 中排队,那它上面的 listen(8080) 依然是在还是没 callback 啊
process.nextTick()
与 setImmediate()
process.nextTick()
是在 same phase 中立即触发
setImmediate()
是在 event loop 的下一个迭代或 tick 中触发
本质上,它们两个的名字应该交换下。process.nextTick()
比 setImmediate()
更 immediately(立即)触发。但是考虑到历史包袱,改名字已经不太可能了。
建议开发人员在所有情况下都使用 setImmediate()
,因为它更容易推理。
process.nextTick()
主要有两个原因:
允许用户处理错误、清理任何不需要的资源,或者在 event loop 继续之前再次尝试 request
有时有必要允许 callback 的运行时机是在 call stack(调用堆栈)展开之后 & 在 event loop 继续之前
比如:
假设 listen()
是在 event loop 开始的时候运行,但 listening callback 是在 setImmediate()
中。除非参数也传递了 hostname,否则将立即绑定到给定的 port。为了让 event loop 继续,它必须到达 poll phase,这就意味着有一个 non-zero(非零)机会可以接收到 connection,从而允许在 listening event 之前触发 connection event。
另一个例子是扩展 EventEmitter
并从 constructor 中 emit event:
在这里可以使用 process.nextTick()
来设置 callback 以便以在 constructor 完成之后来 emit event。否则,script 还没处理到 user 给该 event 分配 callback 的代码。