Node的异步I/O
我们为什么需要异步I/O?
- 用户体验
服务器端如果基于同步执行的,随着应用复杂性的增加,响应的总耗时为M+N+...的总时间,但是异步执行的话,总耗时则为M、N、...中耗时最长的一个,能够更快速响应资源,让前端的体验更好 - 资源分配
Node利用单线程,远离多线程、状态同步等问题,利用异步I/O,让单线程远离阻塞,以更好地利用CPU
异步I/O与非阻塞I/O
操作系统内核对于I/O只有两种方式:阻塞与非阻塞
阻塞I/O: 调用之后一定要等到系统内核层面完成所有操作后,调用才结束
阻塞I/O造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用。
非阻塞I/O:调用后会立刻不带数据立刻返回(返回的仅仅是当前调用的状态),要获取数据,还需要通过文件描述符进行再次读取。
应用程序需要重复调用I/O操作来确认是否完成,称为轮询
主要的轮询技术:
- read。最原始,性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的读取
- select。通过对文件描述符的事件状态来进行判断(仅轮询一次即可,可以同时检查1024个文件描述符,但是会持续等待到数据读取完成为止)
- poll。与select类似,但是采用链表的方式来存储状态。其次它能避免不需要的检查
- epoll。该方案是Linux下效率最高的的I/O事件通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。不会浪费CPU,执行效率较高。
- kquue。与epoll类似,不过仅在FreeBSD系统下存在
现实的异步I/O
通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,实现异步I/O
因此,Javascript只需在单线程(主线程)中执行,内部完成I/O任务的另有线程池。
Node的异步I/O
事件循环
在进程启动时,Node便会创建一个类似while(true)的循环,每执行一次循环体的过程称为Tick,每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数进而执行,然后进入下个循环,如果不再有事件处理,则跳出流程
观察者
每个Tick过程中,由一个或多个观察者来判断是否有要处理的事件。
异步I/O、网络请求等是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处里
请求对象
事实上,从Javascript发起调用到内核执行完成I/O操作的过渡过程中,存在一种中间产物,叫做请求对象
拿fs.open()作为例子
(1)fs.open()根据路径和参数去打开一个.cc文件(C++内建模块),从而得到一个文件描述符
(2)然后这个.cc文件经过libuv平台判断调用对应平台的uv_fs_open()方法
(3)在uv_fs_open()调用过程中,创建了一个FSReqWrap请求对象,而从Javascript层传入的参数和当前方法都会封装到这个请求对象上,而回调函数则被设置在这个对象的oncomplete_sym属性上
req_wrap->object->Set(oncpmplete_sym,callback);
(4)对象包装完成后,在Windows下,调用QueueUserWorkItem()方法将这个FSReaWrap对象推入线程池中等待执行
QueueUserWorkItem(&uv_fs_thread_proc,req,WT_EXECUTEDEFAULT)
/*
接收三个参数:
①将要执行的方法的引用
②将要执行的方法运行时所需要的参数
③执行的标志
*/
(5)当有可用线程时,会调用uv_fs_thread_proc()方法,这个方法会根据传入参数的类型调用相应的底层函数。以uv_fs_open()为例,实际上调用的是fs_open()方法
(6)至此,Javascript调用立即返回,由Javascript层面发起的异步调用的第一阶段就此结束,因此javascript可继续执行其他操作,从而达到异步的目的
执行回调(处理请求对象)
(1)线程中的I/O操作调用完毕之后,会将获取的结果储存在req->result属性上,然后调用PostQueuedCompletionStatus()通知IOCP,告知当前对象操作已经完成
PostQueuedCompletionStatus()方法的作用是向IOCP提交执行状态,并把线程归还线程池,这个状态可以通过GetQueuedCompletionStatus()提取
(2)每次Tick的执行中,观察者会调用IOCP相关的GetQueuedCompletionStatus()检查线程池中是否有执行完的请求,如果存在,会把请求对戏那个加入到I/O观察者的队列中,然后将其当做事件处理
(3)取出请求对象的result属性作为参数,取出oncomplete_sym属性(传入的回调函数)作为方法,然后调用执行,以此达到调用Javascript中传入的回调函数的目的。
总结
一个异步I/O经历了请求对象、I/O线程池、观察者、事件循环这四个步骤,构成了异步I/O模型的基本要素。windows下主要通过IOCP来向系统内核发送I/O调用和从内核获取已完成的I/O操作,配以事件循环,以此完成异步I/O的过程。
非I/O的异步API
-
定时器
调用setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时事件,如果超过就形成一个事件,它的回调函数将会被推入handles中排队等候被执行 -
process.nextTick()
每次调用process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick取出执行。复杂度更低,性能比setTimeout更高效。 -
setImmediate()
与process.nextTick()类似,但process.nextTick中的回调函数执行的优先级要高于setImmediate(),因为事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmeditate属于check观察者。
以上参考《深入浅出Node.js》一书