深入浅出Node.js(五):初探Node.js的异步I/O实现

心不动则不痛 提交于 2019-11-29 07:53:12

异步I/O

在操作系统中,程序运行的空间分为内核空间和用户空间。我们常常提起的异步I/O,其实质是用户空间中的程序不用依赖内核空间中的I/O操作实际完成,即可进行后续任务。以下伪代码模仿了一个从磁盘上获取文件和一个从网络中获取文件的操作。异步I/O的效果就是getFileFromNet的调用不依赖于getFile调用的结束。

getFile("file_path");
getFileFromNet("url");

如果以上两个任务的时间分别为m和n。采用同步方式的程序要完成这两个任务的时间总花销会是m + n。但是如果是采用异步方式的程序,在两种I/O可以并行的状况下(比如网络I/O与文件I/O),时间开销将会减小为max(m, n)。

异步I/O的必要性

有的语言为了设计得使应用程序调用方便,将程序设计为同步I/O的模型。这意味着程序中的后续任务都需要等待I/O的完成。在等待I/O完成的过程中,程序无法充分利用CPU。为了充分利用CPU,和使I/O可以并行,目前有两种方式可以达到目的:

  • 多线程单进程
    多线程的设计之处就是为了在共享的程序空间中,实现并行处理任务,从而达到充分利用CPU的效果。多线程的缺点在于执行时上下文交换的开销较大,和状态同步(锁)的问题。同样它也使得程序的编写和调用复杂化。
  • 单线程多进程
    为了避免多线程造成的使用不便问题,有的语言选择了单线程保持调用简单化,采用启动多进程的方式来达到充分利用CPU和提升总体的并行处理能力。 它的缺点在于业务逻辑复杂时(涉及多个I/O调用),因为业务逻辑不能分布到多个进程之间,事务处理时长要远远大于多线程模式。

前者在性能优化上还有回旋的余地,后者的做法纯粹是一种加三倍服务器的行为。 
而且现在的大型Web应用中,单机的情形是十分稀少的,一个事务往往需要跨越网络几次才能完成最终处理。如果网络速度不够理想,m和n值都将会变大,这时同步I/O的语言模型将会露出其最脆弱的状态。 
这种场景下的异步I/O将会体现其优势,max(m, n)的时间开销可以有效地缓解m和n值增长带来的性能问题。而当并行任务更多的时候,m + n + …与max(m, n, …)之间的孰优孰劣更是一目了然。从这个公式中,可以了解到异步I/O在分布式环境中是多么重要,而Node.js天然地支持这种异步I/O,这是众多云计算厂商对其青睐的根本原因。

操作系统对异步I/O的支持

我们听到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调用时,要读到完整的数据,应用程序需要进行多次轮询,才能确保读取数据完成,以进行下一步的操作。
轮询技术的缺点在于应用程序要主动调用,会造成占用较多CPU时间片,性能较为低下。现存的轮询技术有以下这些:

  • read
  • select
  • poll
  • epoll
  • pselect
  • kqueue

read是性能最低的一种,它通过重复调用来检查I/O的状态来完成完整数据读取。select是一种改进方案,通过对文件描述符上的事件状态来进行判断。操作系统还提供了poll、epoll等多路复用技术来提高性能。
轮询技术满足了异步I/O确保获取完整数据的保证。但是对于应用程序而言,它仍然只能算时一种同步,因为应用程序仍然需要主动去判断I/O的状态,依旧花费了很多CPU时间来等待。

上一种方法重复调用read进行轮询直到最终成功,用户程序会占用较多CPU,性能较为低下。而实际上操作系统提供了select方法来代替这种重复read轮询进行状态判断。select内部通过检查文件描述符上的事件状态来进行判断数据是否完全读取。但是对于应用程序而言它仍然只能算是一种同步,因为应用程序仍然需要主动去判断I/O的状态,依旧花费了很多CPU时间等待,select也是一种轮询。

理想的异步I/O模型

理想的异步I/O应该是应用程序发起异步调用,而不需要进行轮询,进而处理下一个任务,只需在I/O完成后通过信号或是回调将数据传递给应用程序即可。

幸运的是,在Linux下存在一种这种方式,它原生提供了一种异步非阻塞I/O方式(AIO)即是通过信号或回调来传递数据的。
不幸的是,只有Linux下有这么一种支持,而且还有缺陷(AIO仅支持内核I/O中的O_DIRECT方式读取,导致无法利用系统缓存。参见:http://forum.nginx.org/read.php?2,113524,113587#msg-113587
以上都是基于非阻塞I/O进行的设定。另一种理想的异步I/O是采用阻塞I/O,但加入多线程,将I/O操作分到多个线程上,利用线程之间的通信来模拟异步。Glibc的AIO便是这样的典型http://www.ibm.com/developerworks/linux/library/l-async/。然而遗憾在于,它存在一些难以忍受的缺陷和bug。可以简单的概述为: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选择的异步I/O方案。由于Windows平台和*nix平台的差异,Node.js提供了libuv来作为抽象封装层,使得所有平台兼容性的判断都由这一层次来完成,保证上层的Node.js与下层的libeio/libev及IOCP之间各自独立。Node.js在编译期间会判断平台条件,选择性编译unix目录或是win目录下的源文件到目标程序中。

下文我们将通过解释Windows下Node.js异步I/O(IOCP)的简单例子来探寻一下从JavaScript代码到系统内核之间都发生了什么。

Node.js的异步I/O模型

很多同学在遇见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提供。

参考:

关于作者

田永强,新浪微博@朴灵,前端工程师,曾就职于SAP,现就职于淘宝,花名朴灵,致力于NodeJS和Mobile Web App方面的研发工作。双修前后端JavaScript,寄望将NodeJS引荐给更多的工程师。兴趣:读万卷书,行万里路。个人Github地址:http://github.com/JacksonTian

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!