异步I/O
“异步”这个名词其实很早就诞生了,但它大规模流行却是在Web 2.0浪潮中,它伴随着AJAX的第一个A(Asynchronous)席卷了Web。
为什么要异步I/O
关于异步I/O为何在Node里如此重要,这与Node面向网络而设计不无关系。Web应用已经不再是单台服务器就能胜任的年代了,在跨网络的结构下,并发已经是现代编程中的标准配备了。具体到实处,则可以从用户体验和资源分配这两个方面说起。
用户体验
在浏览器中的JavaScript是在单线程上执行,而且它还与UI渲染共用一个线程。这意味着JavaScript在执行的时候UI渲染和相应是处于停滞状态。如果网页临时需要获取一个网络资源,通过同步的方式获取,那么JavaScript则需要等待资源完全从服务器端获取后才能继续执行,这期间UI将停顿,不响应用户的交互行为。可以想象,这样的用户体验将会多差。
而采用异步请求,在下载资源期间,JavaScript和UI的执行都不会处于等待状态,可以继续响应用户的交互行为,给用户一个鲜活的页面。
同理,假如一个资源来自于两个不同位置的数据的返回,第一个位置的数据获取耗时M,第二个位置的数据获取耗时为N。那么最终服务器采用同步方案总耗时为M + N,异步方案耗时为M 与 N两者之间的最大值。
这就是异步I/O在node中如此盛行,甚至将其作为主要理念进行设计的原因。I/O是昂贵的,分布式I/O更是昂贵的。只有后端能快速响应资源,才能让前端的体验变得更好。
资源分配
单线程同步编程模型会因阻塞I/O导致硬件资源得不到更优的使用。多线程编程模型也因为编程中的死锁、状态同步等问题让开发人员头疼。
node在两者之间给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地利用CPU。
异步I/O
异步I/O在node中应用最为广泛,但它并非node的原创。
异步I/O 与非阻塞I/O
在听到node的介绍时,我们时长会听到异步、非阻塞、回调、事件、这些词语混合在一起推介出来。其中异步与非阻塞听起来似乎是同一回事。从实际效果而言,虽然异步和非阻塞都达到了我们并行I/O的目的。但从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上是两回事。
操作系统内核对于I/O只有两种方式:阻塞与非阻塞。在调用阻塞I/O时,应用程序需要等待I/O完成才返回结果。阻塞I/O造成CPU等待I/O执行完成,浪费等待时间,CPU的处理能力不能得到充分利用。为了提高性能,内核提供了非阻塞I/O。
非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回,但此次的返回并不带数据。之后CPU的时间片可以用来处理其他事务,此时性能提升是明显的。
而要获取数据,还需要应用程序重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫做轮询。
任何技术都并非完美。阻塞I/O造成CPU等待浪费,而非阻塞带来的麻烦却是应用层需要轮询去确认是否完成数据获取,它会让CPU对完成状态做判断处理,是对CPU资源的浪费。
轮询技术满足了非阻塞I/O确保获取完整数据的需求,解放了CPU的闲置状况。但对于应用程序而言,它任然只能算是一种同步,因为应用程序仍然需要等待I/O完全返回数据,依旧花费了很多时间来等待。
理想的非阻塞异步I/O
我们期待的完美的异步I/O应该是应用程序发起非阻塞调用,无需通过遍历或者事件唤醒等方式去轮询,可以直接处理下一个任务,只需要在I/O完成操作后通过信号或回调将数据传递给应用程序即可。
现实的异步I/O
要达成异步I/O的目标,并非难事。通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O。
node的异步I/O
介绍完系统对I/O的支持后,我们将讨论node是如何实现异步I/O的。完成整个异步I/O环节的有事件循环、观察者和请求对象等。
事件循环
首先我们着重强调一下node自身的执行模型-事件循环,正是它使得回调函数十分普遍。
在进程启动时,node便会创建一个类似while(true)的循环,每执行一次循环体的过程我们称为Tick。
每个Tick的过程就是查看是否有事件等待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进行下一个循环,如果不再有事件处理,就退出进程。
观察者
在每个Tick的过程中,如果判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件产生,而这些产生的事件都有对应的观察者。在node中,事件主要源于网络请求、文件I/O等,这些事件对应的观察者有I/O观察者、网络I/O观察者等。
事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,它们不断的为node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观者者那里取出事件并处理。
请求对象
对于一般的(非异步)回调函数,函数由我们自行调用,如下所示:
var forEach = function (list, callback) {
for (var i = 0; i < list.length; i++) {
callback(list[i], i, list)
}
}
而对于node中的异步I/O调用而言,回调函数却不由开发者来调用。那么从我们发出调用后,到回调函数被执行,中间发生了什么呢?事实上,从JavaScript发起调用到内核执行完成I/O操作的过渡过程中,存在一种中间产物,它叫做请求对象。
当发起一个请求,就会创建一个请求对象。我们最为关注的回调函数则被设置在了这个请求对象的一个属性上。
对象包装完毕后,会将请求对象推入一个线程池中等待执行。
至此,JavaScript调用立即返回,由JavaScript层面发起的异步调用第一阶段就此结束。JavaScript线程可以继续执行当前任务的后续操作。而请求对象(某个I/O操作)则在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行,如此就达到了异步的目的。
请求对象是异步I/O过程中的重要中间产物,所有状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调执行。
执行回调
组装好请求对象、送入I/O线程池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分。线程池中的I/O操作调用完毕后,会将获取的结果存储在req->result属性上,然后告知当前对象操作已经完成。
在这个过程中,我们其实还动用了事件循环的I/O观察者。在每次Tick的执行中。它会调用相关方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到I/O观察者的队列中,然后将其当作事件处理。
I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,然后调用请求对象中的回调函数调用执行,以此达到调用JavaScript中传入的回调函数的目的。
至此,整个异步I/O的流程完全结束,具体步骤如下:
异步调用
- 开始
- 发起异步调用
- 封装请求对象
- 设置参数和回调函数
- 将请求对象放入线程池等待执行
- 结束
线程池
- 开始
- 线程可用
- 执行请求对象中的I/O操作
- 将执行完成的结果放在请求对象中
- 通知事件循环调用完成
- 归还线程
事件循环
- 开始
- 创建主循环
- 从I/O观察者取到可用的请求对象
- 取出回调函数和结果调用执行
- 从线程池中获取完成的I/O交给I/O观察者