前言
前端工程师因为需要操纵Ajax(Ajax的A就是Asynchronous的意思),因此,是最了解异步IO的人群之一,另外了解异步IO人群就是操作系统开发工程师了(在操作系统层面,异步是通过信号量、消息等方式进行的)。
但是从另外一个层面来说,异步编程在高级语言中非常少见,这是因为,程序员不是很适应异步编程的开发方式和代码书写习惯。以PHP来说,PHP是一种同步阻塞式的编程语言,甚至连多线程都不提供。这种特点在小型网站中,基本上不会构成任何问题,但是,在复杂的网络应用中,阻塞导致了无法更好的并发。
node是第一个以异步编程为特点的高级语言,同时,还包括了单线程和事件驱动的编程方式和特性。因此,Node是一种非常适合开发IO密集型的程序的语言。(这一点跟Nginx很像,但是Nginx是个服务器,它还是要受制于同步语言的牵制)
异步IO的好处
用户体验
异步IO降低了用户获取资源的响应时间。例如有两个任务,分别耗时t1和t2,那么同步程序消耗的总时间将会大于等于这两个任务的时间总和,也就是t>=t1+t2。而异步IO,因此,两个任务是同时进行的,因此,t<=t1+t2,或者说t=max(t1,t2)。我们来看看代码:
//同步程序
//消耗时间t1
getData('from_db');
//消耗时间t2
getData('from_remote_api');
//异步程序
getData('from_db', function (result) {
// 消耗时间t1
});
getData('from_remote_api', function (result) {
// 消耗时间t2
});
因为,当下的网络环境分布式是一种常态,因此,异步IO的优势非常明显。此处附上书中给的不同IO的消耗cpu时钟。
因为,后端的响应速度提升了,因此,前端的用户体验也会更好。
资源分配
计算机组件分为IO设备和计算设备。因此,之前解决业务需求的普遍做法是单线程和多线程,我们来比较一下:
线程 | 说明 |
---|---|
单线程 | 串行执行程序,one by one,虽然可以增加进程来提升效率,但是,这个进程的提升是通过增加机器来实现的,从经济角度来考虑的话,并不实惠 |
多线程 | 多线程的代价在于创建线程和执行期线程上下文切换的开销。一般来说,多线程效率会优于单线程(因此可以利用多核CPU并有效的提升CPU使用率),但是多线程会面临锁、状态同步等问题 |
因此,IO设备和计算设备是可以并行进行的,因此,就有了node的异步IO和事件驱动。
node的解决方案
node利用单线程,多进程的方式(类似于前端浏览器的Web Workers子进程的方式),远离了多线程死锁、状态同步等问题,利用异步IO和事件驱动,让单线程也可以远离阻塞,更好的使用CPU资源。
异步IO和非阻塞IO
操作系统层面,操作系统内核对应IO只有两种方式:阻塞和非阻塞。
阻塞IO举例:读取磁盘文件,系统内核在完成磁盘寻道、读取数据、复制数据到内存,这个调用才结束。
注意:操作系统对计算机进行了抽象,所以IO设备都被抽象为了文件,内核在文件IO操作时,通过文件描述符进行管理,而文件描述符类似于应用程序与系统内核之间的凭证。应用程序如果需要进行IO调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。
那么非阻塞IO呢,他会在调用后,马上完成回调。但是任务仍然在后台运行,直到任务完成,才会返回最终的信号,或者,再次读取文件。
此处,非阻塞IO与阻塞IO的区别在于阻塞IO完成整个获取数据的过程,而非阻塞IO则不带数据直接返回,需要获取数据,还需要通过文件描述符再次读取。但是,由于不知道任务何时真正完成,因此,需要轮询访问任务,查看是否已经完成,这无形当中增加了系统资源的占用。
操作系统的轮询种类
read
read通过了一种反复查看IO状态的形式来完成轮询,并读取数据。cpu一直耗用在无谓的轮询上,资源浪费明显。
select
通过select对文件描述符上的事件状态进行判断,一旦读取完成,则再次调用read完成真正的读取。这个方式也有资源浪费。并且,select采用了1024长度的数组来存储状态,也就是说,select最多只能同时检查1024个文件描述符。
poll
poll是采用了链表的select,避免了数组的长度限制。但是,还是要不断的检查状态,还是存在cpu资源的浪费。并且,书中说,当文件描述符较多时,它的性能比较低下。
epoll
epoll在进入轮询后,如果没有检查到IO事件,将会进行休眠,直到事件将其唤醒,这个方案利用了事件通知、执行回调的方式,避免了无谓的遍历查询,减少了cpu资源的浪费。另外,还有FreeBSD下的kqueue,这个跟epoll类似,书中没有详细介绍。
轮询小结
从本质上来讲,轮询技术还是一种同步执行的程序,要么不断的遍历,要么进行休眠。这使得程序依旧需要花费时间进行等待。
理想的非阻塞异步IO
我们期待的理想非阻塞异步IO是这样的:
应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式的轮询,可以直接处理下一个任务,只需要在IO完成后通过信号或者回调将数据传递给应用程序即可。我们来看一下理想的非阻塞异步IO的示意图:
这种方式使用了信号或者回调来传递数据,在linux下用原生的这种IO,我们称之为AIO。但是,这种方式只在linux下存在,而且,AIO仅支持内核IO的o_direct方式读取,导致无法利用系统缓存。
(O_DIRECT和O_SYNC是系统调用open的flag参数。通过指定open的flag参数,以特定的文件描述符打开某一文件。这两个flag会对写盘的性能有很大的影响。)
/* Open new or existing file for reading and wrting,
sync io and no buffer io; file permissions read+
write for owner, nothing for all others */
fd = open("myfile", O_RDWR | O_CREAT | O_SYNC | O_DIRECT, S_IRUSR | S_IWUSR);
if (fd == -1)
errExit("open");
//O_DIRECT: 无缓冲的输入、输出。
//O_SYNC:以同步IO方式打开文件。
为了解决这种问题,我们通过增加线程来实现:
通过让部分线程进行阻塞IO或者非阻塞IO轮询来获取数据,让一个线程进行计算处理,通过线程之间的通信将IO得到的数据进行传递,这就实现了异步IO。glibc的AIO就是典型的线程池模拟异步IO的程序,但是,存在bug不推荐使用。libev的作者推出了libeio异步IO库,这个库采用了线程池和阻塞IO来模拟异步IO,node在*nix平台下采用了libeio配合libev来实现异步IO,并在v0.9.3后,自行实现了线程池来完成这种异步IO。
在win平台上,win使用了IOCP来实现异步IO,调用异步IO,等待IO完成后通知并执行回调,用户无需考虑轮询,但是他的内部仍然是线程池的原理,这些线程池由系统接手管理。
IOCP的异步IO模型,与node的异步调用模型十分近似。为了平衡差异,node提供了libuv作为抽象封装层,对于平台进行了兼容。在node编译期间会判断平台条件,选择性的编译unix目录或win目录下的源文件到目标程序:
注意:我们时常提到的node是单线程的,这里的单线程仅仅只是js执行在单线程中,在node里,无论哪个平台,内部完成IO任务的都另有线程池。
node的异步IO
完成node整个异步IO环节的有事件循环、观察者、请求对象
事件循环
事件循环是node的自身执行模型,正是事件循环使得回调函数得以在node中大量的使用。在进程启动时node会创建一个while(true)循环,这个和Netty也是一样的,每次执行循环体,都会完成一次Tick。每个Tick的过程就是查看是否有事件等待被处理。如果有,就取出事件及相关的回调函数,并执行关联的回调函数。如果不再有事件处理就退出进程。
观察者
观察者模式又可以称为“生产者-消费者模式”,在node中,每个事件循环中会有一个或者多个观察者,这些观察者都注册了相关的事件,等待事件的完成,并调用回调函数。(例如node中的网络IO观察者、文件IO观察者等)事件循环会不断的从观察者那里取出事件并处理,最终返回回调函数。
在win下,这个循环基于IOCP,在*nix下,则基于多线程创建。
请求对象
js层面发起异步调用
请求对象这个概念比较重要,因此书中给了一个机遇win下异步IO(利用IOCP)实现的简单例子来探寻从JS代码到系统内核之间都发生了什么。
对于一般的非异步回调函数,函数由我们自行调用,如下所示:
var forEach = function (list, callback) {
for (var i = 0; i < list.length; i++) {
callback(list[i], i, list);
}
};
对于node中的异步IO调用而言,回调函数却不由开发者调用,这种调用,从js发起调用到内核执行完IO操作的过程中,存在一种中间产物:请求对象。以fs.open()来说明,通过这个例子我们将要探讨Node与底层之间是如何执行异步IO调用以及回调函数究竟是如何被调用执行的:
fs.open = function(path, flags, mode, callback) {
// ...
binding.open(pathModule._makeLong(path),
stringToFlags(flags),
mode,
callback);
};
fs.open()是根据指定路径和参数打开一个文件,并得到文件描述符,这是后续所有IO操作的初始操作。JS层面的代码通过调用C++核心模块进行下层操作。
我们可以看出,第一步是js调用node核心模块,第二步是核心模块调用c++内建模块,第三步是内建模块通过libuv进行系统调用,调用fs.c,实质上是调用uv_fs_open()。在这个调用过程中,创建了FSReqWrap请求对象。从js层传入的参数和当前方法都被封装在这个请求对象中,回调函数则被设置在这个对象的oncomplete_sym属性上:
req_wrap->object->Set(oncomplete_sym,callback);
对象包装完毕后,在win下,则调用QueueUserWorkItem()方法将这个FSReqWrap对象推入线程池中,等待执行:
QueueUserWorkItem(&uv_fs_thread_proc, /*执行方法的句柄,这个句柄就是uv_fs_thread_proc*/
req, /*uv_fs_thread_proc方法的运行时参数*/
WT_EXECUTEDEFAULT) /*执行标志*/
当线程池中有可用线程时,我们会调用uv_fs_thread_proc()方法,该方法根据传入参数的类型调用相应的底层函数,也就是uv_fs_open()调用的是fs_open()方法。然后,js的调用就立即返回了,也就是由js层面发起的异步调用的第一阶段就结束了,js线程就可以继续执行当前任务的后续操作了。当前的IO操作在线程池中等待执行,不管它是否阻塞IO,都不会影响js线程的后续执行,如此,就达到了异步的目的。
执行回调
请求对象发生在js层面调用异步IO的第一阶段,那么组装好请求对象、送入IO线程池等待执行就全部是在第一阶段完成的,完成这一步,js就会继续执行后续的代码,其他的工作都由内核负责,这后续的步骤被称为回调通知。
线程池中的IO操作调用完毕之后,会将获取的结果存储在req->result属性上,然后调用PostQueuedCompletionStatus()通知IOCP,告知当前对象操作已经完成:
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
PostQueuedCompletionStatus()的作用就是向IOCP提交执行状态,并将线程归还线程池,通过PostQueuedCompletionStatus()方法提交的状态,可以通过GetQueuedCompletionStatus()提取。这个提取动作是通过事件循环IO观察者,在每次执行Tick的过程中调用IOCP的GetQueuedCompletionStatus()方法检测线程池中是否有执行完的请求,如果存在,会将请求对象加入到IO观察者的队列中,然后将其当做事件处理。
这个IO观察者回调函数的行为就是取出请求对象的result属性作为参数,并取出oncomplete_sym属性作为方法,然后调用执行,以此达到调用js中传入的回调函数的目的。
至此,整个异步IO的流程就完全结束了。
小结
事件循环、观察者、请求对象、IO线程池这四部分共同构成了node异步IO模型的基本要素,通过node调用异步IO,然后node内核通过libuv判断平台,并调用不同的组件,这个组件前边介绍的是win下的IOCP,那么在linux下是epoll,在FreeBSD下kqueue,Solaris是Event ports,然后这个组件向系统内核发送IO调用,最后再从内核获取已经完成的IO操作,并配以事件循环,以此完成异步IO的全过程。
不同的是,线程池在win下用内核IOCP直接提供,*inx则由libuv自行实现。
非IO的异步API
在node中存在一些与IO无关的异步API,他们主要包括:设置超时定时器setTimeout()、设置间隔定时器setInterval()、设置马上执行间隔setImmediate()和process.nextTick()
定时器
setTimeout()和setInterval()与浏览器中的API是一致的,setTimeout()设置的是单次执行任务,setInterval()设置的是多次执行任务。这些方法的原理与异步IO类似,只不过不需要线程池的参与,调用setTimeout()和setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次执行Tick,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超出,就形成一个事件,他的回调函数将立即执行。我们来看一下setTimeout()的行为:
setTimeout()是一次的,setInterval()是多次的,会重复检测和执行setTimeout()的这些行为。
通过上边看出,定时器在于cpu时钟层面是不精确的,虽然人类感觉不到,但是,还是会有误差,这个误差就来源于每次的Tick循环,比如一个还有1毫秒就要到时的回调任务,刚好循环体执行过去了,那么再等待下一次循环体调用的时候,这个定时器其实已经超时许久了。因此,对于毫米级精确的任务,定时器并不好用。
process.nextTick()
我记得有一次面试中,面试官问过一个问题,为什么会存在如下程序:
setTimeout(function () {
// TODO
}, 0);
这段程序的目的是实现立即异步执行一个任务。但是,前边已经说了,由于事件循环自身的特点,以及定时器的精度不够,另外,调用定时器会动用红黑树,并通过红黑树创建定时器对象和迭代操作,因此,即便是setTimeout(fn, 0) 也很浪费性能,我们之前讲了从read到epoll的演变就是为了降低性能的浪费,但是,如果使用了这样的定时器,依然会存在性能浪费,非常得不偿失。因此,采用process.nextTick()的方法来操作就较为轻量了,经济划算,代码如下:
process.nextTick = function(callback) {
// on the way out, don't bother.
// it won't get fired anyway
if (process._exiting) return;
if (tickDepth >= process.maxTickDepth)
maxTickWarn();
var tock = { callback: callback };
if (process.domain) tock.domain = process.domain;
nextTickQueue.push(tock);
if (nextTickQueue.length) {
process._needTickCallback();
}
};
每次调用process.nextTick(),只会将回调函数放入队列中,在下一轮Tick时取出执行,定时器采用红黑树的操作时间复杂度是o(lg(n)),nextTick()的时间复杂度仅为o(1),效率的提高可想而知。
setImmediate()
setImmediate()与process.nextTick()方法十分类似,都是将回调函数延迟执行,在node v0.9.1之前,setImmediate()还没有实现,因此,我们可以比较一下两个功能的用法和效率
process.nextTick(function () {
console.log('延迟执行');
});
console.log('正常执行');
打印出的结果是
正常执行
延迟执行
setImmediate(function () {
console.log('延迟执行');
});
console.log('正常执行');
打印出的结果也是
正常执行
延迟执行
但是,两者存在细微差别,process.nextTick()的优先级会高一些,我们看一下示例代码:
process.nextTick(function () {
console.log('nextTick延迟执行');
});
setImmediate(function () {
console.log('setImmediate延迟执行');
});
console.log('正常执行');
输出结果如下
正常执行
nextTick延迟执行
setImmediate延迟执行
process.nextTick()的优先级高的原因在于事件循环对于观察者检查的先后顺序,process.nextTick()属于idle观察者,setImmediate()属于check观察者,在每一轮循检查中,idle观察者会优先于IO观察者,IO观察者又会优先于check观察者。
在具体实现上,process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则保存在链表中,在行为上,process.nextTick()在每轮循环中会将数组中的回调函数全部执行完,setImmediate()则在每轮循环中执行链表中的一个回调函数,我们来看一个例子加以佐证:
// 加入两个 nextTick()的回调函数
process.nextTick(function () {
console.log('nextTick延迟执行1');
});
process.nextTick(function () {
console.log('nextTick延迟执行2');
});
// 加入两个setImmediate()的回调函数
setImmediate(function () {
console.log('setImmediate延迟执行1');
// 进入下次循环
process.nextTick(function () {
console.log('插入一个nextTick证明前边的推论');
});
});
setImmediate(function () {
console.log('setImmediate延迟执行2');
});
console.log('正常执行');
执行结果如下:
正常执行
nextTick延迟执行1
nextTick延迟执行2
setImmediate延迟执行1
插入一个nextTick证明前边的推论
setImmediate延迟执行2
从执行结果可以看出,这个优先级的设置了。之所以这样设计,是为了保证每轮循环能够较快的执行结束,防止cpu占用过多而阻塞后续IO调用。
事件驱动与高性能服务器
通过前边的介绍,我们了解了异步实现的原理,同时也了解了事件驱动的实质,这就是通过主循环加事件触发的方式来运行程序。
异步IO可以用在方方面面,因为,计算机操作系统将设备都抽象为了文件,因此,异步IO可以操作基础文件、标准文件、网络套接字等等,只不过使用的监听不一样,例如网络套接字,会有网络套接字的监听,然后将监听到的请求事件交给IO观察者。利用node构建web服务器,正是在这样一个基础上实现的,我们看一下这个流程:
经典web模型对比
模型 | 说明 |
---|---|
同步式 | 一次处理一个请求,其他请求处于等待状态 |
每进程/每请求 | 通过为每个请求开启一个进程的方式处理多个请求,但是不具备扩展性,因为系统资源只有那么多。 |
每线程/每请求 | 通过为每个请求开启一个线程的方式处理多个请求,线程会占有更多的内存,当大并发的请求到来时,会导致服务器缓慢 |
每线程/每请求的方式目前还被Apache所采用,node通过事件驱动的方式处理请求,无须为每个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务是因为线程较少,上下文切换的代价很低,这使得服务器可以有条不紊的处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,这个就是node高性能的一个原因。
事件驱动带来的高效已经渐渐开始为业界所重视,Nginx也摒弃了多线程的方式,采用于node相同的事件驱动,如今,nginx大有取代apache之势,nginx用于反向代理和负载均衡,将nginx与node结合,必然会写出高性能高并发的好程序的。
其实,在node之前,ruby的event machine、perl的anyevent、python的twisted都采用了事件驱动的方式进行异步IO,但是由于这些语言都是以同步阻塞IO的形式制定的,因此,没有获得成功。另外,由于node的成功,Lua也受到了启发,做了一个新项目叫作luavit。
总结
这一章主要讲解了各种异步IO的原理和node异步IO的实现,并且还介绍了4中非IO的异步API。可以看出,事件循环是异步实现的核心,它与浏览器中的执行模型基本保持了一致,使得node在构建高性能服务器方面取得了长足发展。