Node.js 的 event loop 及 timer / setImmediate / nextTick
本文是对 Node.js 官方文档 The Node.js Event Loop, Timers, and process.nextTick()
的翻译和理解。文章并不是一字一句严格对应原文,其中会夹杂其它相关资料,以及相应的理解和扩展。
相关资料:
- Node.js API: timer
- stackoverflow NodeJS - setTimeout(fn,0) vs setImmediate(fn)
- Concurrency model and Event Loop
什么是事件循环(Event loop
)?
Event loop 是什么?
WIKI 定义:
In computer science, the event loop, message dispatcher, message loop, message pump, or run loop is a programming construct that waits for and dispatches events or messages in a program.
Event loop 是一种程序结构,是实现异步的一种机制。Event loop可以简单理解为:
- 所有任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个 任务队列(task queue)。系统把异步任务放到 任务队列 之中,然后主线程继续执行后续的任务。
- 一旦 执行栈 中的所有任务执行完毕,系统就会读取 任务队列。如果这个时候,异步任务已经结束了等待状态,就会从 任务队列 进入执行栈,恢复执行。
- 主线程不断重复上面的第三步。
对 JavaScript 而言,Javascript 引擎/虚拟机(如 V8)之外,JavaScript的运行环境(runtime,如浏览器,node)维护了任务队列,每当 JS 执行异步操作时,运行环境把异步任务放入任务队列。当执行引擎的线程执行完毕(空闲)时,运行环境就会把任务队列里的(执行完的)任务(的数据和回调函数)交给引擎继续执行,这个过程是一个不断循环的过程,称为事件循环。
注意:JavaScript(引擎)是单线程的,Event loop 并不属于 JavaScript 本身,但 JavaScript 的运行环境是多线程/多进程的,运行环境实现了 Event loop。
另外,视频 What the heck is the event loop anyway 站在前端的角度,用动画的形式描述了上述过程,可以便于理解。
解释 Node.js 的 Event loop
当 Node.js 启动时,它会初始化 event loop,处理提供的代码(代码里可能会有异步 API 调用,timer,以及 process.nextTick()
),然后开始处理 event loop。下面是 node 启动的部分相关代码:
// node.cc { SealHandleScope seal(isolate); bool more; do { v8_platform.PumpMessageLoop(isolate); more = uv_run(env.event_loop(), UV_RUN_ONCE); if (more == false) { v8_platform.PumpMessageLoop(isolate); EmitBeforeExit(&env); // Emit `beforeExit` if the loop became alive either after emitting // event, or after running some callbacks. more = uv_loop_alive(env.event_loop()); if (uv_run(env.event_loop(), UV_RUN_NOWAIT) != 0) more = true; } } while (more == true); }
Event Loop 的执行顺序
下面的示意图展示了一个简化的 event loop 的操作顺序:
┌───────────────────────┐ ┌─>│ timers │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘
(图来自 Node.js API)
图中每个 盒子 都是 event loop 执行的一个阶段(phase)。
每个阶段都有一个 FIFO 的回调队列(queue)要执行。而每个阶段有自己的特殊之处,简单说,就是当 event loop 进入某个阶段后,会执行该阶段特定的(任意)操作,然后才会执行这个阶段的队列里的回调。当队列被执行完,或者执行的回调数量达到上限后,event loop 会进入下个阶段。
Phases Overview 阶段总览
- timers: 这个阶段执行
setTimeout()
和setInterval()
设定的回调。 - I/O callbacks: 执行被推迟到下一个 iteration 的 I/O 回调。
- idle, prepare: 仅内部使用。
- poll: 获取新的 I/O 事件;node 会在适当条件下阻塞在这里。这个阶段执行几乎所有的回调,除了
close
回调,timer 的回调,和setImmediate()
的回调。 - check: 执行
setImmediate()
设定的回调。 - close callbacks: 执行比如
socket.on('close', ...)
的回调。
Phases in Detail 阶段详情
timers
一个 timer 指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers 会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。
- 注意:技术上来说,poll 阶段控制 timers 什么时候执行。
- 注意:这个下限时间有个范围:
[1, 2147483647]
,如果设定的时间不在这个范围,将被设置为1。
I/O callbacks
这个阶段执行一些系统操作的回调。比如 TCP 错误,如一个 TCP socket 在想要连接时收到 ECONNREFUSED
,类 unix 系统会等待以报告错误,这就会放到 I/O callbacks 阶段的队列执行。
poll
poll 阶段有两个主要功能:
- 执行下限时间已经达到的 timers 的回调,然后
- 处理 poll 队列里的事件。
当 event loop 进入 poll 阶段,并且 没有设定的 timers(there are no timers scheduled),会发生下面两件事之一:
- 如果 poll 队列不空,event loop 会遍历队列并同步执行回调,直到队列清空或执行的回调数到达系统上限;
- 如果 poll 队列为空,则发生以下两件事之一:
- 如果代码已经被
setImmediate()
设定了回调, event loop 将结束 poll 阶段进入 check 阶段来执行 check 队列(里的回调)。 - 如果代码没有被
setImmediate()
设定回调,event loop 将阻塞在该阶段等待回调被加入 poll 队列,并立即执行。
- 如果代码已经被
但是,当 event loop 进入 poll 阶段,并且 有设定的timers,一旦 poll 队列为空(poll 阶段空闲状态):
1. event loop将检查timers,如果有1个或多个timers的下限时间已经到达,event loop将绕回 **timers** 阶段,并执行 **timer** 队列。
check
这个阶段允许在 poll 阶段结束后立即执行回调。如果 poll 阶段空闲,并且有被setImmediate()
设定的回调,event loop 会转到 check 阶段而不是继续等待。
setImmediate()
实际上是一个特殊的 timer,跑在 event loop 中一个独立的阶段。它使用 libuv
的 API 来设定在 poll 阶段结束后立即执行回调。
通常上来讲,随着代码执行,event loop 终将进入 poll 阶段,在这个阶段等待 incoming connection、request 等等。但是,只要有被 setImmediate()
设定了回调,一旦 poll 阶段空闲,那么程序将结束 poll 阶段并进入 check 阶段,而不是继续等待 poll 事件们 (poll events)。
close callbacks
如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()
),close 事件将在这个阶段被触发,否则将通过 process.nextTick()
触发。
event loop 的一个例子讲述
var fs = require('fs'); function someAsyncOperation (callback) { // 假设这个任务要消耗 95ms 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); // someAsyncOperation要消耗 95 ms 才能完成 someAsyncOperation(function () { var startCallback = Date.now(); // 消耗 10ms... while (Date.now() - startCallback < 10) { ; // do nothing } });
当 event loop 进入 poll 阶段,它有个空队列(fs.readFile()
尚未结束)。所以它会等待剩下的毫秒,直到最近的timer的下限时间到了。当它等了95ms,fs.readFile()
首先结束了,然后它的回调被加到 poll 的队列并执行——这个回调耗时10ms。之后由于没有其它回调在队列里,所以 event loop 会查看最近达到的 timer 的下限时间,然后回到 timers 阶段,执行 timer 的回调。
所以在示例里,回调被设定 和 回调执行间的间隔是 105ms。
setImmediate()
vs setTimeout()
setImmediate()
和 setTimeout()
是相似的,区别在于什么时候执行回调:
setImmediate()
被设计在 poll 阶段结束后立即执行回调;setTimeout()
被设计在指定下限时间到达后执行回调。
下面看一个例子:
// timeout_vs_immediate.js setTimeout(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
是的,你没有看错,输出结果是 不确定 的!从直觉上来说,setImmediate()
的回调应该先执行,但为什么结果随机呢?再看一个例子:
// timeout_vs_immediate.js var 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
在这里永远先执行!
所以,结论是:
- 如果两者都在主模块(main module)调用,那么执行先后取决于进程性能,即随机。
- 如果两者都不在主模块调用(即在一个 IO circle 中调用),那么
setImmediate
的回调永远先执行。
那么又是为什么呢?看 int uv_run(uv_loop_t* loop, uv_run_mode mode)
源码(deps/uv/src/unix/core.c#332):
int uv_run(uv_loop_t* loop, uv_run_mode mode) { int timeout; int r; int ran_pending; r = uv__loop_alive(loop); if (!r) uv__update_time(loop); while (r != 0 && loop->stop_flag == 0) { uv__update_time(loop); //// 1. timer 阶段 uv__run_timers(loop); //// 2. I/O callbacks 阶段 ran_pending = uv__run_pending(loop); //// 3. idle/prepare 阶段 uv__run_idle(loop); uv__run_prepare(loop); // 重新更新timeout,使得 uv__io_poll 有机会跳出 timeout = 0; if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) timeout = uv_backend_timeout(loop); //// 4. poll 阶段 uv__io_poll(loop, timeout); //// 5. check 阶段 uv__run_check(loop); //// 6. close 阶段 uv__run_closing_handles(loop); if (mode == UV_RUN_ONCE) { uv__update_time(loop); // 7. UV_RUN_ONCE 模式下会再次检查timer uv__run_timers(loop); } r = uv__loop_alive(loop); if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break; } if (loop->stop_flag != 0) loop->stop_flag = 0; return r; }
上面的代码看起来很清晰,一一对应了我们的几个阶段。
- 首先进入 timer 阶段,如果我们的机器性能一般,那么进入timer阶段时,1毫秒可能已经过去了(
setTimeout(fn, 0)
等价于setTimeout(fn, 1)
),那么setTimeout
的回调会首先执行。 - 如果没到一毫秒,那么我们可以知道,在check阶段,
setImmediate
的回调会先执行。 - 为什么
fs.readFile
回调里设置的,setImmediate
始终先执行?因为fs.readFile
的回调执行是在 poll 阶段,所以,接下来的 check 阶段会先执行setImmediate
的回调。 - 我们可以注意到,
UV_RUN_ONCE
模式下,event loop 会在开始和结束都去执行 timer。
理解 process.nextTick()
直到现在,我们才开始解释 process.nextTick()
。因为从技术上来说,它并不是 event loop 的一部分。相反的,process.nextTick()
会把回调塞入 nextTickQueue
,nextTickQueue
将在当前操作完成后处理,不管目前处于 event loop 的哪个阶段。
看看我们最初给的示意图,process.nextTick()
不管在任何时候调用,都会在所处的这个阶段最后,在 event loop 进入下个阶段前,处理完所有 nextTickQueue
里的回调。
process.nextTick()
vs setImmediate()
两者看起来也类似,区别如下:
process.nextTick()
立即在本阶段执行回调;setImmediate()
只能在 check 阶段执行回调。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(4)
@creeperyang 谢谢,
那在H5新特征的多线程JS处理代码中,这个异步该如何处理呢?
@zColdWater 你理解得应该没什么问题,就是表述有点不是很好懂(比如没有event loop为空的说法)。
下面总结几点关于event loop的:
process.nextTick
和Promise
都是microtask,即在进入下个event loop前执行(也可以理解成不是event loop相关的概念)。setTimeout/setImmediate/io
是task,会把回调延迟到后面的某个event loop执行。@creeperyang Hi,我想说的是那个105ms的例子,我用js在浏览器当中执行异步代码的原理也能解释的通,那么问题是不是WebAPIs是浏览器中的,在Nodejs中就是libuv,EventLoop只是等待堆栈执行完才把TaskQueue的回调Push到堆栈执行,按照我的理解是,someAsyncOperation方法先完成了,他被push到任务队列中,然后EventLoop判断Stack中空了,执行那剩下10秒,但是过了5s后,Settimeout回调任务完成了,被Push到任务队列中,但是当前Stack正在执行后面那5s不为空,所以EventLoop要等到为空才执行这个回调,所以是105ms。 js在web中的例子:http://latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D