Node中的事件循环和异步API
1. 介绍
单线程编程会因阻塞I/O导致硬件资源得不到更优的使用。多线程编程也因为编程中的死锁、状态同步等问题让开发人员头痛。
Node在两者之间给出了它的解决方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以好使用cpu。
实际上,node只是在应用层属于单线程,底层其实通过libuv维护了一个阻塞I/O调用的线程池。
但是:在应用层面,JS是单线程的,业务代码中不能存在耗时过长的代码,否则可能会严重拖后续代码(包括回调)的处理。如果遇到需要复杂的业务计算时,应当想办法启用独立进程或交给其他服务进行处理。
1.1 异步I/O
在Node中,JS是在单线程中执行的没错,但是内部完成I/O工作的另有线程池,使用一个主进程和多个I/O线程来模拟异步I/O。
当主线程发起I/O调用时,I/O操作会被放在I/O线程来执行,主线程继续执行下面的任务,在I/O线程完成操作后会带着数据通知主线程发起回调。
1.2 事件循环
事件循环是Node的执行模型,正是这种模型使得回调函数非常普遍。
在进程启动时,Node便会创建一个类似while(true)的循环,执行每次循环的过程就是判断有没有待处理的事件,如果有,就取出事件及其相关的回调并执行他们,然后进入下一个循环。如果不再有事件处理,就退出进程。
1.3 观察者
在每个循环中,怎么判断是否有事件需要处理呢?这里就要引入观察者了。每个事件循环中都有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
事件循环是一个典型的生产者/消费者模型,异步I/O、网络请求等是事件的生产者,源源不断的为Node提供不同类型的事件,这些事件被传递到观察者那里,事件循环则从观察者那里取出事件并处理。
Node中的观察者处理的优先级不同,idle观察者 > I/O观察者其 > check观察者
- idle观察者:顾名思义,就是早已等在那里的观察者,
process.nextTick
就属于这类 - I/O观察者:顾名思义,就是I/O相关观察者,也就是I/O的回调事件,如网络,文件,数据库I/O等
- check观察者:顾名思义,就是需要检查的观察者,
setImmediate
就属于这类
1.4 请求对象
对于Node中的异步I/O调用而言,回调函数不由开发者来调用,从JS发起调用到I/O操作完成,存在一个中间产物,叫请求对象。
在JS发起调用后,JS调用Node的核心模块,核心模块调用C++内建模块,內建模块通过libuv判断平台并进行系统调用。在进行系统调用时,从JS层传入的方法和参数都被封装在一个请求对象中,请求对象被放在线程池中等待执行。JS立即返回继续后续操作。
1.5 执行回调
在线程可用时,线程会取出请求对象来执行I/O操作,执行完后将结果放在请求对象中,并归还线程。
在事件循环中,I/O观察者会不断的找到线程池中已经完成的请求对象,从中取出回调函数和数据并执行。
这里写图片描述
跑完当前执行环境下能跑完的代码。每一个事件消息都被运行直到完成为止,在此之前,任何其他事件都不会被处理。这和C等一些语言不通,它们可能在一个线程里面,函数跑着跑着突然停下来,然后其他线程又跑起来了。JS这种机制的一个典型的坏处,就是当某个事件处理耗时过长时,后面的事件处理都会被延后,直到这个事件处理结束,在浏览器环境中运行时,可能会出现某个脚本运行时间过长,页面无响应的提示。Node环境则可能出现大量用户请求被挂起,不能及时响应的情况。
2. 非I/O的异步API
Node中除了异步I/O之外,还有一些与I/O无关的异步API,分别是:setTimeout()
、setInterval()
、process.nextTick()
、setImmediate()
,他们并不是像普通I/O操作那样真的需要等待事件异步处理结束再进行回调,而是出于定时或延迟处理的原因才设计的。
2.1 setTimeout()
与setInterval()
这两个方法实现原理与异步I/O相似,只不过不用I/O线程池的参与。
使用它们创建的定时器会被放入定时器观察者内部的一个红黑树中,每次事件循环执行时会从观察者中取出并判断是否超过定时时间,超过就形成一个事件,回调立即执行。
所以,和浏览器中一样,这个并不精确,会被长时间的同步事件阻塞。
值得一提的是,在Node的setTimeout的源码中:
// Node源码 after *= 1; // coalesce to number or NaN if (!(after >= 1 && after <= TIMEOUT_MAX)) { if (after > TIMEOUT_MAX) { process.emitWarning(...); } after = 1; // schedule on next tick,follows browser behavior }
意思是如果没有设置这个after,或者小于1,或者大于TIMEOUT_MAX(2^31-1),都会被强制设置为1ms。也就是说setTimeout(xxx,0)其实等同于setTimeout(xxx,1)。
2.2 process.nextTick()
有时我们想要立即异步执行一个任务,可能会使用延时为0的定时器,但是这样开销很大。我们可以换而使用process.nextTick()
,它会将传入的回调放入nextTickQueue
队列中,下一轮Tick之后取出执行,不管事件循环进行到什么地步,都在当前操作结束的时候调用,参见Nodejs官网。
相比于在定时器中采用红黑树树的操作时间复杂度为0(lg(n)),而process.nextTick()
的时间复杂度为0(1),相比之下更高效。
不过nextTick
方法有个有意思的地方:
process.nextTick(function() { console.log('next tick 1') process.nextTick(function() { console.log('next tick 2') }) process.nextTick(function() { console.log('next tick 3') }) }) setTimeout(function() { console.log('set timeout') },0) setImmediate(function() { console.log('set Immediate') }) console.log('开始了') // 输出: 开始了 next tick 1 next tick 2 next tick 3 set timeout set Immediate
上面代码中表明,由于process.nextTick方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行。
我们由此得到了一个重要区别:多个process.nextTick语句总是一次执行完,多个setImmediate则需要多次才能执行完。事实上,这正是Node.js 0.10.0版添加setImmediate方法的原因,否则递归调用process.nextTick,将会没完没了,主线程根本不会去读取"事件队列",导致阻塞后续调用。
2.3 setImmediate()
虽然setTimeout的优先级高于setImmediate,但是因为setTimeout的after被强制修正为1,这就可能存在下一个tick触发时,耗时尚不足1ms,setTimeout的回调依然未超时,因此setImmediate就先执行了!这可以通过在本次tick中加入一段耗时较长的代码来来保证本次tick耗时必须超过1ms来检测:
setTimeout(function() { console.log('setTimeout') },0) setImmediate(function() { console.log('setImmediate') }) // 输出:set Immediate setTimeout
与
setTimeout(function() { console.log('set timeout') },0) setImmediate(function() { console.log('set Immediate') }) for (let i = 0; i < 100000; i++) {} // 可以保证执行时间超过1ms // 稳定输出: setTimeout setImmediate
以上两个例子可以清楚的证明,setTimeout是优先于setImmediate被处理的。
2.4 结论
按执行优先级:
-
process.nextTick()
,效率最高,消费资源小,但会阻塞cpu的后续调用; -
setTimeout()
,精确度不高,可能有延迟执行的情况发生,且因为动用了红黑树,所以消耗资源大; -
setImmediate()
,消耗的资源小,也不会造成阻塞,但效率也是最低的。
网上的帖子大多深浅不一,甚至有些前后矛盾,在下的文章都是学习过程中的总结,如果发现错误,欢迎留言指出~
参考:
Node——异步I/O
Node探秘之事件循环
细说setTimeout/setImmediate/process.nextTick的区别
深入浅出Nodejs
Node官方文档