如何理解Node.js的事件循环

由于JavaScript是单线程的,那么在浏览器中,为了在等待动作完成时不会阻塞主线程的异步代码处理,JavaScript使用事件循环在调用堆栈、Web API和回调队列之间,持续协调代码的执行。不过,由Node.js自行实现的Node.js事件循环,虽然与之有着许多相同的模式,但是由于Node.js不与DOM交互,且可以处理各种输入和输出(I/O),因此它在工作方式上却有所不同。

在本文中,我们将先了解Node.js事件循环背后的理论,再探究几个使用setTimeout、setImmediate和process.nextTick的示例。最后,我们将部分工作代码部署到Heroku中,以查看其运行情况。

Node.js的事件循环

总的说来,Node.js事件循环可以协调计时器、回调、以及I/O事件等操作与执行。这便是Node.js在单线程的情况下,处理异步行为的方式。如下事件循环图,很好地展示了其执行的顺序。

如您所见,Node.js事件循环共有六个主要阶段,它们分别是:

计时器(Timers):那些由setTimeout和setInterval安排的回调,会在此阶段被执行。

待处理的回调(Pending callbacks):那些被推迟到下一个循环迭代的I/O回调,会在此阶段被执行。

空闲,准备(Idle, prepare):此阶段仅由Node.js内部所使用。

轮询(Poll):此阶段用于检索新的I/O事件,并执行I/O回调(不过那些由计时器和setImmediate安排的回调,以及下面将提到的关闭回调除外,毕竟它们会在其他不同的阶段被处理)。

检查(Check):由setImmediate安排的回调会在该阶段被执行。

关闭回调(Close callbacks):此阶段主要执行诸如销毁套接字连接等回调。

您可能会好奇,为何process.nextTick并未在上述任何阶段被提到?其实,这是因为:作为一种特殊的方法,就技术而言,它并非Node.js事件循环的一部分。相反,无论process.nextTick方法在何时被调用,它都会将自己的回调放入队列之中,然后“无论事件循环当前处于哪个阶段,都会在完成当前操作后,处理排队中的各种回调”(源自:Node.js事件循环文档)。

事件循环的场景示例

也许您觉得上文针对Node.js事件循环的每个阶段的解释,过于抽象了。那么,我在Heroku上创建了一个包含了各种可运行代码段示例的演示应用。在该应用中,单击任何示例按钮,都会向服务器端发送一个API请求。而Node.js会在后端执行所选示例的代码片段,然后通过API将相应的响应返回给前端。您可以从GitHub的链接处,查看到完整的代码。

让我们通过如下示例,来更好地理解Node.js事件循环的调用顺序。

示例1

让我们从如下简单的示例开始(如下图所示):

示例1-同步代码

在此,我们有三个功能函数。由于它们是同步的,因此代码会从上至下顺次执行。也就是说,如果三个函数的调用顺序为:first、second、third,它们的代码也会以相同的顺序去执行:first、second、third。

示例2

接下来,我们会在第二个示例中引入setTimeout的概念(如下图所示):

示例2-setTimeout

在此,我们先调用first函数,然后在延迟0毫秒后计划调用带有setTimeout的second函数,最后调用third函数。那么,这些函数的执行顺序就变成了:first、third、second。您一定会好奇:为什么second函数会被最后执行呢?

下面让我们来理解两个重要的原则。首先,使用带有延迟值的setTimeout方法,并不意味着应用将在指定毫秒数后,立即执行回调函数。实际上,该值表示的是:执行回调之前,需要经过的最短时间。其次,使用setTimeout来为回调设定的后期执行时间,会在事件循环的每一次迭代期间中始终执行该规则。因此,在事件循环的第一次迭代中,first函数被执行,second函数被“安排”(scheduled),third函数再被执行。然而,在事件循环的第二次迭代期间中,0毫秒的最小延迟已被满足,因此second函数便会在第二次迭代的“计时器”阶段被执行。

示例3

然后,我们会在第三个示例中引入setImmediate的概念(如下图所示):

示例3-setImmediate与setTimeout

在该示例中,我们执行first函数,使用setTimeout来为second函数延迟0毫秒,然后使用setImmediate来“安排”third函数。那么,在代码执行的过程中,就会出现一个问题:到底是哪种类型的安排优先?setTimeout还是setImmediate?

鉴于前面已经讨论过setTimeout的工作机制,我们来简单介绍一下setImmediate方法。该方法在事件循环的下一次迭代的“检查”阶段,会去执行其回调函数。因此,如果setImmediate在事件循环的第一次迭代期间被调用,那么它的回调方法会被“安排”上,并在事件循环的第二次迭代期间,执行该回调方法。

正如你在输出中所看到的那样,在我们的示例中,由于被setImmediate安排的回调先于被setTimeout安排的回调执行,因此该示例函数的执行顺序为:first、third、second。

当然,由setImmediate和setTimeout安排的执行到底谁先谁后,实际上取决于被调用方法的上下文。当从Node.js脚本中的主模块,直接调用这两种方法时,其时间取决于进程的性能,因此在每次运行脚本时,回调都可以按照不同的顺序被执行。不过,在I/O周期内调用这些方法时,setImmediate回调总是发生在setTimeout回调之前。在我们上述示例中,由于这些方法是作为响应API端点的某个部分被调用的,因此setImmediate回调会始终在setTimeout回调之前被执行。

示例4

为了实现快速的健全性检查,我们使用setImmediate和setTimeout来构建另一个示例(如下图所示)。

示例4-再次使用setImmediate与setTimeout

在此示例中,我们使用setImmediate来安排first函数,接着直接执行second函数,然后使用setTimeout的0毫秒延迟来安排third函数。您恐怕已经猜到了,上述函数的执行顺序为:second、first、third。而在事件循环的第二次迭代中,second函数被setImmediate安排在该I/O周期内被执行,然后third函数在延迟0毫秒时间后也被执行了。

示例5

下面,我们将process.nextTick方法引入最后一个示例(如下图所示)。

示例5-process.nextTick

在该示例中,我们使用setImmediate来安排first函数,并使用process.nextTick来安排second函数,再使用带有0毫秒延迟的setTimeout来安排third函数,最后执行fourth函数。那么,在代码运行后,整体的调用顺序为:fourth、second、first、third。

有了前面的基础,我们很容易理解fourth函数为何被首先执行了。毕竟它是被直接调用的,而无需通过任何其他方法来进行安排。process.nextTick方法安排了second函数在第二个被执行,first函数紧接其后。最后被执行的是third函数,其原因在于,在同一个I/O周期内,由setImmediate安排的回调会先于setTimeout安排的回调去执行。

那么,为什么由process.nextTick安排的second函数会先于由setImmediate安排的first函数被执行呢?请不要被这两种方法的名称所误导,并非setImmediate就代表着回调一定会被立即执行,而process.nextTick就一定要等到事件循环的下一轮再执行回调。您只需注意的是:process.nextTick是在安排的同一阶段中,立即执行回调的;而setImmediate的回调则是在事件循环的下一次迭代、或计时期间中被执行的。

小结

通过上述示例,您应该对Node.js的事件循环,以及诸如setTimeout、setImmediate和process.nextTick等方法有所了解了。当然,您不必深究Node.js的内部结构,以及处理命令的相关操作。我们完全可以将Node.js视为一个黑匣子,轻松地用好Node.js事件循环的各项调用顺序即可。

你可能感兴趣的:(如何理解Node.js的事件循环)