1.为什么要使用异步I/O
1.1 用户体验
浏览器中的Javascripts是在单线程上执行的,并且和UI渲染公用一个线程。这就意味着在执行Javascript时候UI的渲染和响应是出于停滞的状态,如果脚本执行时间超过100ms用户就能感受到页面卡顿。在B/S模型中如果通过同步方式获取服务器资源Javascript需要等待资源的返回,这段时间UI将会停顿不响应交互。而采用异步方式请求资源的同时Javascript和UI渲染可以继续执行。
通过异步执行可以消除UI阻塞现象,但是获取资源速度取决于服务器的响应,假设有这么个场景,获取两个资源数据:
get('json_a');//需要消耗时间M
get('json_b');//需要消耗时间N
如果采用同步方式获取资源的时间为M+N,如果采用异步方式时间则是max(M,N)。随着网站的扩大,数据将会分布在不同服务器上,分布式也将意味着M与N的值会线性增长。同步与异步的耗时差距也会变大。
1.2 资源的分配
假设一组互不先关的任务需要执行,主流方法有两种:
- 单线程串行依次执行
- 多线程并行完成
如果创建多线程的开销小于并行执行,那么多线程的方式是首选。多线程的代价在于创建线程和执行线程时的上下文切换。在复杂业务中多线程需要面临锁、状态同步问题。优势在于多线程在多核CPU上可以提升CPU利用率。
单线程串行执行缺点在于性能,任意一个任务略慢都会影响下一个执行。通常I/O与CPU计算之间是可以并行进行的,但是同步编程导致I/O的进行会让后续任务等待,造成资源浪费。
Node在两者之间做出了自己的方案:利用单线程,远离多线程死锁、状态同步问题;利用异步I/O,让单线程远离阻塞,更好的利用CPU。
为了弥补单线程无法利用多核CPU缺点,Node提供了类似前端浏览器的Web Workers的子进程,子进程可以通过工作进程高效的利用CPU和I/O。
[异步I/O调用示意图]
2.异步I/O实现
2.1异步I/O与非阻塞I/O
操作系统内核对于I/O只有两种方式:阻塞和非阻塞。调用阻塞I/O时,程序需要等待I/O完成才返回结果,如图:
为了提高性能,内核提供了非阻塞I/O,非阻塞I/O调用之后会立刻返回,如图:
非阻塞I/O返回后,完整的I/O并没有完成,立即返回的不是业务层期望的数据,仅仅是当前调用状态。为了获取完整的数据,应用需要反复调用I/O操作来确认是否完成。这种反复调用判断操作是否完成的计算叫做 轮询。
现存的轮询技术主要有这些:
-
read
最原始的一种方式,通过反复调用I/O状态来完成数据读取,在获取最终数据前,CPU一直耗用在等待是,示意图:
-
select
在read基础上的改进方案,通过文件描述符上的事件状态来进行判断,select轮询有一个限制,它采用一个1024长度的数组来保存储存状态,所以它最多可以检查1024个文件描述符,示意图:
-
poll
采用链表的方式来避免数组长度限制,能避免不需要的检查。当文件描述符多时,性能还是十分低下,于select相似,性能有所改善,如图:
-
epoll
Linux下效率最高的I/O事件通知机制,进入轮询时如果没有检查到I/O事件,将会进行休眠,直到事件将他唤醒。利用的事件通知、执行回调方式,而不是遍历查询,所以不会浪费CPU,执行效率比较高。示意图:
-
kqueue
与epoll类似,只存在FreeBSD系统下。
2.2 理想的非阻塞异步I/O
期望的完美异步I/O应该是程序发起非阻塞调用,无需通过遍历或者事件唤醒等轮询方式,可以进行下一个任务,只需要在I/O完成后通过信号或回调将数据传递给应用程序,示意图:
2.3现实的异步I/O
通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术完成数据获取。让一个线程进行处理计算,通过线程之间的通讯将I/O得到的数据进行传递,实现异步I/O,示意图:
最初Node在*nix平台下采用libeio配合libev实现I/O异步I/O,Node v0.9.3中,自行实现了线程池完成异步I/O。
windows下通过IOCP来实现(实现原理仍然是线程池,只是由系统内核接受管理)。
windows和*nix平台的差异,Node提供了libuv作为封装,兼容性判断由这一层完成,Node编译期间会判断平台条件。
3.Node的异步I/O
3.1事件循环
启动Node时会创建一个类似while(true)的循环,每执行一次循环过程称之为Tick。每个Tick的过程就是检查是否有待处理事件,如果有,就读取出事件及其相关的回调函数,如果存在关联的回调函数,就执行。然后加入下一个循环,如果不再有事件处理就退出进程。如图:
3.2观察者
在每个Tick过程中,怎么判断是否有事件需要处理呢?,这里引入了观察者概念。
每个事件循环中有一个或多个观察者,而判断是否有事件要处理的过程就是向观察者询问是否需要处理事件。
3.3请求对象
Javascript发起调用到内核执行完I/O操作的过程中,存在一种中间产物,叫做请求对象。
以fs.open()为例:
fs.open = function(path, flags, mode, callback) {
//...
binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);
}
fs.open()是根据指定路径和参数打开一个文件,从而获取一个文件描述符,这是后续所有I/O操作的初始操作。
Javascript层面的代码调用C++核心模块进行下层操作。示意图:
实际上调用了uv_fs_open()方法。在调用过程中创建了一个FSReqWrap请求对象。从Javascriptc层传入的参数和当前方法都封装在这个请求对象中,回调函数则被设置在对象的oncomplete_sym属性上:
req_wrap->object_->Set(oncomplete_sym, callback);
对象包装完毕,将FSReqWrap对象推入线程池中等待执行。此时Javascript调用立即返回,Javascript线程可继续执行当前任务的后续操作,当前的I/O操作在线程池中等待执行,不管是否是阻塞I/O,的不会影响Javascript线程的后续执行。
请求对象是异步I/O过程的重要中间产物,所有状态都保存在这个对象中,包括送入线程池执行以及I/O操作完毕后的回调处理。
3.4执行回调
线程池中的I/O操作调用完毕后,将获取结果储存在req->result属性上,然后通知IOCP(windows下)告知操作已完成,并归还线程到线程池。
在每次Tick的执行中,它会检查线程池中是否有执行完的请求,如果存在,将请求对象加入I/O观察者列队中,然后将其当做事件处理。
I/O观察者回调函数的行为就是取出请求对象的result属性作为参数然后执行回调,调用Javascript中传入的回调函数,至此,这个I/O流程完全接受,示意图:
在Node中除了Javascript是单线程外,Node自身其他都是多线程的,除了用户代码无法并行执行,所有I/O则是可以并行起来的。
4.非I/O的异步API
无关I/O的异步API
- setTimeout()
- setInterval()
- setImmediate()
- process.nextTick()
4.1定时器
setTimeout()与setInterval()与浏览器API一致,分别用于单次和多次定时执行任务。调用它们时创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次Tick执行会到该红黑树中迭代取出定时器对象,检测是否超时,如果超时则形成一个事件,它的回调函数立即执行。
定时器并非精确,虽然循环非常快但是如果某一次计算占用循环事件特别多,那么下次循环,它可能已经超时很久了。
setTimeout()的行为图:
4.2 process.nextTick()
如果需要一个立即异步执行的任务,可以这样调用:
setTimeout(() =>{
//todo
}, 0);
process.nextTick(() => {
//todo
})
由于定时器需要调用红黑树所有比较浪费性能。process.nextTick()方法比较轻量。每次调用process.nextTick()方法,只会把回调函数放入队列中,在下一轮Tick时取出立即执行。所有process.nextTick()更为高效。
4.3 setImmediate()
setImmediate()与process.nextTick()相似,都是将回调函数延迟执行,process.nextTick()执行回调优先级高于setImmediate()。
process.nextTick(() => {
console.log('process.nextTick');
})
setImmediate(() => {
console.log('setImmediate');
})
console.log('正常执行')
//执行结果
正常执行
process.nextTick
setImmediate
这是因为事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者。
process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中。process.nextTick()在每次循环中会将数组的回调函数全部执行完毕,而setImmediate()每轮循环中执行链表中的一个回调函数 (当前运行node版本是windows8.7.0,新版的setImmediate处理回调函数已经改变,在一轮循环中setImmediate中的回调函数被全部执行)。
列如:
process.nextTick(() => {
console.log('nextTick执行1');
})
process.nextTick(() => {
console.log('nextTick执行2');
})
setImmediate(() => {
console.log('setImmediate执行1');
process.nextTick(() => {
console.log('插入执行');
})
})
setImmediate(() => {
console.log('setImmediate执行2');
})
console.log('正常执行')
//执行结果
正常执行
nextTick执行1
nextTick执行2
setImmediate执行1
setImmediate执行2
插入执行
5.事件驱动与高性能服务器
利用Node构建web服务器流程图:
服务器模型的经典模型:
-
同步式
一次只能处理一个请求,其余请求出于等待状态 -
每进程/每请求
为每个请求创建一个进程,可以同时处理多个请求,不具备高扩展性,系统资源有限。 -
每线程/每请求
为每个请求启动一个新线程。比启动新进程轻量,但是高并发的时候内存将很快耗光。(Apache采用这种模式),线程多了后上下文切换频繁消耗资源。
Node采用事件驱动方式无需为每个请求创建新线程,可以省掉很多开销(Nginx采用与Node相同的事件驱动),即使在大并发的情况下也不受上下文切换开销的影响。