为什么要异步I/O
异步I/O、事件驱动、单线程构成了Node的基调,Node可以作为服务器端去处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。
使用异步I/O的两个原因:
用户体验
1、同步会在数据请求阶段阻塞用户与UI界面的交互;
2、前端获取资源的速度:多个资源的请求在同步情况下的耗时是M+N,而异步是Max(M, N),随着应用复杂性的增加,异步的优势将越来越大。
资源分配
假设业务场景中有一组互不相关的任务需要完成,现行的主流方法有以下两种:单线程串行依次执行 和 多线程并行完成。
- 多线程
多线程的代价在于创建线程和执行期线程上下文切换的开销较大。而且,多线程变成经常面临锁,状态同步等问题。但是多线程的优势是在多核CPU上能够有效提升CPU的利用率。
- 单线程
在计算机资源中,通常I/O与CPU计算之间是可以并行进行的,但是同步的编程模型会导致的问题是,I/O的进行会让后续任务等待,造成资源的浪费。
Node的解决方案
利用单线程,远离多线程死锁、状态同步等问题,利用异步I/O,让单线程远离阻塞,以更好地使用CPU。为了弥补单线程无法利用多核CPU的缺点,Node提供了类似前端浏览器中Web Workers的子进程,该子进程可以通过工作进程高效地利用CPU和I/O。
异步I/O的提出是期望I/O的调用不再阻塞后续运算,将原有等待I/O完成的这段时间分配给其余需要的业务去执行。
异步I/O实现现状
非阻塞I/O
阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。
非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回。不会花时间去等待,充分利用了CPU。
但是非阻塞I/O也存在一些问题,由于完成的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态,为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成,这种重复调用判断操作是否完成的技术叫轮询。
阻塞I/O造成CPU等待浪费,非阻塞I/O带来的麻烦却是需要轮询去确认是否完全完成数据获取,它会让CPU处理状态判断,是对CPU资源的浪费。轮询技术演进,就是以减少 I/O状态判断的CPU损耗 为目的进行的。
epoll: 是Linux下效率最高的I/O时间通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒,它是真实利用了事件通知,执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。但是休眠期间CPU几乎是闲置的,对于当前线程而言利用率不够。
轮询技术满足了非阻塞I/O确保获取完整数据的需求,但是应用程序仍然需要花费时间等待I/O完全返回,等待期间,CPU要么用于遍历文件描述符的状态,要么用于休眠等待事件发生,结论是它不够好。
理想下的异步I/O
我们期望的完美异步I/O应该是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或回调将数据传递给应用程序即可。
现实的异步I/O
多线程方式的异步I/O设想:通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O。
libeio是一个异步I/O的库,是采用线程池与阻塞I/O模拟异步I/O,最初Node在*nix平台下采用了libeio实现异步I/O,在Node v0.9.3中自行实现线程池来完成异步I/O。
而在windows平台下的异步I/O方案则是IOCP:调用异步方法,等待I/O完成之后的通知,执行回调,用户无须考虑轮询,但是它的内部其实仍然是线程池原理,不同之处在于这些线程池由系统内核接受管理。
由于windows平台和*nix平台的差异,Node提供了libuv作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的Node与下层的自定义线程池及IOCP之间各自独立。Node会在编译期间判断平台条件,选择性编译unix目录或win目录下的源文件到目标程序中。
另一个需要强调的地方在于我们时常提到Node是单线程的,这里的单线程仅仅是JavaScript执行在单线程中罢了。在Node中,无论是*nix还是Windows平台,内部完成I/O任务的另有线程池。
Node的异步I/O
事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素。
事件循环
Node自身的执行模型就是事件循环,在进程启动时,Node便会创建一个类似于while(true)的循环,每执行一次循环体的过程称为Tick,每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数就执行它们。然后进入下个循环,如果不再有事件处理,就退出进程。
观察者
每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
浏览器采用了类似的机制,事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者,在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。观察者将事件进行了分类。
事件循环是一个典型的生成者/消费者模型,异步I/O、网络请求等则是事件的生产者,源源不断为Node提供不同类型的时间,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。
在Windows下,这个循环基于IOCP创建,而在*nix下则基于多线程创建。
请求对象
从JavaScript发起调用到内核执行完I/O操作的过渡过程中,存在一种中间产物,它就叫请求对象。
从JavaScript调用Node的核心模块,核心模块调用c++内建模块,内建模块通过libuv(封装层,兼容两个平台)进行系统调用。在这个调用过程中,就会封装一个请求对象,把JavaScript层传入的参数和当前方法都封装在这个对象上,其中回调函数也被设置在这个对象上。
请求对象封装完成之后,就将这个请求对象推入线程池中等待执行,至此JavaScript调用立即返回,由JavaScript层面发起的异步调用的第一阶段就此结束,当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行。
执行回调
异步调用的第二部分就是执行回调,当I/O操作完成之后,会将获取的结果存在请求对象的result属性上,并发出操作完成通知,并将线程规还线程池。
每次事件循环时会检查 I/O 线程池中是否存在已经完成的 I/O 操作,如果有就将请求对象加入到I/O观察者队列当中(事件队列),之后当作事件处理。
事件循环时查看到有事件需要处理,就会从I/O观察者中取到可用的请求对象,从中取出回调函数和请求结果并调用执行。
非I/O的异步API
Node中还存在一些与I/O无关的异步API,它们分别是setTimeout()、setInterval()、setImmediate()和process.nextTick()。
定时器
setTimeout()、setInterval()与浏览器中的API一致,调用setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数将立即执行。
process.nextTick()
定义出一个动作,并且让这个动作在下一个事件轮询的时间点上执行。
每次调用process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行,定时器中采用红黑树的操作时间复杂度为O(lg(n)),nextTick()的时间复杂度为O(1),相较之下,process.nextTick()更高效。
setImmediate()
该方法用来把一些需要长时间运行的操作放在一个回调函数里,在完成后面的其他语句后,就立刻执行这个回调函数
setImmediate()函数方法与process.nextTick()方法十分类似,都是将回调函数延迟执行,process.nextTick()中的回调函数的优先级高于setImmediate(),这里的原因在于事件循环对观察者的检查所有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者,setTimeout()属于I/O观察者,在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。
事件驱动与高性能服务器
事件驱动的实质:即通过主循环加事件触发的方式来运行程序。
几种典型的服务器模型
同步式:对于同步式的服务,一次只能处理一个请求,并且其余请求都处于等待状态。
每进程/每请求:为每一个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多。
每线程/每请求:为每一个请求启动一个线程来处理,尽管线程比进程要轻量,但是由于每个线程都要占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢,每线程/每请求的扩展性比每进程/每请求的方式要好,但对于大型站点而言依然不够。
Node通过事件驱动的方式处理请求,无须为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销。