nodejs深入学(4)异步IO

前言

前端工程师因为需要操纵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类型及其对应开销

因为,后端的响应速度提升了,因此,前端的用户体验也会更好。

资源分配

计算机组件分为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与阻塞IO的区别在于阻塞IO完成整个获取数据的过程,而非阻塞IO则不带数据直接返回,需要获取数据,还需要通过文件描述符再次读取。但是,由于不知道任务何时真正完成,因此,需要轮询访问任务,查看是否已经完成,这无形当中增加了系统资源的占用。

操作系统的轮询种类

read

通过read进行轮询

read通过了一种反复查看IO状态的形式来完成轮询,并读取数据。cpu一直耗用在无谓的轮询上,资源浪费明显。

select

通过select进行轮询

通过select对文件描述符上的事件状态进行判断,一旦读取完成,则再次调用read完成真正的读取。这个方式也有资源浪费。并且,select采用了1024长度的数组来存储状态,也就是说,select最多只能同时检查1024个文件描述符。

poll

通过poll进行轮询

poll是采用了链表的select,避免了数组的长度限制。但是,还是要不断的检查状态,还是存在cpu资源的浪费。并且,书中说,当文件描述符较多时,它的性能比较低下。

epoll

通过epoll进行轮询

epoll在进入轮询后,如果没有检查到IO事件,将会进行休眠,直到事件将其唤醒,这个方案利用了事件通知、执行回调的方式,避免了无谓的遍历查询,减少了cpu资源的浪费。另外,还有FreeBSD下的kqueue,这个跟epoll类似,书中没有详细介绍。

轮询小结

从本质上来讲,轮询技术还是一种同步执行的程序,要么不断的遍历,要么进行休眠。这使得程序依旧需要花费时间进行等待。

理想的非阻塞异步IO

我们期待的理想非阻塞异步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得到的数据进行传递,这就实现了异步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目录下的源文件到目标程序:

基于libuv的架构示意图

注意:我们时常提到的node是单线程的,这里的单线程仅仅只是js执行在单线程中,在node里,无论哪个平台,内部完成IO任务的都另有线程池。

node的异步IO

完成node整个异步IO环节的有事件循环、观察者、请求对象

事件循环

事件循环是node的自身执行模型,正是事件循环使得回调函数得以在node中大量的使用。在进程启动时node会创建一个while(true)循环,这个和Netty也是一样的,每次执行循环体,都会完成一次Tick。每个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的流程

小结

事件循环、观察者、请求对象、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()的行为

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服务器,正是在这样一个基础上实现的,我们看一下这个流程:

利用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在构建高性能服务器方面取得了长足发展。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,565评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,021评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,003评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,015评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,020评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,856评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,178评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,824评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,264评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,788评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,913评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,535评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,130评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,102评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,334评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,298评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,622评论 2 343

推荐阅读更多精彩内容