第三章 异步I/O
伴随着异步I/O的还有事件驱动和单线程,它们构成Node的基调。
3.1 为什么要异步I/O
3.1.1 用户体验
采用异步请求,在下载资源期间,JavaScript和UI的执行都不会处于等待状态,可以继续相应用户的交互行为,给用户一个鲜活的页面。
3.1.2 资源分配
Node利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地利用CPU。
为了弥补单线程无法利用多核CPU的缺点,Node提供了类似前端浏览器总Web Woekers的子进程,该子进程可以通过工作进程高效地利用CPU和I/O。
异步I/O的提出是期望I/O的调用不再阻塞后续运算,将原有等待I/O完成的这段时间分配费其余需要的业务中去执行。
3.2 异步I/O实现现状
3.2.1 异步I/O与非阻塞I/O
从实际效果而言,异步和非阻塞都达到了我们并行I/O的目的。但是从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上是两回事。
操作系统内核对于I/O只有两种方式:阻塞与非阻塞。
阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。阻塞I/O造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用。
非阻塞I/O,调用之后会立即返回。
但非阻塞I/O也存在一些问题。由于完整的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。
这种重复调用判断操作是否完成的技术叫做轮询。
现存的轮询技术主要有一下这些:
read
select
poll
epoll:Linux下最高效的I/O事件通知机制。在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。
kqueue
3.2.2 理想的非阻塞异步I/O
我们期望的完美的异步I/O应该是应用程序发起非阻塞调用,无须通过便利或者事件唤醒等待方式轮询,可以直接处理下一个任务,只需要在I/O完成后通过信号或回调将数据传递给应用程序即可。
幸运的是,在Linux下存在这样的方式。但不幸的是,只有Linux下有,而且它还有缺陷(无法利用系统缓存)。
3.2.2 现实的异步I/O
Linux的自定义线程池、Windows平台下的IOCP。
另一个需要强调的地方在于我们时常提到Node是单线程的,这里的单线程仅仅只是JavaScript执行在单线程中罢了。在Node中,无论是*nix还是Windows平台,内部完成I/O任务的另有线程池。
3.3 Node的异步I/O
3.3.1 事件循环
事件循环是Node自身的执行模型。
3.3.2 观察者
在每个Tick的过程中,如何判断否有时间需要处理呢?引入观察者,类似观察这模式思想。
在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。
3.3.3 请求对象
对于Node中的异步I/O调用而言,回调函数却不由开发者来调用。
事实上,从JavaScript发起调用到内核执行完I/O操作的过渡过程中,存在一种中间产物,它叫做请求对象。
Node机制是,把回调函数赋值到请求对象的属性上,再把请求对象放到当前线程池中。当线程池中有可用线程时,就会执行回调方法。
至此,JavaScript调用立即返回,有JavaScript层面发起的异步调用的第一阶段就此结束。JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行。不管他是否阻塞I/O,都不会影响到JavaScript线程的后续执行,如此就达到了异步的目的。
请求对象是异步I/O过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以I/O操作完毕后的回调处理。
3.3.4 执行回调
组装好请求对象,送入I/O想城池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分。
当线程池中的I/O操作调用完毕后,会将获取的结果储存在req对象的result属性上,然后调用方法通知IOCP,告知当前对象操作已经完成。
在这个过程中,其实还动用了实现循环的I/O观察者。在每次Tick的执行中,他会调用IOCP相关方法检查线程池中是否有执行完的请求,若果存在,会将请求对象加入到I/O观察者的对了中,等待处理。
I/O观察者调用函数的行为就是取出请求对象的result属性作为参数,取出complete_sys属性作为方法,然后调用执行。以此达到调用JavaScript中传入的回调函数的目的。
事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素。
Windows下主要通过IOCP来向系统内核发送I/O请求和从内核获取已完成的I/O操作,配以事件循环,以此完成异步I/O的过程。
3.3.5 小结
记住,在Node中,除了JavaScript是单线程外,Node自身其实是多线程的,只是I/O线程使用的CPU较少。另一个需要重视的观点则是,除了用户代码无法执行外,所有I/O(磁盘I/O和网络I/O)则是可以并行起来的。
3.4 非I/O的异步API
尽管我们在介绍Node的时候,多数情况下都会提到异步I/O,但是Node中其实还存在一些与I/O无关的异步API,它们分别是setTimeout()、setInterval()、setImmediate()和process.nextTick()。
3.4.1 定时器
调用setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick执行时,回从该红黑树中迭代取出定时器对象,检查是否超过定时实际,如果超过,就形成一个事件,它的回调函数将立即执行。
定时器的问题在于,它并非精确的(在容忍范围内)。
3.4.2 process.nextTick()
process.nextTick()比setTimeout()方法效率更高。
3.4.3 setImmediate()
setImmediate()方法与process.nextTick()方法十分类似,都是将回调函数延迟执行。
process.nextTick(function(){ console.log("nextTick延迟执行"); }); setImmediate(function(){ console.log("setImmediate延迟执行"); }); console.log("正常执行");
执行结果:
正常执行
nextTick延迟执行
setImmediate延迟执行
从结果里可以看到,process.nextTick()中的回调函数执行的优先级要高于setImmediate()。这里的原因在于事件循环对观察者的检查是又先后顺序的,process.next()属于idle观察者,setTimediate()属于check观察者。在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。
在具体实现上,process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中。在行为上,process.nextTick()在每轮循环中回将数组中回调函数全部执行完,而setImmediate()在每轮循环中执行链表中的一个回调函数。
3.5 事件驱动与高性能服务器
事件驱动的实质:通过主循环加事件触发的方式来运行程序。
下面为几种经典的服务器模型,这里对比下它们的优缺点:
同步试。对于同步试的服务,一次只能处理一个请求,并且其余请求都处于等待状态。
每进程/每请求。为每个请求启动一个进程,这样可以处理多个请求,但是他不具备扩展性,因为系统资源只有那么多。
每线程/每请求。为每个请求启动一个线程来处理。尽管线程比进程要轻量,但是由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。每线程/每请求的扩展性比每进程/每请求的方式要好,但对于大型站点而言依然不够。
Node通过实践驱动的方式处理请求,无须为每一个请求创建额外的线程,可以省掉创建线程和销毁线程的开销,同事操作系统在调度任务时因为线程较少,上线文切换的代价很低。这是Node高性能的一个原因。
3.6 总结
事件循环是异步实现的核心,它与浏览器中的执行模型基本保持了一致。