Node.js 使用了一种称为异步I/O(Asynchronous I/O)或者事件循环的模型,这是它能够高效处理大量并发连接的关键。在 Node.js 中,当你执行 I/O 操作(如文件读写、网络请求等)时,不会阻塞事件循环,而是将 I/O 操作排队,并继续处理其他事件。一旦I/O操作完成,就会通知事件循环,然后执行相应的回调函数。
在听到 nodejs 相关的特性时,经常会对 异步I/O、非阻塞I/O有所耳闻,听起来好像是差不多的意思,但其实是两码事,下面我们就以原理的角度来剖析一下对 nodejs 来说,这两种技术底层是如何实现的?
什么是I/O
首先,我想有必要把 I/O 的概念解释一下。I/O 即Input/Output, 输入和输出的意思。在浏览器端,只有一种 I/O,那就是利用 Ajax 发送网络请求,然后读取返回的内容,这属于网络I/O。回到 nodejs 中,其实这种的 I/O 的场景就更加广泛了,主要分为两种:
- 文件 I/O。比如用 fs 模块对文件进行读写操作。
- 网络 I/O。比如 http 模块发起网络请求。
异步IO
在操作系统中,程序运行的空间分为内核空间和用户空间。我们常常提起的异步I/O,其实质是用户空间中的程序不用依赖内核空间中的I/O操作实际完成,即可进行后续任务。
同步IO的并行模式
- 多线程单进程
多线程的设计之处就是为了在共享的程序空间中,实现并行处理任务,从而达到充分利用CPU的效果。多线程的缺点在于执行时上下文交换的开销较大,和状态同步(锁)的问题。同样它也使得程序的编写和调用复杂化。
- 单线程多进程
为了避免多线程造成的使用不便问题,有的语言选择了单线程保持调用简单化,采用启动多进程的方式来达到充分利用CPU和提升总体的并行处理能力。它的缺点在于业务逻辑复杂时(涉及多个I/O调用),因为业务逻辑不能分布到多个进程之间,事务处理时长要远远大于多线程模式。
异步IO的必要性
采用同步方式的程序要完成这两个任务的时间总花销会是m + n。但是如果是采用异步方式的程序,在两种I/O可以并行的状况下(比如网络I/O与文件I/O),时间开销将会减小为max(m, n)。而当并行任务更多的时候,m + n + …与max(m, n, …)之间的孰优孰劣更是一目了然。Node.js天然地支持这种异步I/O,这是众多云计算厂商对其青睐的根本原因。
操作系统对异步I/O的支持
异步与非阻塞听起来似乎是同一回事。从实际效果的角度说,异步和非阻塞都达到了我们并行I/O的目的。但是从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上时两回事。
- I/O的阻塞与非阻塞
阻塞模式的I/O会造成应用程序等待,直到I/O完成。同时操作系统也支持将I/O操作设置为非阻塞模式,这时应用程序的调用将可能在没有拿到真正数据时就立即返回了,为此应用程序需要多次调用才能确认I/O操作完全完成。
- I/O的同步与异步
I/O的同步与异步出现在应用程序中。如果做阻塞I/O调用,应用程序等待调用的完成的过程就是一种同步状况。相反,I/O为非阻塞模式时,应用程序则是异步的。
总结:只有同步才有阻塞和非阻塞之分,异步必定是非阻塞的。
异步I/O与轮询技术
当进行非阻塞I/O调用时,要读到完整的数据,应用程序需要进行多次轮询,才能确保读取数据完成,以进行下一步的操作。
轮询技术的缺点在于应用程序要主动调用,会造成占用较多CPU时间片,性能较为低下。现存的轮询技术有以下这些:
- read:通过重复调用来检查I/O的状态来完成完整数据读取,性能也是最低的一种。
- select:通过对文件描述符上的事件状态来进行判断。
- poll
- epoll
- pselect
- kqueue
轮询技术满足了异步I/O确保获取完整数据的保证。但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要主动去判断I/O的状态,依旧花费了很多CPU时间来等待。
理想的异步I/O模型
理想的异步I/O应该是应用程序发起异步调用,而不需要进行轮询,进而处理下一个任务,只需在I/O完成后通过信号或是回调将数据传递给应用程序即可。
不同操作系统的异步IO方案
- Linux
在Linux下存在一种这种方式,它原生提供了一种异步非阻塞I/O方式(AIO)即是通过信号或回调来传递数据的。不幸的是,只有Linux下有这么一种支持,而且还有缺陷(AIO仅支持内核I/O中的O_DIRECT方式读取,导致无法利用系统缓存.
另一种理想的异步I/O是采用阻塞I/O,但加入多线程,将I/O操作分到多个线程上,利用线程之间的通信来模拟异步.
Linux平台下没有完美的异步I/O支持。所幸的是,libev的作者Marc Alexander Lehmann重新实现了一个异步I/O的库:libeio。libeio实质依然是采用线程池与阻塞I/O模拟出来的异步I/O。
- Windows
Windows有一种独有的内核异步IO方案:IOCP。IOCP的思路是真正的异步I/O方案,调用异步方法,然后等待I/O完成通知。IOCP内部依旧是通过线程实现,不同在于这些线程由系统内核接手管理。IOCP的异步模型与Node.js的异步调用模型已经十分近似。
Node.js的异步IO方案
由于Windows平台和*nix平台的差异,Node.js提供了libuv来作为抽象封装层,使得所有平台兼容性的判断都由这一层次来完成,保证上层的Node.js与下层的libeio/libev及IOCP之间各自独立。Node.js在编译期间会判断平台条件,选择性编译unix目录或是win目录下的源文件到目标程序中。
Node.js的异步IO模型
Node.js的回调函数究竟是如何被调用的?在文件I/O这一块与普通的业务逻辑的回调函数不同在于它不是由我们自己的代码所触发,而是系统调用结束后,由系统触发的。
下面我们以最简单的fs.open方法来作为例子,探索Node.js与底层之间是如何执行异步I/O调用和回调函数究竟是如何被调用执行的。
fs.open = function(path, flags, mode, callback) {
callback = arguments[arguments.length - 1];
if (typeof(callback) !== 'function') {
callback = noop;
}
mode = modeNum(mode, 438 /*=0666*/);
binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);
};
fs.open的作用是根据指定路径和参数,去打开一个文件,从而得到一个文件描述符,是后续所有I/O操作的初始操作。
在JavaScript层面上调用的fs.open方法最终都透过node_file.cc调用到了libuv中的uv_fs_open方法,这里libuv作为封装层,分别写了两个平台下的代码实现,编译之后,只会存在一种实现被调用。
- 请求对象
在uv_fs_open的调用过程中,Node.js创建了一个FSReqWrap请求对象。从JavaScript传入的参数和当前方法都被封装在这个请求对象中,其中回调函数则被设置在这个对象的oncomplete_sym属性上。
req_wrap->object_->Set(oncomplete_sym, callback);
对象包装完毕后,调用QueueUserWorkItem方法将这个FSReqWrap对象推入线程池中等待执行。
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTELONGFUNCTION)
QueueUserWorkItem接受三个参数,第一个是要执行的方法,第二个是方法的上下文,第三个是执行的标志。当线程池中有可用线程的时候调用uv_fs_thread_proc方法执行。该方法会根据传入的类型调用相应的底层函数,以uv_fs_open 为例,实际会调用到fs__open方法。调用完毕之后,会将获取的结果设置在req->result上。然后调用PostQueuedCompletionStatus通知我们的IOCP对象操作已经完成。
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
PostQueuedCompletionStatus方法的作用是向创建的IOCP上相关的线程通信,线程根据执行状况和传入的参数判定退出。至此,由JavaScript层面发起的异步调用第一阶段就此结束。
- 事件循环
在调用uv_fs_open方法的过程中实际上应用到了事件循环。以在Windows平台下的实现中,启动Node.js时,便创建了一个基于IOCP的事件循环loop,并一直处于执行状态。
uv_run(uv_default_loop());
每次循环中,它会调用IOCP相关的GetQueuedCompletionStatus方法检查是否线程池中有执行完的请求,如果存在,poll操作会将请求对象加入到loop的pending_reqs_tail属性上。另一边这个循环也会不断检查loop对象上的pending_reqs_tail引用,如果有可用的请求对象,就取出请求对象的result属性作为结果传递给oncomplete_sym执行,以此达到调用JavaScript中传入的回调函数的目的。至此,整个异步I/O的流程完成结束。
其流程如下:
事件循环和请求对象构成了Node.js的异步I/O模型的两个基本元素,这也是典型的消费者生产者场景。在Windows下通过IOCP的GetQueuedCompletionStatus、PostQueuedCompletionStatus、QueueUserWorkItem方法与事件循环实。对于*nix平台下,这个流程的不同之处在于实现这些功能的方法是由libeio和libev提供。
阻塞和非阻塞I/O
阻塞和非阻塞 I/O 其实是针对操作系统内核而言的,而不是 nodejs 本身。阻塞 I/O 的特点就是一定要等到操作系统完成所有操作后才表示调用结束,而非阻塞 I/O 是调用后立马返回,不用等操作系统内核完成操作。
对前者而言,在操作系统进行 I/O 的操作的过程中,我们的应用程序其实是一直处于等待状态的,什么都做不了。那如果换成非阻塞I/O,调用返回后我们的 nodejs 应用程序可以完成其他的事情,而操作系统同时也在进行 I/O。这样就把等待的时间充分利用了起来,提高了执行效率,但是同时又会产生一个问题,nodejs 应用程序怎么知道操作系统已经完成了 I/O 操作呢?
为了让 nodejs 知道操作系统已经做完 I/O 操作,需要重复地去操作系统那里判断一下是否完成,这种重复判断的方式就是轮询。对于轮询而言,有以下这么几种方案:
- 一直轮询检查I/O状态,直到 I/O 完成。这是最原始的方式,也是性能最低的,会让 CPU 一直耗用在等待上面。其实跟阻塞 I/O 的效果是一样的。
- 遍历文件描述符(即 文件I/O 时操作系统和 nodejs 之间的文件凭证)的方式来确定 I/O 是否完成,I/O完成则文件描述符的状态改变。但 CPU 轮询消耗还是很大。
- epoll模式。即在进入轮询的时候如果I/O未完成CPU就休眠,完成之后唤醒CPU。
总之,CPU要么重复检查I/O,要么重复检查文件描述符,要么休眠,都得不到很好的利用,我们希望的是:
nodejs 应用程序发起 I/O 调用后可以直接去执行别的逻辑,操作系统默默地做完 I/O 之后给 nodejs 发一个完成信号,nodejs 执行回调操作。
这是理想的情况,也是异步 I/O 的效果,那如何实现这样的效果呢?
异步 I/O 的本质
Linux 原生存在这样的一种方式,即(AIO), 但两个致命的缺陷:
- 只有 Linux 下存在,在其他系统中没有异步 I/O 支持。
- 无法利用系统缓存。
nodejs中的异步 I/O 方案
是不是没有办法了呢?在单线程的情况下确实是这样,但是如果把思路放开一点,利用多线程来考虑这个问题,就变得轻松多了。我们可以让一个进程进行计算操作,另外一些进行 I/O 调用,I/O 完成后把信号传给计算的线程,进而执行回调,这不就好了吗?没错,异步 I/O 就是使用这样的线程池来实现的。
只不过在不同的系统下面表现会有所差异,在 Linux 下可以直接使用线程池来完成,在Window系统下则采用 IOCP 这个系统API(其内部还是用线程池完成的)。
有了操作系统的支持,那 nodejs 如何来对接这些操作系统从而实现异步 I/O 呢?
以文件为 I/O 我们以一段代码为例:
let fs = require('fs');
fs.readFile('/test.txt', function (err, data) {
console.log(data);
});
执行流程
执行代码的过程中大概发生了这些事情:
- 首先,fs.readFile调用Node的核心模块fs.js ;
- 接下来,Node的核心模块调用内建模块node_file.cc,创建对应的文件I/O观察者对象(这个对象后面有大用!) ;
- 最后,根据不同平台(Linux或者window),内建模块通过libuv中间层进行系统调用
libuv调用过程拆解
重点来了!libuv 中是如何来进行进行系统调用的呢?也就是 uv_fs_open() 中做了些什么?
1. 创建请求对象
以Windows系统为例来说,在这个函数的调用过程中,我们创建了一个文件I/O的请求对象,并往里面注入了回调函数。
req_wrap->object_->Set(oncomplete_sym, callback);
req_wrap 便是这个请求对象,req_wrap 中 object_ 的 oncomplete_sym 属性对应的值便是我们 nodejs 应用程序代码中传入的回调函数。
2. 推入线程池,调用返回
在这个对象包装完成后,QueueUserWorkItem() 方法将这个对象推进线程池中等待执行。
好,至此现在js的调用就直接返回了,我们的 js 应用程序代码可以继续往下执行,当然,当前的 I/O 操作同时也在线程池中将被执行,这不就完成了异步么:)
等等,别高兴太早,回调都还没执行呢!接下来便是执行回调通知的环节。
3. 回调通知
事实上现在线程池中的 I/O 无论是阻塞还是非阻塞都已经无所谓了,因为异步的目的已经达成。重要的是 I/O 完成后会发生什么。
在介绍后续的故事之前,给大家介绍两个重要的方法: GetQueuedCompletionStatus 和 PostQueuedCompletionStatus。
- 还记得之前讲过的
eventLoop吗?在每一个Tick当中会调用GetQueuedCompletionStatus检查线程池中是否有执行完的请求,如果有则表示时机已经成熟,可以执行回调了。 PostQueuedCompletionStatus方法则是向IOCP提交状态,告诉它当前I/O完成了。
名字比较长,先介绍是为了让大家混个脸熟,至少后面出来不会感到太突兀:)
我们言归正传,把后面的过程串联起来。
当对应线程中的 I/O 完成后,会将获得的结果存储起来,保存到相应的请求对象中,然后调用PostQueuedCompletionStatus()向 IOCP 提交执行完成的状态,并且将线程还给操作系统。一旦 EventLoop 的轮询操作中,调用GetQueuedCompletionStatus检测到了完成的状态,就会把请求对象塞给I/O观察者(之前埋下伏笔,如今终于闪亮登场)。
I/O 观察者现在的行为就是取出请求对象的存储结果,同时也取出它的oncomplete_sym属性,即回调函数(不懂这个属性的回看第1步的操作)。将前者作为函数参数传入后者,并执行后者。 这里,回调函数就成功执行啦!
总结 :
- 阻塞和非阻塞 I/O 其实是针对操作系统内核而言的。阻塞 I/O 的特点就是一定要等到操作系统完成所有操作后才表示调用结束,而非阻塞 I/O 是调用后立马返回,不用等操作系统内核完成操作。
- nodejs中的
异步 I/O采用多线程的方式,由EventLoop、I/O 观察者,请求对象、线程池四大要素相互配合,共同实现。