本文同步发表于 我的博客
首先所有平台,不论是浏览器还是 nodejs
的 JS
事件循环都不是由 ECMA 262
规范定义。事件循环并不是 ECMA 262
规范的一部分。浏览器端的事件循环由 Web API
中定义,并由 W3C
和 HTML living standard
来维护。而 nodejs
是基于 libuv
的事件循环,其并没有一个事件循环规范标准,那么了解 nodejs
事件循环的最好方式就是 nodejs
的源码和官方文档和 libuv
的源码和官方文档。
文章中引用的参考尽可能选取官方文档、nodejs/libuv
仓库,nodejs/libuv
贡献者解答,google/microsoft
工程师,高赞stackoverflow
回答等来源。
事件循环概述
根据 nodejs
官方文档,在通常情况下,nodejs
中的事件循环根据不同的操作系统可能存在特殊的阶段,但总体是可以分为以下 6
个阶段:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
-
timer
阶段,用于执行所有通过计时器函数(即setTimeout
和setInterval
)注册的回调函数。 -
pending callbacks
阶段。虽然大部分I/O
回调都是在poll
阶段被立即执行,但是会存在一些被延迟调用的I/O
回调函数。那么此阶段就是为了调用之前事件循环延迟执行的I/O
回调函数。引自在
libuv
的设计文档 the I/O loop - step.4。 -
idle prepare
阶段,仅用于nodejs
内部模块使用。 -
poll
(轮询)阶段,此阶段有两个主要职责:1. 计算当前轮询需要阻塞后续阶段的时间;2. 处理事件回调函数。nodejs
中事件循环中存在一种维持在此阶段的趋势,后文会做详细说明。 -
check
阶段,用于在poll
阶段的回调函数队列为空时,使用setImmediate
实现调度执行特定代码片段。 -
close
回调函数阶段,执行所有注册close
事件的回调函数。
每一个 nodejs
事件循环 tick
总是要经历以上阶段,由 timer
阶段开始,由 close
回调函数阶段结束。每一个阶段都会循环执行当前阶段的回调函数队列,直至队列为空或到达最大可执行回调函数次数。
事件循环实现
据 nodejs
官方文档,nodejs
中的事件循环是依赖于名为 libuv 的 C
语言库实现。本质上 libuv
的执行方式决定了 nodejs
中的事件循环的执行方式。
至本文发布之际,最新 libuv
的版本为 v1.35.0.
Q: libuv
是什么?
A: libuv
是使用 C
语言实现的单线程非阻塞异步 I/O
解决方案,本质上它是对常见操作系统底层异步 I/O
操作的封装,并对外暴露功能一致的 API
, 首要目的是尽可能的为 nodejs
在不同系统平台上提供统一的事件循环模型。
<!-- 在 libuv
中存在对外暴露的两种抽象操作:
-
handles
句柄,表示在存活期间代表执行某种特定操作的长生命周期对象。 -
requests
请求,表示短生命周期操作。这些操作都是可以通过句柄或直接执行。如write request
表示通过句柄来写入数据。 -->
nodejs
的事件循环核心对应 libuv
中的 uv_run 函数,核心逻辑如下:
// http://docs.libuv.org/en/v1.x/loop.html#c.uv_loop_alive
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
// http://docs.libuv.org/en/v1.x/loop.html#c.uv_update_time
uv__update_time(loop);
// timer 阶段
uv__run_timers(loop);
// pending callbacks 阶段
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll 阶段
uv__io_poll(loop, timeout);
// check 阶段
uv__run_check(loop);
// close callbacks 阶段
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
据 libuv
文档中对 IO loop 的描述,原则上,一个线程中至多仅有一个事件循环,在多个线程中可以存在多个并行的事件循环。事件循环遵循常规的单线程异步 I/O
方案。所有的 (网络)I/O
都是在非阻塞的 socket
上执行。这些 socket
使用了如下表给定平台的最佳轮询机制。
mechanism | platform |
---|---|
epoll | Linux |
kqueue | OSX, BSD |
IOCP | Windows |
event ports | SunOS |
单个事件循环 loop
作为整个事件循环迭代 loop iteration
的一部分,它会 阻塞 等待已经添加到 poller
中的 sockets
上的 IO
活动,并间触发对应的回调函数以指示 socket
的条件(即可读,可写,挂起),以便句柄可以读取,写入,或执行所期望的 IO
操作。
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
根据源代码和 libuv
官方文档,事件循环首先会缓存当前事件循环 tick
的开始时间,用于减少时间相关的系统调用。
缓存时间的做法是因为系统内的时间调用会受到系统内其他应用的影响,所以为了尽可能避免其他应用对nodejs
的影响而在事件循环的tick
开始之时缓存时间。
如果事件循环是活动的,那么开始当前事件循环,否则立即退出整个事件循环迭代。那么如何界定一个事件循环迭代是活动的?如果一个事件循环拥有活动的句柄或引用句柄,活动的请求或 closing
句柄,那么该事件循环被认为是活动的。
// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
// ...
}
从以上示例代码不难看出,整个事件循环迭代就是一个 while
无限循环,正是这个 while
语句在不断地推动事件循环的迭代。在每一次循环迭代开始时,都会不断验证当前事件循环 tick
是否是活动的,且没有 stop
标识。在进入循环之后首先会更新当前事件循环的开始时间并继续执行事件循环的各个阶段的回调函数队列。
结合前文对 nodejs
中事件循环的生命周期抽象归纳,不难依据 uv_run()doc 的核心逻辑得出:
-
timer
阶段: uv__run_timers(loop) -
pending callbacks
阶段:uv__run_pending(loop) -
idle
阶段:uv__run_idle(loop) -
poll
阶段:uv__io_poll(loop, timeout) -
check
阶段:uv__run_check(loop) -
close callbacks
阶段:uv__run_closing_handles(loop)
函数定义
timer 阶段
nodejs
事件循环的一个 tick
始终以 timer
阶段开始,其中包含一个由所有 setTimeout
和 setInterval
注册的待执行回调函数队列。此阶段的 核心职责 是执行由所有到达时间阈值的计时器注册的回调函数。
待执行,表示在已经到达计时器的时间阈值时,被加入到 timer
阶段的回调函数队列中等待执行的由计时器注册的回调函数。
值得声明的一点是,不论是在 nodejs
还是 web
浏览器中,所有的计时器实现都 不能保证 在到达时间阈值后回调函数一定会被立即执行,它们只能保证在到达时间阈值后,尽快 执行由计时器注册的回调函数。
const NS_PER_SEC = 1e9
const time = process.hrtime()
// [ 1800216, 25 ]
setTimeout(() => {
const diff = process.hrtime(time)
// [ 1, 552 ]
console.log(`Benchmark took ${diff[0] * NS_PER_SEC + diff[1]} nanoseconds`)
// Benchmark took 1000000552 nanoseconds
}, 1000)
另外,从技术上讲,poll
阶段决定了 timer
回调函数的执行时机。详情可见后文关于 poll 对 timer 的影响 的说明。
libuv 如何调度计时器
如前文所述,timer
阶段对应 libuv
中 C
函数为 uv__run_timers(loop);。且在 uv_run
函数体中对应的核心调用逻辑如下:
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
// ...
}
在开始事件循环的一个 tick
时,总是会首先调用 uv__update_time(loop);
来更新当前事件循环 tick
的开始时间。
UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
/* Use a fast time source if available. We only need millisecond precision.
*/
loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}
此处 uv__hrtime
函数内部包含当前操作系统的暴露的时间相关系统调用。在此处对系统的时间调用时,可能会受到其他其他应用的影响。一旦更新 loop
结构体的 time
后,接着会开始执行 timer
阶段的回调函数队列。如下:
void uv__run_timers(uv_loop_t* loop) {
struct heap_node* heap_node;
uv_timer_t* handle;
for (;;) {
heap_node = heap_min(timer_heap(loop));
if (heap_node == NULL)
break;
// container_of 由 preprocesser 来实现编译前文本替换
// https://github.com/libuv/libuv/blob/v1.35.0/src/uv-common.h#L57-L58
handle = container_of(heap_node, uv_timer_t, heap_node);
if (handle->timeout > loop->time)
break;
// http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_stop
uv_timer_stop(handle);
// http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_again
uv_timer_again(handle);
handle->timer_cb(handle);
}
}
这里值得注意的时,所有计时器在 libuv
中是以计时器回调函数的 执行时间节点(即 time + timeout
,而不是计时器时间阈值) 构成的 二叉最小堆 结构来存储。通过 二叉最小堆
的根节点来获取时间线上最近的 timer
对应的回调函数的句柄,再通过该句柄对应的 timeout
值获取最近的计时器的执行时间节点:
- 当该值大于当前事件循环
tick
的开始时间时,即表示还没有到执行时机,回调函数还不应该被执行。那么根据二叉最小堆的性质,父节点始终比子节点小,那么根节点的时间节点都不满足执行时机的话,其他的timer
时间节点肯定也没有过期。此时,退出timer
阶段的回调函数执行,进入事件循环tick
的下一阶段。 - 当该值小于当前事件循环
tick
的开始时间时,表示至少存在一个过期的计时器,那么循环迭代计时器最小堆的根节点,并调用该计时器所对应的回调函数。每次循环迭代时都会更新最小堆的根节点为最近时间节点的计时器。
nodejs 内置计时器
在现行 nodejs
中,有且仅有两种计时器,其中之一就是是 setTimeout/setInterval
。 在使用 setTimeout/setInterval
时,值得注意的一点是:
时间阈值的取值范围是 1 ~ 231-1 ms,且为整数。
在 nodejs/node 源码中不论是 setTimeout
源码实现 还是 setInterval
源码实现本质上都是内置类 Timeout 的实例,如下:
// Timeout values > TIMEOUT_MAX are set to 1.
const TIMEOUT_MAX = 2 ** 31 - 1
// Timer constructor function.
// The entire prototype is defined in lib/timers.js
function Timeout(callback, after, args, isRepeat, isRefed) {
after *= 1 // Coalesce to number or NaN
if (!(after >= 1 && after <= TIMEOUT_MAX)) {
if (after > TIMEOUT_MAX) {
process.emitWarning(
`${after} does not fit into` +
' a 32-bit signed integer.' +
'\nTimeout duration was set to 1.',
'TimeoutOverflowWarning'
)
}
after = 1 // Schedule on next tick, follows browser behavior
}
this._idleTimeout = after
this._idlePrev = this
this._idleNext = this
this._idleStart = null
// This must be set to null first to avoid function tracking
// on the hidden class, revisit in V8 versions after 6.2
this._onTimeout = nullv
this._onTimeout = callback
this._timerArgs = args
this._repeat = isRepeat ? after : null
this._destroyed = false
if (isRefed) incRefCount()
this[kRefed] = isRefed
initAsyncResource(this, 'Timeout')
}
从构造函数的函数体可见,nodejs
中所有计时器是通过一个 双向链表 实现关联,并且所有超出时间阈值范围的时间阈值都会被 重置为 1ms,且所有非整数值会被转换为 整数值。
那么一种常见的写法 setTimeout(callback, 0)
会被 nodejs
内部模块转换为 setTimeout(callback, 1)
来执行。
pending callbacks
pending callbacks
阶段用于执行先前事件循环 tick
中延迟执行的 I/O
回调函数。
poll 阶段
poll
阶段的首要职责是:
- 计算因处理
I/O
需要阻塞当前事件循环tick
的时间;该阻塞表示当前事件循环tick
应该在当前poll
阶段停留多久,这个时间一般是根据最小的setTimeout/setInterval
的时间阈值等多个因素(见下文)来确定。在到达阻塞时间后,会经历当前事件循环tick
的后续阶段,并最终进入下一个事件循环tick
的timer
阶段,此时,过期的计时器的回调函数得以执行。 - 处理事件回调。
如前文概述,nodejs
中 poll
阶段对应 libuv
中的 核心逻辑 如下:
timeout = 0;
/**
* uv_backend_timeout 用于获取 poll 阶段的超时(阻塞)时间
* http://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_timeout
*/
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout);
在调用 uv__io_poll 之前,首先初始化一个 timeout
变量,该变量在 loop
为常规模式下,将通过 uv_backend_timeout(loop)
定义 来确定 poll
阶段的超时时间,该超时时间也就是 nodejs
文档 中提到的 poll
阶段应该阻塞的时间,那么确定该阻塞时间的具体依据是什么呢?
int uv_backend_timeout(const uv_loop_t* loop) {
// https://github.com/libuv/libuv/blob/v1.35.0/src/uv-common.c#L521-L523
// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
if (loop->stop_flag != 0)
return 0;
if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
return 0;
if (!QUEUE_EMPTY(&loop->idle_handles))
return 0;
if (!QUEUE_EMPTY(&loop->pending_queue))
return 0;
if (loop->closing_handles)
return 0;
return uv__next_timeout(loop);
}
从 uv_backend_timeout
函数体不难看出,该函数根据当前事件循环 tick
的部分属性来确定 poll
阶段的阻塞时间:
- 当事件循环
tick
被 uv_stop()doc 函数标记为停止#时,返回0
,即不阻塞。 - 当事件循环
tick
不处于活动状态时且不存在活动的 request 时返回0
,即不阻塞。 - 当
idle
句柄队列不为空时,返回0
,即不阻塞。 - 当 pending callbacks 的回调队列不为空时,返回
0
,即不阻塞。 - 当存在 closing 句柄,即存在
close
事件回调时,返回0
,即不阻塞。
为什么返回 0
表示不阻塞,而 -1
表示无限制阻塞?
因为从 uv__io_poll 函数体可见 poll
阶段实现轮询的关键点在于各个系统平台的轮询机制。上文中 0
和 -1
分别对应 linux
系统底层轮询机制的轮询参数。
以 linux
的 epoll
轮询机制为例,在 uv__io_poll 函数体中调用了系统底层 epoll_wait 函数来实现 libuv
的轮询核心功能:
nfds = epoll_wait(loop->backend_fd,
events,
ARRAY_SIZE(events),
timeout);
The parameter timeout shall specify the maximum number of milliseconds that epoll_wait() shall wait for events. If the value of this parameter is 0, then epoll_wait() shall return immediately, even if no events are available, in which case the return code shall be 0. If the value of timeout is -1, then epoll_wait() shall block until either a requested event occurs or the call is interrupted.
从 epoll_wait 文档可见,当 timeout
传参为 0
时,将立即返回,当 timeout
传参为 -1
时,将无限制阻塞,直到某个事件触发或无限阻塞状态被主动打断。
回到 timeout
的主题上,在不满足以上不阻塞当前事件循环 tick
的前提下,由 uv__next_timeout 函数来计算最终的 poll
阶段阻塞时间:
int uv__next_timeout(const uv_loop_t* loop) {
const struct heap_node* heap_node;
const uv_timer_t* handle;
uint64_t diff;
// libuv 计时器二叉最小堆的根节点为所有计时器中距离当前时间节点最近的计时器
heap_node = heap_min(timer_heap(loop));
// 此处 true 条件为无限制的阻塞当前 poll 阶段
if (heap_node == NULL)
return -1; /* block indefinitely */
handle = container_of(heap_node, uv_timer_t, heap_node);
// 若最近时间节点的计时器小于等于当前事件循环 `tick` 开始的时间节点
// 那么不阻塞,并进入下一阶段,直至进入下一 `tick` 的 `timer` 阶段执行回调函数
if (handle->timeout <= loop->time)
return 0;
// 如 nodejs 文档中对 poll 阶段计算阻塞时间的描述
// 以下语句用于计算当前 poll 阶段应该阻塞的时间
diff = handle->timeout - loop->time;
// INT_MAX 在 limits.h 头文件中声明
if (diff > INT_MAX)
diff = INT_MAX;
return (int) diff;
}
从以上函数体并结合前文 对计时器的分析 不难看出,通过获取计时器最小堆的根节点得到距离现在最近的计时器执行节点。将该节点与当前事件循环 tick
的开始时间 loop->time
做对比:
- 若不存在任何计时器,那么当前事件循环
tick
中的poll
阶段将 无限制阻塞。以实现一旦存在I/O
回调函数加入到poll queue
中即可立即得到执行。 - 若最近计时器时间节点小于等于开始时间,则表明在计时器二叉最小堆中 至少存在一个 过期的计时器,那么当前
poll
阶段的超时时间将被设置为0
,即表示poll
阶段不发生阻塞。这是为了尽可能快的进入下一阶段,即尽可能快地结束当前事件循环tick
。在进入下一事件循环tick
时,在timer
阶段,上一tick
中过期的计时器回调函数得以执行。 - 若最近计时器时间节点大于开始时间,则计算两个计时器之前的差值,且不大于
int
类型最大值。poll
将根据此差值来阻塞当前阶段,这么做是为了在轮询阶段,尽可能快的处理异步I/O
事件。此时我们也可以理解为 事件循环tick
始终有一种维持在poll
阶段的倾向。
由以上源码分析,不难得出 poll
阶段的本质:
- 为了尽可能快的处理异步
I/O
事件,那么事件循环tick
总有一种维持poll
状态的倾向; - 当前
poll
阶段应该维持(阻塞)多长时间是由 后续tick
各个阶段是否存在不为空的回调函数队列 和 最近的计时器时间节点 决定。若所有队列为空且不存在任何计时器,那么事件循环将 无限制地维持在poll
阶段。
注:因为 poll
阶段的超时时间在进入 poll
阶段之前计算,故当前 poll
阶段中回调函数队列中的计时器并不影响当前 poll
阶段的超时时间。
poll 对 timer 的影响
Nodejs doc:Note: Technically, the poll phase controls when timers are executed.
从技术上来说,poll
阶段控制了计时器的执行时机。为什么这么说?
首先,libuv
的事件循环是无法再入的,并且事件循环总是有一种维持在 poll
阶段的倾向,那么在没有满足 poll
阶段的结束条件时,就无法进入到下一个事件循环 tick
的 timer
阶段,就无法执行 timer queue
中到期计时器的回调函数。所以才会存在 “poll
阶段控制了计时器回调函数的执行时机” 的说法。
另外,无限制的轮询事件和调用回调函数,会导致完全不会清空 poll
的回调函数队列,进而永远都不会发生计时器的阈值检测导致拖垮整个事件循环迭代。libuv
在其内部设定了一个依赖于系统的最大执行数。结合前文对 nodejs
内置计时器 的描述,这也是计时器无法保证准确的执行回调函数,而是尽快的执行回调函数的原因之一。
check 阶段
该阶段的设计目的是可在 poll
阶段结束之时,立即调用指定代码片段(即函数)。如果 poll
阶段进入 idle
状态并且 setImmediate
函数存在回调函数时,那么 poll
阶段将打破无限制的等待状态,并进入 check
阶段执行 check
阶段的回调函数。
check
阶段的回调函数队列中所有的回调函数都是来自 poll
阶段的 setImmediate
函数。
setTimeout vs setImmediate
由前文 nodejs 内置计时器 章节可知,在现行的 nodejs
环境中,有且仅有两种计时器,一种是 setTimeout/setInterval
,另一种是 setImmediate
。
setTimeout/setInterval
设计目的在于经历一段最小时间阈值后尽快调用指定的回调函数。而 setImmediate
是作为特殊的计时器而存在,其设计目的是给予用户能在 poll
阶段结束后(即 check
阶段)能够立即执行代码的机会,而不用在 timer
阶段执行。
实践
结合以上简短介绍,若同时在 user code
的模块词法环境中直接调用 setTimeout
和 setImmediate
会出现什么样的结果?
为什么上文提到在nodejs
中user script
是模块词法环境而不是全局词法环境?可简单通过
console.log(this === module.exports)
(而不是global
) 为true
值判断。
// index.js
setTimeout(
/* setTimeoutCallback */ () => {
console.log('from setTimeout')
},
0
)
setImmediate(
/* setImmediateCallback */ () => {
console.log('from setImmediate')
}
)
以上代码通过 node index.js
命令调用后会出现 无法预测的随机 结果:
from setTimeout
from setImmediate
或
from setImmediate
from setTimeout
为什么会出现这样的现象?
在 nodejs
脚本初始编译运行时,nodejs
会首先以入口 JS
文件为执行入口,那么此时 运行中执行上下文
为当前入口 JS
文件对应的 Script 执行上下文。
如前文所述,setTimeout(callback, 0)
其实是被重置为 setTimeout(callback, 1)
了。那么在首次 user script
代码执行后,即 Script
执行上下文退出执行上下文栈后,并 开始首次 事件循环 tick
[nodejs 贡献者],在第一次进入 timer
阶段时,会抽取 timer
最小堆中的节点对比当前事件循环 tick
的开始时间是否已经过了阈值 1ms
:
-
若在前文
uv__run_timer(loop)
中,系统时间调用和时间比较的过程总耗时没有超过1ms
的话,在timer
阶段会发现没有过期的计时器,setTimeoutCallbacks
同时也并不存在于timer queue
中。那么此时,将继续执行至poll
阶段,而在poll
阶段poll queue
队列为空时,检查check queue
队列并不为空。那么继续进入事件循环tick
的下一阶段,并清空check queue
中由setImmediate
注册的setImmediateCallback
回调函数。在经历后续的事件循环tick
并重新开始时,会发现先前的阈值为1ms
的过期计时器,此时的setTimeoutCallback
才得以加入timer queue
并得以在当前timer
阶段执行。控制台的输出如下:
from setImmediate from setTimeout
-
若在上文源码中,系统时间调用和时间比较的过程总耗时超过
1ms
的话,那么会将过期计时器的setTimeoutCallback
加入到timer queue
中,并进入timer queue
的调用阶段。后续控制台输出如下:from setTimeout from setImmediate
那么从上文针对 libuv
的 uv__run_timers 函数的分析可见,在 user script
的模块词法环境中直接同时调用 setTimeout(callback, 0)
和 setImmediate(callback)
时无法预判回调函数的调用顺序的原因总结如下相关 issue:
- 在初始的事件循环
tick
执行时,会 首先执行第一次时间检查。 -
timer
句柄中timeout
存储的是当次事件循环tick
的开始时间加上时间阈值
(示例代码中为1ms
)后的时间节点。 - 这一次初始
timer
的时间检查距当前事件循环tick
的间隔可能小于1ms
也可能大于1ms
的阈值,这取决于时间的系统调用的耗时,而时间的系统调用又会受到操作系统的其他应用的影响。当间隔小于1ms
时,将在timer
阶段忽略示例代码中的setTimeoutCallback
执行,并先执行setImmediateCallback
函数;反之,首先执行setTimeoutCallback
执行。
nodejs
官网另外 描述 在 I/O cycle
中,示例代码的调用是可预测的,为什么?
const fs = require('fs')
fs.readFile(__dirname, () => {
setTimeout(() => {
console.log('from setTimeout')
}, 1)
setImmediate(() => {
console.log('from setImmediate')
})
})
上述示例代码将始终输出:
from setImmediate
from setTimeout
The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.
基于先前的分析,在经历初次事件循环 tick
后,后续所有的 setTimeout/setInterval
计时器阈值检查和调用都被先前事件循环 tick
的 poll
阶段所阻塞。而不论根据 nodejs
还是 libuv
的事件循环抽象结构图还是 uv_run 函数的源码,并且基于事件循环 无法再入 的前提,poll
阶段的下一阶段始终是 check
阶段,那么在 I/O cycle
中,所有的 timer
在当前事件循环 tick
中注册,并首先通过包含 setImmediate
回调函数的 check
阶段及其后续阶段,才会进入到下一事件循环 tick
的 timer
阶段。以至于在执行顺序上在 I/O cycle
中注册的 setTimeout/setInterval
回调函数始终在 setImmediate
的回调函数之后执行。以上同样说明了为什么在 nodejs
官网上 描述 在 I/O cycle
中 setImmediate
的优先级高于 setTimeout
。
close callbacks
此阶段用于执行所有的 close
事件的回调函数。如突然通过 socket.destroy()
关闭 socket
连接时,close
事件将在此阶段触发。
与浏览器实现对比
nodejs
与浏览器端的 Web API
版本的事件循环最大的不同的是:
在 nodejs
中事件循环不再是由单一个 task queue
和 micro-task queue
组成,而是由多个 阶段 phase
的多个回调函数队列 callbacks queues
组成一次事件循环 tick。 并且在每一个单独的阶段都存在一个单独的 回调函数 FIFO 队列。
References
- 官方 - The Node.js Event Loop, Timers, and process.nextTick()
- 官方 - libuv design
- medium - What you should know to really understand the Node.js Event Loop
- github issue - how the event loop works
- github issue - non-deterministic order of execution of setTimeout vs setImmediate
- IBM - learn nodejs the event loop
- Jake Archibald (google 工程师) - In the loop
- Bert Belder (nodejs, libuv 贡献者) - Everything You Need to Know About Node.js Event Loop
- twitter.Bert Belder (nodejs, libuv 贡献者) - event loop slider
- Bryan Hughes, Microsoft - The Node.js Event Loop: Not So Single Threaded
- medium - handling IO - nodejs event loop
- medium - Timers, Immediates and Process.nextTick— nodejs event Loop
- zhihu - nodejs 定时器
- devto - Understanding the Node.js event loop phases and how it executes the JavaScript code
- Demystifying Asynchronous Programming Part 1: Node.js Event Loop
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。