单线程编程会因阻塞I/O致使硬件资源得不到更优的使用。多线程编程也由于编程中的死锁、状态同步等问题让开发人员头痛。
Node在二者之间给出了它的解决方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以好使用CPU。html
实际上,node只是在应用层属于单线程,底层其实经过libuv维护了一个阻塞I/O调用的线程池。前端
可是:在应用层面,JS是单线程的,业务代码中不能存在耗时过长的代码,不然可能会严重拖后续代码(包括回调)的处理。若是遇到须要复杂的业务计算时,应当想办法启用独立进程或交给其余服务进行处理。node
在Node中,JS是在单线程中执行的没错,可是内部完成I/O工做的另有线程池,使用一个主进程和多个I/O线程来模拟异步I/O。
当主线程发起I/O调用时,I/O操做会被放在I/O线程来执行,主线程继续执行下面的任务,在I/O线程完成操做后会带着数据通知主线程发起回调。git
事件循环是Node的执行模型,正是这种模型使得回调函数很是广泛。
在进程启动时,Node便会建立一个相似while(true)的循环,执行每次循环的过程就是判断有没有待处理的事件,若是有,就取出事件及其相关的回调并执行他们,而后进入下一个循环。若是再也不有事件处理,就退出进程。github
Event loop是一种程序结构,是实现异步的一种机制。Event loop能够简单理解为:编程
Node中事件循环阶段解析:segmentfault
┌───────────────────────┐ ┌─>│ timers │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘
每一个阶段都有一个FIFO的回调队列(queue)要执行。而每一个阶段有本身的特殊之处,简单说,就是当event loop进入某个阶段后,会执行该阶段特定的(任意)操做,而后才会执行这个阶段的队列里的回调。当队列被执行完,或者执行的回调数量达到上限后,event loop会进入下个阶段。浏览器
Phases Overview 阶段总览微信
setTimeout()
、setInterval()
设定的回调。close callbacks
、setTimeout()
、setInterval()
、setImmediate()
的回调。setImmediate()
设定的回调。一个timer
指定一个下限时间而不是准确时间,定时器setTimeout()
和setInterval()
在达到这个下限时间后执行回调。在指定的时间事后,timers会尽早的执行回调,可是系统调度或者其余回调的执行可能会延迟它们。
从技术上来讲,poll
阶段控制timers何时执行,而执行的具体位置在timers。
下限的时间有一个范围:[1, 2147483647]
,若是设定的时间不在这个范围,将被设置为1。多线程
执行除了close callbacks
、setTimeout()
、setInterval()
、setImmediate()
回调以外几乎全部回调,好比说TCP链接发生错误。
系统内部的一些调用。
这是最复杂的一个阶段。poll
会检索新的I/O events
,而且会在合适的时候阻塞,等待回调被加入。
poll阶段有两个主要的功能:一是执行下限时间已经达到的timers的回调,一是处理poll队列里的事件。
注:Node不少API都是基于事件订阅完成的,这些API的回调应该都在poll阶段完成。
当事件循环进入poll阶段:
poll
队列不为空的时候,事件循环确定是先遍历队列并同步执行回调,直到队列清空或执行回调数达到系统上限。poll
队列为空的时候,这里有两种状况。
setImmediate()
设定了回调,那么事件循环直接结束poll
阶段进入check
阶段来执行check
队列里的回调。若是代码没有被设定setImmediate()
设定回调:
Node的不少API都是基于事件订阅完成的,好比fs.readFile,这些回调应该都在poll
阶段完成。
setImmediate()
在这个阶段执行。
这个阶段容许在poll
阶段结束后当即执行回调。若是poll
阶段空闲,而且有被setImmediate()
设定的回调,那么事件循环直接跳到check
执行而不是阻塞在poll阶段等待poll 事件们 (poll events)被加入。
注意:若是进行到了poll
阶段,setImmediate()具备最高优先级,只要poll
队列为空且注册了setImmediate(),不管是否有timers
达到下限时间,setImmediate()的代码都先执行。
若是一个socket或handle被忽然关掉(好比socket.destroy()
),close
事件将在这个阶段被触发,不然将经过process.nextTick()触发。
对于Node中的异步I/O调用而言,回调函数不禁开发者来调用,从JS发起调用到I/O操做完成,存在一个中间产物,叫请求对象。
在JS发起调用后,JS调用Node的核心模块,核心模块调用C++内建模块,內建模块经过libuv判断平台并进行系统调用。在进行系统调用时,从JS层传入的方法和参数都被封装在一个请求对象中,请求对象被放在线程池中等待执行。JS当即返回继续后续操做。
在线程可用时,线程会取出请求对象来执行I/O操做,执行完后将结果放在请求对象中,并归还线程。
在事件循环中,I/O观察者会不断的找到线程池中已经完成的请求对象,从中取出回调函数和数据并执行。
跑完当前执行环境下能跑完的代码。每个事件消息都被运行直到完成为止,在此以前,任何其余事件都不会被处理。这和C等一些语言不通,它们可能在一个线程里面,函数跑着跑着忽然停下来,而后其余线程又跑起来了。JS这种机制的一个典型的坏处,就是当某个事件处理耗时过长时,后面的事件处理都会被延后,直到这个事件处理结束,在浏览器环境中运行时,可能会出现某个脚本运行时间过长,页面无响应的提示。Node环境则可能出现大量用户请求被挂起,不能及时响应的状况。
Node中除了异步I/O以外,还有一些与I/O无关的异步API,分别是:setTimeout()
、setInterval()
、process.nextTick()
、setImmediate()
,他们并非像普通I/O操做那样真的须要等待事件异步处理结束再进行回调,而是出于定时或延迟处理的缘由才设计的。
setTimeout()
与setInterval()
这两个方法实现原理与异步I/O类似,只不过不用I/O线程池的参与。
使用它们建立的定时器会被放入timers
队列的一个红黑树中,每次事件循环执行时会从相应队列中取出并判断是否超过定时时间,超过就造成一个事件,回调当即执行。
因此,和浏览器中同样,这个并不精确,会被长时间的同步事件阻塞。
值得一提的是,在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)。
setImmediate()
setImmediate()是放在check
阶段执行的,其实是一个特殊的timer,跑在event loop中一个独立的阶段。它使用libuv
的API来设定在 poll
阶段结束后当即执行回调。
来看看这个例子:
setTimeout(function() { console.log('setTimeout') }, 0) setImmediate(function() { console.log('setImmediate') }) // 输出不稳定
setTimeout与setImmediate前后入队以后,首先进入的是timers
阶段,若是咱们的机器性能通常或者加入了一个同步长耗时操做,那么进入timers
阶段,1ms已通过去了,那么setTimeout的回调会首先执行。
若是没有到1ms,那么在timers
阶段的时候,超时时间没到,setTimeout回调不执行,事件循环来到了poll
阶段,这个时候队列为空,此时有代码被setImmediate(),因而先执行了setImmediate()的回调函数,以后在下一个事件循环再执行setTimemout的回调函数。
setTimeout(function() { console.log('set timeout') }, 0) setImmediate(function() { console.log('set Immediate') }) for (let i = 0; i < 100000; i++) {} // 能够保证执行时间超过1ms // 稳定输出: setTimeout setImmediate
这样就能够稳定输出了。
再一个栗子:
const fs = require('fs') fs.readFile('./filePath.js', (err, data) => { setTimeout(() => console.log('setTimeout') , 0) setImmediate(() => console.log('setImmediate')) console.log('开始了') for (let i = 0; i < 100000; i++) {} }) // 输出 开始了 setImmediate setTimeout
这里咱们就会发现,setImmediate永远先于setTimeout执行。
fs.readFile的回调是在poll
阶段执行的,当其回调执行完毕以后,setTimeout与setImmediate前后入了timers
与check
的队列,继续到poll
,poll
队列为空,此时发现有setImmediate,因而事件循环先进入check
阶段执行回调,以后在下一个事件循环再在timers
阶段中执行setTimeout回调,虽然这个setTimeout已经到了超时时间。
再来个栗子:
一样的,这段代码也是同样的道理:
setTimeout(() => { setImmediate(() => console.log('setImmediate') ); setTimeout(() => console.log('setTimeout') , 0); }, 0);
以上的代码在timers
阶段执行外部的setTimeout回调后,内层的setTimeout和setImmediate入队,以后事件循环继续日后面的阶段走,走到poll
阶段的时候发现队列为空,此时有代码被setImmedate(),因此直接进入check
阶段执行响应回调(注意这里没有去检测timers
队列中是否有成员到达超时事件,由于setImmediate()优先)。以后在下一个事件循环的timers
阶段中再去执行相应的回调。
process.nextTick()
与Promise
对于这两个,咱们能够把它们理解成一个微任务。也就是说,它们其实不属于事件循环的一部分。
有时咱们想要当即异步执行一个任务,可能会使用延时为0的定时器,可是这样开销很大。咱们能够换而使用process.nextTick()
,它会将传入的回调放入nextTickQueue
队列中,下一轮Tick以后取出执行,无论事件循环进行到什么地步,都在当前执行栈的操做结束的时候调用,参见Nodejs官网。
process.nextTick方法指定的回调函数,老是在当前执行队列的尾部触发,多个process.nextTick语句老是一次执行完(无论它们是否嵌套),递归调用process.nextTick,将会没完没了,主线程根本不会去读取事件队列,致使阻塞后续调用,直至达到最大调用限制。
相比于在定时器中采用红黑树树的操做时间复杂度为0(lg(n)),而process.nextTick()
的时间复杂度为0(1),相比之下更高效。
来举一个复杂的栗子,这个栗子搞懂基本上就所有理解了:
setTimeout(() => { process.nextTick(() => console.log('nextTick1')) setTimeout(() => { console.log('setTimout1') process.nextTick(() => { console.log('nextTick2') setImmediate(() => console.log('setImmediate1')) process.nextTick(() => console.log('nextTick3')) }) setImmediate(() => console.log('setImmediate2')) process.nextTick(() => console.log('nextTick4')) console.log('sync2') setTimeout(() => console.log('setTimout2'), 0) }, 0) console.log('sync1') }, 0) // 输出: sync1 nextTick1 setTimout1 sync2 nextTick2 nextTick4 nextTick3 setImmediate2 setImmediate1 setTimout2
process.nextTick()
,效率最高,消费资源小,但会阻塞CPU的后续调用;setTimeout()
,精确度不高,可能有延迟执行的状况发生,且由于动用了红黑树,因此消耗资源大;setImmediate()
,消耗的资源小,也不会形成阻塞,但效率也是最低的。网上的帖子大多深浅不一,甚至有些先后矛盾,在下的文章都是学习过程当中的总结,若是发现错误,欢迎留言指出~
参考:
Node——异步I/O
Node探秘之事件循环
Node探秘之事件循环--setTimeout/setImmediate/process.nextTick的差异
细说setTimeout/setImmediate/process.nextTick的区别
深刻浅出Nodejs
Node官方文档
由setTimeout和setImmediate执行顺序的随机性窥探Node的事件循环机制
Node.js的event loop及timer/setImmediate/nextTick
Node.js 探秘:初识单线程的 Node.js | Taobao FED | 淘宝前端团队
Node.js 事件循环机制 - 一像素 - 博客园
PS:欢迎你们关注个人公众号【前端下午茶】,一块儿加油吧~
另外能够加入「前端下午茶交流群」微信群,长按识别下面二维码便可加我好友,备注加群,我拉你入群~