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

阅读数:32828 2012 年 3 月 22 日

专栏的第五篇文章《Node.js 的异步实现》。之前介绍了 Node.js 的事件机制,也许读者对此尚会觉得意犹未尽,因为仅仅只是简单的事件机制,并不能道尽 Node.js 的神奇。如果 Node.js 是一盘别开生面的磁带,那么事件与异步分别是其 A 面和 B 面,它们共同组成了 Node.js 的别样之处。本文将翻转 Node.js 到 B 面,与你共同聆听。

异步 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


感谢崔康对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。