CXsilent 2019-06-28
本文,将会详细的讲解 node.js 事件循环工作流程和生命周期
最常见的误解之一,事件循环是 Javascript 引擎(V8,spiderMonkey等)的一部分。事实上事件循环主要利用 Javascript 引擎来执行代码。
首先没有栈,其次这个过程是复杂的,有多个队列(像数据结构中的队列)参与。但是大多数开发者知道多少有的回调函数被推进一个单一的队列里面,是完全错误的。
由于错误的 node.js 事件循环图,我们中有一部分人认为u有两个线程。一个执行 Javascript,另一个执行事件循环。事实上都在一个线程里面运行。
另一个非常大的误解是 setTimeout 的回调函数在给定的延迟完成之后被(可能是 OS 或者 内核)推进一个队列。
作为常见的事件循环描述只有一个队列;所以一些开发者认为 setImmediate 将回调放在工作队列的前面。这是完全错误的,在 Javascript 的工作队列都是先进先出的。
在我们开始描述事件循环的工作流程时,知道它的架构非常重要。下图为事件循环真正的工作流程:
图中不同的盒子代表不同的阶段,每个阶段执行特定的工作。每个阶段都有一个队列(这里说成队列主要是为了更好理解;真实的数据结构可能不是队列),Javascript 可以在任何一个阶段执行(除了 idle & prepare)。你在图片中也能看到 nextTickQueue 和 microTaskQueue,它们不是循环的一部分,它们之中的回调可以在任意阶段执行。它们有更高的优先级去执行。
现在你知道了事件循环是不同阶段和不同队列的结合;下面是每个阶段的描述。
这个是事件循环开始的阶段,绑定到这个阶段的队列,保留着定时器(setTimeout, setInterval)的回调,尽管它并没有将回调推入队列中,但是以最小的堆来维持计时器并且在到达规定的事件后执行回调。
这个阶段执行在事件循环中 pending_queue 里的回调,这些回调时被之前的操作推入的。例如当你尝试往 tcp 中写入一些东西,这个工作完成了,然后回调被推入到队列中。错误处理的回调也在这里。
尽管名字是空闲(idle),但是每个 tick 都运行。Prepare 也在轮询阶段开始之前运行。不管怎样,这两个阶段是 node 主要做一些内部操作的阶段。
可能整个事件循环最重要的一个阶段就是 poll phase。这个阶段接受新传入的连接(新的 Socket 建立等)和数据(文件读取等)。我们可以将轮询阶段分成几个不同的部分。
轮询的下一个阶段是 check pahse,这个专用于 setImmediate 的阶段。为什么需要一个专门的队列来处理 setImmediate 回调?这是因为轮询阶段的行为,待会儿将在流程部分讨论。现在只需要记住检查(check)阶段主要处理 setImmediate() 的回调。
回调的关闭(stocket.on('close', () => {}))都在这里处理的,更像一个清理阶段。
nextTickQueue 中的任务保留在被 process.nextTick() 触发的回调。 microTaskQueue 保留着被 Promise 触发的回调。它们都不是事件循环地一部分(不是在 libUV 中开发地),而是在 node 中。在 C/C++ 和 Javascript 有交叉的时候,它们都是尽可能快地被调用。因此它们应该在当前操作运行后(不一定是当前 js 回调执行完)。
当在你的控制台运行 node my-script.js ,node 设置事件循环然后运行你主要的模块(my-script.js)事件循环的外部。一旦主要模块执行完,node 将会检查循环是否还活着(事件循环中是否还有事情要做)?如果没有,将会在执行退出回调后退出。process, on('exit', foo) 回调(退出回调)。但是如果循环还活着,node 将会从计时器阶段进入循环。
事件循环进入计时器阶段并且检查在计时器队列中是否有需要执行的。好吧,这句话听起来非常简单,但是事件循环实际上要执行一些步骤发现合适的回调。实际上计时器脚本以升序储存在堆内存中。它首先获取到一个执行计时器,计算下是否 now-registeredTime == delta?如果是,他会执行这个计时器的回调并且检查下一个计时器。直到找到一个还没有约定时间的计时器,它会停止检查其他的定时器(因为定时器都以升序排好了)并且移到下一个阶段了。
假设你调用了 setTimeout 4次创建了4个定时器,分别相对于时间 t 来说 100,200,300,400 的差值。
假设事件循环在 t+250 进入到了计时器阶段。它会首先看下计时器 A,A 的过期时间是 t+100。但是现在时间是 t+250。因此它将执行绑定在计时器 A 上的回调。然后去检查计时器 B,发现它的过期时间是 t+200,因此也会执行 B 的回调。现在它会检查 C,发现它的过期时间是 t+300,因此将会离开它。时间循环不会去检查 D,因为计时器是按升序拍好的;因此 D 的阈值比 C 大。然而这个阶段有一个系统相关的硬限制,如果达到系统依赖最大限制数量,即使有未执行的计时器,它也会移到下一个阶段。
计时器阶段后,事件循环将会进入到了悬而未决的 I/O 阶段,然后检查一下 pengding_queue 中是否有来自于之前的悬而未决的任务的回调。如果有,一个接一个的执行,直到队列为空,或者达到系统的最大限制。之后,事件循环将会移到 idle handler 阶段,其次是准备阶段做一些内部的操作。然后最终可能进入到最重要的阶段 poll phase。
像名字说的那样,这是一个观察的阶段。观察是否有新的请求或者连接传入。当事件循环进入轮询阶段,它会在 watcher_queue 中执行脚本,包含文件读响应,新的 socket 或者 http 连接请求,直到事件耗尽或者像其他阶段那样达到系统依赖上限。假设没有要执行的回调,轮询在某些特定的条件下将会等待一会儿。如果在检查队列(check queue),悬而未决队列(pending queue),或者关闭队列(closing callbacks queue 或者 idle handler queue)里面有任何任务等待,它将等待 0 毫秒。然后它会根据定时器堆来决定等待时间执行第一个定时器(如果可获取)。如果第一个定时器阈值经过了,毫无疑问它不需要等待(就会执行第一个定时器)。
轮询阶段结束之后,立即来到检查阶段。这个阶段的队列中有被 api setImmediate 触发的回调。它将会像其他阶段那样一个接着一个的执行,直到队列为空或者达到依赖系统的最大限制。
完成在检查阶段的任务之后,事件循环的下一个目的地是处理关闭或者销毁类型的回调 close callback。事件循环执行完这个阶段的队列中的回调后,它会检查循环(loop)是否还活着,如果没有,退出。但是如果还有工作要做,它会进入下一个循环;因此在计时器阶段。如果你认为之前例子中的定时器(A & B)过期,那么现在定时器阶段将会从定时器 C 开始检查是否过期。
因此,这两个队列的回调函数什么时候运行?它们当然在从当前阶段到下一个阶段之前尽可能快的运行。不像其他阶段,它们两个没有系统依赖的醉倒限制,node 运行它们直到两个队列是空的。然而,nextTickQueue 会比 microTaskQueue 有着更高的任务优先级。
我从 Javascript 开发者哪里听到普遍的一个词就是 ThreadPool。一个普遍的误解是,nodejs 有一个处理所有异步操作的进程池。但是实际上进程池是 libUV (nodejs用来处理异步的第三方库)库中的。之所以没有在图中画出来,是因为它不是循环机制的一部分。目前,并不是每个异步任务都会被进程池处理的。libUV 能够灵活使用操作系统的异步 api 来保持环境为事件驱动。然而操作系统的 api 不能做文件读取,dns 查询等,这些由进程池来处理,默认只有 4 个进程。你可以通过设置 uv_threadpool_size 的环境变量增加进程数直到 128.
希望你能理解事件循环是如何工作的。C 语言 中同步的 while 帮助 Javascript 成为异步的。每次只处理一件事但是很呐阻塞。当然,无论我们如果描述理论,最好的理解还是示例,因此,让我们通过一些代码片段来理解这个脚本。
setTimeout(() => {console.log('setTimeout'); }, 0); setImmediate(() => {console.log('setImmediate'); });
你能够猜到上面的输出吗?好吧,你可能认为 setTimeout 会先被打印出来,但是不能保证,为什么呢?执行完主模块之后进入计时器阶段,他可能不会或者会发现你的计时器耗尽了。为什么呢?一个计时器脚本是根据系统时间和你提供的增量时间注册的。setTimeout 调用的同时,计时器脚本被写入到了内存中,根据你的机器性能和其他运行在它上面的操作(不是node)的不同,可能会有一个很小的延迟。另一点时,node仅仅在进入计时器阶段(每一轮遍历)之前设置一个变量 now,将 now 作为当前时间。因此你可以说相当于精确的时间有点问题。这就是不确定性的原因。如果你在一个计时器代码的回调里面指向相同的代码会得到相同的结果。
然而,如果你移动这段代码到 i/o 周期里,保证 setImmediate 回调会先于 setTimeout 运行。
fs.readFile('my-file-path.txt', () => { setTimeout(() => {console.log('setTimeout');}, 0); setImmediate(() => {console.log('setImmediate');}); });
var i = 0; var start = new Date(); function foo () { i++; if (i < 1000) { setImmediate(foo); } else { var end = new Date(); console.log("Execution time: ", (end - start)); } } foo();
上面的例子非常简单。调用函数 foo 函数内部再通过 setImmediate 递归调用 foo 直到 1000。在我的电脑上面,大概花费了 6 到 8 毫秒。仙子啊修改下上面的代码,把 setImmedaite(foo) 换成 setTimeout(foo, o)。
var i = 0; var start = new Date(); function foo () { i++; if (i < 1000) { setTimeout(foo, 0); } else { var end = new Date(); console.log("Execution time: ", (end - start)); } } foo();
现在在我的电脑上面运行这段代码花费了 1400+ms。为什么会这样?它们都没有 i/o 事件,应该一样才对。上面两个例子等待事件是 0.为什么花费这么长时间?通过事件比较找到了偏差,CPU 密集型任务,花费更多的时间。注册计时器脚本也花费事件。定时器的每个阶段都需要做一些操作来决定一个定时器是否应该执行。长时间的执行也会导致更多的 ticks。然而,在 setImmediate 中,只有检查这一个阶段,就好像在一个队列里面然后执行就行了。
var i = 0; function foo(){ i++; if (i>20) return; console.log("foo"); setTimeout(()=>console.log("setTimeout"), 0); process.nextTick(foo); } setTimeout(foo, 2000);
你认为上面输出是什么?是的,它会输出 foo 然后输出 setTimeout。2秒后被 nextTickQueue 递归调用 foo() 打印出第一个 foo。当所有的 nextTickQueue 执行完了,开始执行其他(比如 setTimeout 回调)的。
所以是每个回调执行完之后,开始检查 nextTickQueue 的吗? 我们改下代码看下。
var i = 0; function foo(){ i++; if (i>20) return; console.log("foo"); setTimeout(()=>console.log("setTimeout"), 0); process.nextTick(foo); } setTimeout(foo, 2000); setTimeout(()=>{console.log("Other setTimeout"); }, 2000);
在 setTimeout 之后,我仅仅用一样的延迟时间添加了另一个输出 Other setTimeout 的 setTimeout。尽管不能保证,但是有可能会在输出第一个 foo 之后输出 Other setTimeout 。相同的定时器分为一个组,nextTickQueue 会在正在进行中的回调组执行完之后执行。
就像我们大多数人都认为事件循环是在一个单独的线程里面,将回调推入一个队列,然后一个接着一个执行。第一次读到这篇文章的读者可能会感到疑惑,Javascript 在哪里执行的?正如我早些时候说的,只有一个线程,来自于本身使用 V8 或者其他引擎的事件循环的 Javascript 代码也是在这里运行的。执行是同步的,如果当前的 Javascript 执行还没有完成,事件循环不会传播。
首先不是0,而是1.当你设置一个计时器,时间为小于 1,或者大于 2147483647ms 的时候,它会自动设置为 1.因此你如果设置 setTimeout 的延迟时间为 0,它会自动设置为1.
此外,setImmediate 会减少额外的检查。因此 setImmediate 会执行的更快一些。它也放置在轮询阶段之后,因此来自于任何一个到来的请求 setImmediate 回调将会立即被执行。
setImmediate 和 process.nextTick() 都命名错了。所以功能上,setImmediate 在下一个 tick 执行,nextTick 是马上执行的。
由于 nextTickQueue 没有回调执行的限制。因此如果你递归地执行 process.nextTick(),你地程序可能永远在事件循环中出不来,无论你在其他阶段有什么。
它可能会初始化计时器,但回调可能永远不会被调用。因为如果 node 在 exit callback 阶段,它已经跳出事件循环了。因此没有回去执行。
事件循环没有工作栈
事件循环不在一个单独地线程里面,Javascript 的执行也不是像从队列中弹出一个回调执行那么简单。
setImmediate 没有将回调推入到工作队列地头部,有一个专门的阶段和队列。
setImmediate 在下一个循环执行,nextTick 实际上是马上执行。
当心,如果递归调用的话,nextTickQueue 可能会阻塞你的 node 代码。