事件循环
Node.js 在主线程里维护了一个事件队列,当接到请求后,就将该请求作为一个事件放入这个队列中,然后继续接收其他请求。当主线程空闲时(没有请求接入时),就开始循环事件队列,检查队列中是否有要处理的事件,这时要分两种情况:如果是非 I/O 任务,就亲自处理,并通过回调函数返回到上层调用;如果是 I/O 任务,就从 线程池 中拿出一个线程来处理这个事件,并指定回调函数,然后继续循环队列中的其他事件。
当线程中的 I/O 任务完成以后,就执行指定的回调函数,并把这个完成的事件放到事件队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用。 这个过程就叫 事件循环 (Event Loop),这是一个很笼统的描述,具体的细节东西,下面还有。其运行原理如下图所示:
这个图是整个 Node.js 的运行原理,从左到右,从上到下,Node.js 被分为了四层,分别是 应用层、V8引擎层、Node API层 和 LIBUV层。
应用层: 即 JavaScript 交互层,常见的就是 Node.js 的模块,比如 http,fs
V8引擎层: 即利用 V8 引擎来解析JavaScript 语法,进而和下层 API 交互
NodeAPI层: 为上层模块提供系统调用,一般是由 C 语言来实现,和操作系统进行交互 。
LIBUV层: 是跨平台的底层封装,实现了 事件循环、文件操作等,是 Node.js 实现异步的核心 。
无论是 Linux 平台还是 Windows 平台,Node.js 内部都是通过 线程池 来完成异步 I/O 操作的,而 LIBUV 针对不同平台的差异性实现了统一调用。因此,Node.js 的单线程仅仅是指 JavaScript 运行在单线程中(应用层是单线程的),而并非 Node.js 是单线程。
node对回调事件的处理完全是基于事件循环的tick的,因此具有几大特征:
1、在应用层面,JS是单线程的,业务代码中不能存在耗时过长的代码,否则可能会严重拖后续代码(包括回调)的处理。如果遇到需要复杂的业务计算时,应当想办法启用独立进程或交给其他服务进行处理。
2、回调是不精确,因为前面的原因,setTimeout并不能得到准确的超时回调。
3、不同类型的观察者,处理的优先级不同,idle观察者最先,I/O观察者其次,check观察者最后。
下面的这个图是一个完整的node运行流程:
关于观察者,从上图中可以很明显的看到,在整个事件循环过程中承担了最基本的数据结构的角色,所有的io请求或者网络请求都被封装成了观察者对象,事件循环通过观察者对象来调用回调函数。这里可以很明确的看到,观察者就是文件描述符表和callbach的和。这个图太直观,太明了了,事件循环的一切细节都在上面了。
说到这个观察者对象,有人会觉得难道这就是传说设计模式中的观察者模式吗?nonono,观察者模式是定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。而反观上面的事件循环机制,我们封装了一个又一个的观察者对象,然后事件循环通过观察者对象来获取异步操作结束之后返回的数据,并交给主线程来处理。
仔细想想这是一种典型的生产者消费者模式。生产者是libuv中的线程池,线程池通对io处理返回数据给观察者,实现循环检查观察者来获取返回数据来操作,那么事件循环中检查观察者的线程就是一个消费者。所以这是一个典型的生产者消费者模式
当Node.js启动时会初始化event loop, 每一个event loop都会包含按如下顺序六个循环阶段,
timers 阶段: 这个阶段执行setTimeout(callback) and setInterval(callback)预定的callback;
I/O callbacks 阶段: This phase executes callbacks for some system operations such as types of TCP errors. For example if a TCP socket receives ECONNREFUSED when attempting to connect, some *nix systems want to wait to report the error. This will be queued to execute in the I/O callbacks phase;
idle, prepare 阶段: 仅node内部使用;
poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
check 阶段: 执行setImmediate() 设定的callbacks;
close callbacks 阶段: 比如socket.on(‘close’, callback)的callback会在这个阶段执行.
event loop按顺序执行上面的六个阶段,每一个阶段都有一个装有callbacks的fifo queue(队列),当event loop运行到一个指定阶段时,node将执行该阶段的fifo queue(队列),当队列callback执行完或者执行callbacks数量超过该阶段的上限时,event loop会转入下一下阶段.
那么我们平常的异步io是在哪个阶段执行的呢,答案是poll阶段。
poll阶段
在node.js里,除了上面几个特定阶段的callback之外,任何异步方法完成时,都会将其callback加到poll queue里。分以下的两种情况:
1.当event loop到poll阶段时,且不存在timer,将会发生下面的情况
如果poll queue不为空,event loop将同步的执行queue里的callback,直至queue为空,或执行的callback到达系统上限;
如果poll queue为空,将会发生下面情况:
如果代码已经被setImmediate()设定了callback 或者有满足close callbacks阶段的callback, event loop将结束poll阶段进入check阶段,并执行check阶段的queue (check阶段的queue是 setImmediate设定的)
如果代码没有设定setImmediate(callback)或者没有满足close callbacks阶段的callback,event loop将阻塞在该阶段等待callbacks加入poll queue;
2.当event loop到poll阶段时,如果存在timer并且timer未到超时时间,将会发生下面情况:
则会把最近的一个timer剩余超时时间作为参数传入io_poll()中,这样event loop 阻塞在poll阶段等待时,如果没有任何I/O事件触发,也会由timerout触发跳出等待的操作,结束本阶段,然后在close callbacks阶段结束之后会在进行一次timer超时判断
所以实际上,timer检查会发生在两个地方:timers阶段和close callbacks阶段结束之后。
应该说,事件循环、观察者、请求对象、I/O线程池,这四者共同组成了Node异步I/O模型的基本要素。
setTimeout/setInterval
setTimeout和setInterval的表现和实现其实基本相同,不同的只是setInterval会不断重复。在底层实现上他们是创建了一个Timeout的中间对象,并且放到了实现定时器的红黑树中,每一次tick开始时,都会到这个红黑树中检查是否存在超时的回调,如果存在,则一一按照超时顺序取出来进行回调。因此,我们可以得出这样一个结论:
js的定时器是不可靠的。因此单线程的原因,它是基于tick的,每次tick开始时才开始检查是否有超时,如果一个tick耗时过长,在它之后出发的定时回调都将被延迟
timer的效率不是很高,因为是从红黑树上取下所有超时的Timer对象,然后依次调用他们的回调方法进行回调。
process.nextTick()方法的操作相对较为轻量,每次调用Process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行。定时器采用红黑树的操作时间复杂度为o(lg(n)),而nextTick()的时间复杂度为o(1)。相较之下,process.nextTick()更高效。
nextTick函数,会将callback封装为一个obj对象,并且插入到nextTickQueue队列(数组)中。
每次nextTick回调,都会nextTickQueue数组中的回调全部跑完!
setImmediate函数,首先把callback封装成了一个immediate对象,然后把它插入到了immediateQueue队列(数组)中
两者之间其实是有差别的。区别表现为两点:
1、process.nextTick中回调函数的优先级高于setImmediate,根据我前面写的那篇文章可知,原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick属于idle观察者,setImmediate属于check观察者。在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。
2、在实现上,process.nextTick的回调函数保存在一个数组中,setImmediate则保存在一个链表中。顺便这里抛出一个朴灵老师在《深入浅出Node.js》中对process.nextTick和setImmediate的不够准确的描述:“在行为上,process.nextTick在每轮循环中将数组中的回调函数全部执行完,而setImmediate在每轮循环中执行链表中的一个回调函数。
3、setImmediate可以使用clearImmediate清除(没搞懂这个到底能干吗,谁明白请告诉我一下),process.nextTick不能被清除
观察者优先级
在每次轮训检查中,各观察者的优先级分别是:
idle观察者 > I/O观察者 > check观察者。
idle观察者:process.nextTick
I/O观察者:一般性的I/O回调,如网络,文件,数据库I/O等
check观察者:setImmediate,setTimeout
知乎上曾有人贴过一段关于setImmediate和setTimeout(xxx,0)的代码,得出了一个这样的结论:“而在执行setImmedia时,setTimeout是随机的插入在setImmediate的顺序中的”。我对这个结论是持怀疑态度的
根本原因是node底层的设计所致,也就是说setTimeout(xxx,0)其实在底层强制设置成等同于setTimeout(xxx,1)。小于1秒都要强制设置成1秒
那就很容易理解知乎这位作者的给出的代码为什么是这样的结果了。因此:setTimeout的优先级高于setImmediate,但是因为setTimeout的after被强制修正为1,这就可能存在下一个tick触发时,耗时尚不足1ms,setTimeout的回调依然未超时,因此setImmediate就先执行了!
优先级顺序:process.nextTick > setTimeout/setInterval > setImmediate
setTimeout需要使用红黑树,且after设置为0,其实会被node强制转换为1,存在性能上的问题,建议替换为setImmediate
process.nextTick有一些比较难懂的问题和隐患,从0.8版本开始加入setImmediate,使用时,建议使用setImmediate