阻塞、非阻塞I/O
操作系统内核对于I/O只有两种方式:阻塞与阻塞
阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才会结束;以读取磁盘上的一段文件为例,系统内核在完成磁盘寻道,读取数据,复制数据到内存中之后这个调用才会结束。阻塞I/O造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用为了提供性能。为此内核提供了非阻塞。非阻塞I/O和阻塞I/O的区别为调用后立即返回。
操作系统对计算机进行了抽象,将所有输入输出的设备抽象为文件。内核在进行文件I/O操作时,通过文件描述符进行管理,而文件描述符类似于应用程序与系统内核之间的凭证,应用程序如果需要进行I/O调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。从这个角度来讲阻塞I/O非阻塞I/O的区别在于阻塞I/O需要完成整个获取数据的过程。
轮询
非阻塞I/O返回后,CPU时间片可以用来出来其他事务,但是也存在一些问题,由于完整的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成,这种重复调用判断操作是否完成叫做轮询
- read:它是最原始,性能最低的一种,通过重复调用来检查I/O的状态来完成完整的数据的读取,在得到最终数据前,CPU一直耗在等待上
- select:它是在read的基础上改进的一种方案,通过对文件描述附上的事件状态来进行判断。select有一个很大的缺陷,它用于存储各文件描述符状态的数组只有1024个字节,所以其最多可以同时检查1024个文件描述符
- epoll:该方案是linux下效率最高的I/O事件通知机制,再进入轮询的时候没有检查到I/O时间,将会进行休眠,指导实践发生将它唤醒,它是真是利用了事件通知,执行会掉的方式,而不是遍历查询,所以不会浪费cpu,执行效率较高
- kqueue:该方案的实现方式与epoll的类似,不过它仅在FreeBSD系统下存在
异步I/O
尽管epoll已经利用了事件降低CPU的耗用,但是休眠期间CPU几乎是闲置的。对于当前的线程而言利用率不够。我们期待一种完美的异步I/O,可以在应用程序发起非阻塞调用,无须通过遍历或者时间唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或回调将数据传递应用程序即可,幸运的是,在linux下存在这样一种方式,它原生提供一种异步I/O方式就是通过信号或回调来传递数据的。
多线程模拟异步I/O
多线程模拟异步I/O可以通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,从而模拟实现了异步I/O
Node中的异步I/O
事件循环:Node采用事件循环作为自身的执行模型,当进程启动时,Node变回创建一个类似于while(true)的循环,每执行一次循环体的过程称为tick,每个tick的过程就是查看是否有事件待处理,如果有,就取出时间及其相关的毁掉函数,如果存在关联的回调函数,就执行他们,然后进入下个循环,如果不再有事件处理就退出进程。
观察者:在每个tick的过程中,Node通过观察者模式来判断是否事件需要处理,在每个事件循环中有一个或多个事件观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。浏览器采用了类似的机制,时间可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者,在Node中,时间主要来源于网络请求,文件I/O等,这些事件对应观察者有文件I/O观察者,网络I/O观察者观察者将事件进行了分类。
事件循环是一个典型的生产者/消费者的模型。异步I/O、网络请求等则是事件的生产者,源源不断的为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者哪里去除事件并处理
Node实现异步I/O的过程
封装请求对象:
fs.open()发出打开文件指令,该函数将会调用Node核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统的调用,在进行系统调用时,javascript层传入的参数和当前方法都被封装成一个请求对象,然后在将该请求对象推入线程池中等待线程执行,至此,Javascript层的调用将返回,调用的第一阶段将结束
执行回调
线程池中的I/O调用完成以后,则将获取的结果存储在req->result属性上,然后通知当前对象操作已完成。在每次tick的执行中,观察者都会调用相关方法检查线程池中是否有执行完的请求,如果存在,会将请求加入到I/O观察者的队列中,然后将其当做事件处理