1.3 事件循环(Event loop)
关于Event loop网络上已经有了很多的介绍,不少开发者即使已经有了JavaScript编程的经验,却仍然不能很好地理解事件循环的概念。不过通俗地来讲,事件循环就是一个程序启动期间运行的死循环,没有任何特别之处。
Node代码虽然运行在单线程中,但仍然能支持高并发,就是依靠事件循环实现的。
1.3.1 事件与循环
首先我们要回答两个问题:
- 什么是事件
- 什么在循环
1.事件
在可交互的用户页面上,用户会产生一系列的事件,包括单击按钮、拖动元素等,这些事件会按照顺序被加载到一个队列中去。除了页面事件之外,还有一些例如Ajax执行成功、文件读取完毕等事件。
2.循环
在GUI程序中,代码本身就处在一个循环的包裹中,例如用Java Swing开发桌面程序,就要启动一个JFrame,还要调用run方法,而run方法内部就包括了一个循环,该循环位于主线程上。
这个循环通常对开发者来说是不可见的,只有当开发者单击了窗体的关闭按钮,该循环才会结束。当用户单击了页面上的按钮或者进行其他操作时,就会产生相应的事件,这些事件会被加入到一个队列中,然后主循环会逐个处理它们。
JavaScript也是一样,用户在前台不断产生事件,背后的循环(由浏览器实现)会逐个地处理他们。
而JavaScript是单线程的,为了避免一个过于耗时的操作阻塞了其他操作的执行,就要通过异步加回调的方式解决问题。
以Ajax请求为例,当JavaScript执行到对应的代码时,就为这句代码注册了一个事件,在发出请求后该语句就执行完毕了,后续的操作会交给回调函数来处理。
此时,浏览器背后的循环正在不断遍历事件队列,在Ajax操作完成之前,事件队列里还是空的(并不是发出请求这一动作被加入事件队列,而是请求完成这一事件才会加入队列)。
如果Ajax操作完成了,这个队列中就会增加一个事件,随后被循环遍历到,如果这个事件绑定了一个回调方法,那么循环就会去调用这个方法。
1.3.2 Node中的事件循环
Node中的事件循环比起浏览器中的JavaScript还是有一些区别的,各个浏览器在底层的实现上可能有些细微的出入;而Node只有一种实现,相对起来就少了一些理解上的麻烦。
首先要明确的是,事件循环同样运行在单线程环境下,JavaScript的事件循环是依靠浏览器实现的,而Node作为另一种运行时,事件循环由底层的libuv实现。
下面如图1-4所示描述了Node中事件循环的具体流程。
图1-4
上面的图例中,将事件循环分成了6个不同的阶段,其中每个阶段都维护着一个回调函数的队列,在不同的阶段,事件循环会处理不同类型的事件,其代表的含义分别为:
- Timers:用来处理setTimeOut()和setInterval()的回调。
- I/O callbacks:大多数的回调方法在这个阶段执行,除了timers、 close和setImmediate事件的回调(所谓的“大多数”,我们会在后面解释)。
- idle, prepare:仅仅在内部使用,我们不管它。
- Poll:轮询,不断检查有没有新的IO事件,事件循环可能会在这里阻塞(也会在后面介绍)。
- Check:处理setImmediate()事件的回调。
- close callbacks:处理一些close相关的事件,例如socket.on('close', ...)。
注意:我们上面使用“阶段”(Phase)来描述事件循环,它并没有任何特别之处,本质上就是不同方法的顺序调用,用代码描述一下大约就是这种结构:
上面代码中的每一个方法即代表一个“阶段”。
假设事件循环现在进入了某个阶段(即开始执行上面其中一个方法),即使在这期间有其他队列中的事件就绪,也会先将当前阶段队列里的全部回调方法执行完毕后,再进入到下个阶段,结合代码这也是易于理解的。
接下来我们针对每个阶段进行详细说明。
1.timers
从名字就可以看出来,这个阶段主要用来处理定时器相关的回调,当一个定时器超时后,一个事件就会加入到队列中,事件循环会跳转至这个阶段执行对应的回调函数。
定时器的回调会在触发后尽可能早(as early as they can)地被调用,这表示实际的延时可能会比定时器规定的时间要长。
如果事件循环,此时正在执行一个比较耗时的callback,例如处理一个比较耗时的循环,那么定时器的回调只能等当前回调执行结束了才能被执行,即被阻塞。事实上,timers阶段的执行受到poll阶段控制。
2.IO callbacks阶段
官方文档对这个阶段的描述为除了timers、 setImmediate,以及close操作之外的大多数的回调方法都位于这个阶段执行。事实上从源码来看,该阶段只是用来执行pending callback,例如一个TCP socket执行出现了错误,在一些*nix系统下可能希望稍后再处理这里的错误,那么这个回调就会放在IO callback阶段来执行。
一些常见的回调,例如fs.readFile的回调是放在poll阶段来执行的。
3.poll阶段
poll阶段的主要任务是等待新的事件出现(该阶段使用epoll来获取新的事件),如果没有,事件循环可能会在此阻塞(关于是否在poll阶段阻塞以及阻塞多长时间,libuv有一些复杂的判定方法,这里不作深究,如果读者有兴趣,可以参考libuv源码文件src/unix/core.c下的uv_run方法,该方法是事件循环的核心方法)。
这些事件对应的回调方法可能位于timers阶段(如果定义了定时器),也可能是check阶段(设置了setImmediate方法)。
Poll阶段主要有两个步骤如下:
(1)如果有到期的定时器,那么就执行定时器的回调方法。
(2)处理poll阶段对应的事件队列(以下简称poll队列)里的事件。
当事件循环到达poll阶段时,如果这时没有要处理的定时器的回调方法,则会进行下面的判断:
(1)如果poll队列不为空,则事件循环会按照顺序遍历执行队列中的回调函数,这个过程是同步的。
(2)如果poll队列为空,会接着进行如下判断:
- 如果当前代码定义了setImmediate方法,事件循环会离开poll阶段,然后进入check阶段去执行setImmediate方法定义的回调方法。
- 如果当前代码没有定义setImmediate方法,那么事件循环可能会进入等待状态,并等待新的事件出现,这也是该阶段为什么会被命名为poll(轮询)的原因。此外,还会不断检查是否有相关的定时器超时,如果有,就会跳转到timers阶段,然后执行对应的回调。
4.check阶段
setImmediate是一个特殊的定时器方法,它占据了事件循环的一个阶段,整个check阶段就是为setImmediate方法而设置的。
一般情况下,当事件循环到达poll阶段后,就会检查当前代码是否调用了setImmediate,但如果一个回调函数是被setImmediate方法调用的,事件循环就会跳出poll阶段进而进入check阶段。
5.close阶段
如果一个socket或者一个句柄被关闭,那么就会产生一个close事件,该事件会被加入到对应的队列中。close阶段执行完毕后,本轮事件循环结束,循环进入到下一轮。
看完了上面的描述,我们明白了Node中的事件循环是分阶段处理的,对于每一阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一阶段的事件循环都对应着不同的队列。
在Node中,事件队列不止一个,定时器相关的事件和磁盘IO产生的事件需要不同的处理方式,如果把所有的事件都放到一个队列里,势必要增加许多类似switch/case的代码;那样的话倒不如将不同类型的事件归类到不同的事件队列里,然后一层层地遍历下来,如果当中出现了新的事件,就进行相应的处理。
为了更好地理解Node中的事件循环,以一段代码为例来配合说明:
上面代码改编自官方文档中的一个例子,讲述事件循环不同过程的处理步骤。这段代码的逻辑很简单,包含了readfile和timer两个异步操作。
我们来观察这段代码的执行过程,代码开始运行后,事件循环也开始运作了。
首先检查timers,然而timers对应的事件队列目前还为空(100ms后才会有事件产生),事件循环向后执行到了poll阶段,到目前为止还没有事件出现,由于代码中没有定义setImmediate操作,事件循环便在此一直等待新的事件出现。
直到95ms后(假设readFile耗费的时间为95ms,实际上可能比这个时间长或短一些),readFile读取文件完毕,产生了一个事件,加入到了poll这一队列中,此时事件循环将该队列中的事件取出,准备执行之后的callback(此时err和data的值已经就绪),readFile的回调方法什么都没做,只是暂停了10ms。
事件循环本身也被阻塞10ms,按照通常的思维,95ms+10ms=105ms>100ms,timers队列中的事件已经就绪,应该先执行对应的回调方法才是,然而由于事件循环也是单线程运行的,因此也会停止10ms,如果readFile的回调函数中包含了一个死循环,那么整个事件循环都会被阻塞,setTimeout的回调永远不会执行。
readFile的回调完成后,事件循环切换到timers阶段,接着取出timers队列中的事件执行对应的回调方法。
如果读者想了解更多关于事件循环的内容,也可以参考libuv文档中针对事件循环不同阶段的处理方式(http://docs.libuv.org/en/v1.x/design.html#the-i-o-loop)。
讲完了事件循环,我们回过头来看1.2节最后的那句话。
“除了你的代码,一切都是并行的”
试着回答几个问题:
- 如果存在并行,那么应该位于Node的哪个层面?
- 是事件循环提供了并行的能力吗?
- 如果你的计算机只有一个单核的CPU(暂先不考虑超线程技术,即在一个CPU上同时执行两个线程),还能做到并行吗?
第三个问题是最容易答的,当你只有一个单核CPU时,就算把代码写出花来也不能获得真正的并行。
第二个问题,事件循环运行在单线程环境中,这表示一个时刻只能处理一个事件,没法提供并行支持。
那么回到第一个问题,如果真的存在并行,那么只能存在于libuv的线程池中,实现的并行为线程级别的并行(需要多核CPU)。
1.3.3 process.nextTick
process.nextTick的意思就是定义出一个异步动作,并且让这个动作在事件循环当前阶段结束后执行。
例如下面的代码,将打印first的操作放在nextTick的回调中执行,最后先打印出next,再打印first。
process.nextTick其实并不是事件循环的一部分,但它的回调方法也是由事件循环调用的,该方法定义的回调方法会被加入到名为nextTickQueue的队列中。在事件循环的任何阶段,如果nextTickQueue不为空,都会在当前阶段操作结束后优先执行nextTickQueue中的回调函数,当nextTickQueue中的回调方法被执行完毕后,事件循环才会继续向下执行。
Node限制了nextTickQueue的大小,如果递归调用了process.nextTick,那么当nextTickQueue达到最大限制后会抛出一个错误,我们可以写一段代码来证实这一点。
运行上面的代码,马上就会出现:
的错误。
既然nextTickQueue也是一个队列,那么先被加入队列的回调会先执行,我们可以定义多个process.nextTick,然后观察他们的执行顺序:
和其他回调函数一样,nextTick定义的回调也是由事件循环执行的,如果nextTick的回调方法中出现了阻塞操作,后面的要执行的回调同样会被阻塞。
1.nextTick与setImmediate
setImmediate方法不属于ECMAScript标准,而是Node提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeout和setInterval,setImmediate并不接受一个时间作为参数,setImmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环末尾(check阶段)执行。
setImmediate方法和process.nextTick方法很相似,二者经常被拿来放在一起比较,由于process.nextTick会在当前操作完成后立刻执行,因此总会在setImmediate之前执行。
关于这两个方法有个笑话:nextTick和setImmediate的行为和名称含义是反过来的。
上面的代码总是会输出:
此外,当有递归的异步操作时只能使用setImmediate,不能使用process.nextTick,前面已经展示过了递归调用nextTick会出现的错误,下面使用setImmediate来试试看:
完全没问题!这是因为setImmediate不会生成call stack。
2.setImmediate和 setTimeout
通过上面的内容,我们已经知道了setImmediate方法会在poll阶段结束后执行,而setTimeout会在规定的时间到期后执行,由于无法预测执行代码时事件循环当前处于哪个阶段,因此当代码中同时存在这两个方法时,回调函数的执行顺序不是固定的。
但如果将二者放在一个IO操作的callback中,则永远是setImmediate先执行:
这是因为readFile的回调执行时,事件循环位于poll阶段,因此事件循环会先进入check阶段执行setImmediate的回调,然后再进入timers阶段执行setTimeout的回调。