当前位置: 首页 > 文档资料 > 深入浅出 Node.js >

第三章 异步 I/O

优质
小牛编辑
127浏览
2023-12-01

“异步”这个名词其实很早就诞生了,但它的大规模流行确实在 Web 2.0 浪潮中,它伴随着 Ajax 的第一个 A(Asnchronous)席卷了 Web。Node 在出现之前,最习惯异步编程的程序员莫过于前端工程师了。前端编程算 GUI 编程的一种,其中充斥着各种 Ajax 和事件,这些都是典型的异步应用场景。

但事实上,异步早就存在于操作系统的底层。在底层系统中,异步通信信号量、消息等方式有了广泛的应用。意外的是,在绝大多数高级编程语言中,异步并不多见,疑似呗屏蔽了一般。造成这个现象的主要原因也许令人经验:程序员不太适合通过异步来进行程序设计。

PHP 这门语言的设计最能体现这个观点。它对调用层不仅屏蔽了异步,甚至连多线程都不提供。PHP 语言从头到脚都是以同步阻塞的方式来执行的。它的优点十分明显,利于程序员顺序编写业务逻辑;它的缺点在小规模站点中基本不存在,但是在复杂的网络应用中,阻塞导致它无法更好地开发。

而在其它语言中,尽管可能存在异步的 API,它们构成 Node 的基调,Ryan Dahl 正是基于这几个因素设计了 Node。Ryan Dahl 最初期望设计出一个高性能的 Web 服务器,后来则演变为一个可以基于它构建各种告诉、可伸缩网络应用的平台,因为一个 Web 服务器已经无法完全涵盖和代表它的能力。尽管它不再是一个服务器,但是可以基于它搭建更多更丰富、更强大的网络应用。

与 Node 的事件驱动、异步 I/O 设计理念比较相近的一个知名产品为 Nginx。Nginx 采用纯 C 编写,性能表现非常优异。它们的区别在于,Nginx 具备面向客户端管理连接的强大能力,但是它背后依旧受限于各种同步方式的编程语言。但 Node 确实全方位的,既可以作为服务器端去处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。

Web 的含义是网,Node 的表现就如同它的名字一样,在网络中灵活的一个节点。

3.1 为什么要异步 I/O

关于异步 I/O 为何在 Node 里如此重要,这与 Node 面向网络而设计不无关系。Web 应用已经不单是单台服务器能胜任的时代了,在跨网络的结构下,并发已经是现代编程中的标准配置了。具体到实处,则可以从用户体验和资源分配这两个方面说起。

3.1.1 用户体验

异步的概念之所以首先在 Web 2.0 中火起来,是因为在浏览器中 JavaScript 在单线程上执行,而且它还与 UI 渲染共用一个线程。这意味着 JavaScript 在执行的时候 UI 渲染和响应式处于停滞状态的。《高性能 JavaScript》一书中曾经总结过,如果脚本的执行时间超过 100ms,用户就会感到页面卡顿,以为网页停止响应。在 B/S 模型中,网络速度的限制给网页的实时体验造成很大的麻烦。如果网页临时需要获取一个网络资源,通过同步的方式获取,那么 JavaScript 则需要等待资源完全从服务器端获取后才能继续执行,这期间 UI 将停顿,不响应用户的交互行为。可以想象,这样的用户体验会有多差。而采用异步请求,在下载资源期间,JavaScript 和 UI 的执行都不会处于等待状态,可以继续响应用户的交互行为,给用户一个鲜活的页面。

同理,前端通过异步可以消除 UI 阻塞的现象,但是前端获取资源的速度也取决于后端的响应速度。假如一个资源来自于两个不同位置的数据返回,那么第一个资源需要 Mms 的耗时,第二个资源需要 Nms 的耗时,采用同步的方式,代码大致如下:

// 消费时间M
getData('from_db');
// 消费时间N
getData('from_remote_api');

但是如果采用异步方式,第一个资源的获取并不会阻塞第二个资源,也即第二个资源的请求并不依赖第一个资源的结束。因此,我们可以享受并发的优势,相关代码如下:

getData('from_db', function (result) {
  // 消耗时间M
});

getData('from_remote_api', function (result) {
  // 消耗时间为N
});

对比两者的时间总消耗,前者为 M+N,后者为 max(M,N)。

随着应用复杂性的增加,情景将会编程 M+N+....和 max(M,N,...),同步与异步的优势将会凸显出来。另一方面,随着网站或应用不断膨胀,数据将会分不到多台服务器上,分布式将会式常态。分布也意味着 M 与 N 的值会线性增长,这也会放大异步和同步在性能方面的差异。为了让读者感知 M 和 N 具体有多昂贵,下表列出了从 CPU 以及缓存到网络的数据访问所需要的开销。

I/O 类型花费的 CPU 时钟周期
CPU 一级缓存3
CPU 二级缓存14
内存250
硬盘41000000
网络240000000

这就是异步 I/O 在 Node 中如此盛行,甚至将其作为主要理念进行设计的原因。I/O 是昂贵的,分布式 I/O 是更加昂贵的。

只有后端能够快速响应资源,才能让前端体验变好。

3.1.2 资源分配

排除用户体验的因素,我们从资源分配的层面来分析一下异步 I/O 的必要性。我们知道计算机在发展过程中将组件进行了抽象,分为 I/O 设备和计算设备。

假设业务场景中有一组步步相关的任务需要完成,现在主流方法有以下两种:

  • 单线程串行依次执行。
  • 多线程并行完成。

如果创建多线程的开销小于并行执行,那么多线程的方式是首选。多线程的代价在于创建线程和执行期线程上下文切换的开销较大。另外,在复杂的业务中,多线程编程经常面临锁、状态同步的问题,这是多线程被诟病的主要原因。但是多线程在多核 CPU 上能够有效提升 CPU 的利用率,这个优势是毋庸置疑的。

单线程顺序执行任务的方式比较符合编程人员按照顺序思考的思维方式。它依然是最主流的编程方式,因为异步表达。但是串行执行的缺点在于性能,任意一个略慢的任务都会导致后续任务执行代码被阻塞。在计算机资源中,通常 I/O 与 CPU 计算之间是可以并行运行的。但是同步的编程模型导致的问题是,I/O 的进行会让后续任务等待,这造成资源不能被更好地利用。

操作系统将会 CPU 地时间片分配给其余进程,以公平而有效地利用资源,基于这一点,有的服务器提升响应能力,会通过启动多个工作进程来高效利用资源,结束所有任务所需要地时间将会较长。这种模式类似于加三倍服务器,达到占用更多资源来提升服务速度,它并没有真正改善问题。

添加硬件资源是一种提升服务质量地方式,但它不是唯一地方式。

单线程同步编程模型会因阻塞 I/O 导致硬件资源得不到更优的利用。多线程编程模型也因为编程中的死锁、状态同步等问题让开发人员头疼。

Node 在两者之间给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好地利用 CPU。

异步 I/O 可以算作 Node 的特色,因为它是首个大规模将异步 I/O 应用在应用层的平台,它力求在单线程上将资源分配得更高效。

为了弥补单线程无法利用多核 CPU 得缺点,Node 提供了类似前端 Web Workers 得子进程,该子进程可以通过工作进程高效地利用 CPU 和 I/O。

异步 I/O 地提出是期望 I/O 的调用时不再阻塞后续运算,将原有等待 I/O 完成的这段时间分配给其余需要的业务去执行。

3.2 异步 I/O 实现现状

异步 I/O 在 Node 中应用最为广泛,但是它并非 Node 的原创。

如果 Brendan Eich 援引 18 世纪文学家约翰逊所说,“它的优秀之处并非原创,它的原创之处并不优秀”,以之评价他自己创造的 JavaScript 一样,Node 的优秀之处也并非原创。下面我们看看操作系统对异步 I/O 实现的支持状况。

3.2.1 异步 I/O 与非阻塞 I/O

在听到 Node 的介绍时,我们时常会听到异步、非阻塞、回调、事件这些词语混合在一起推介出来,其中异步与非阻塞听起来似乎时同一回事。从实际效果而言,异步和非阻塞都达到了我们并行 I/O 的目的。但是从计算机内核 I/O 而言,异步/同步和阻塞/非阻塞实际上时两回事。

操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞。在调用阻塞 I/O 时,应用程序需要等待 I/O 完成才返回结果。

阻塞 I/O 的一个特点是,调用之后一定要等系统内核层面完成所有操作后,才调用结束。以读取磁盘上的一段文件为例,系统内核在完成磁盘寻道、读取数据、复制数据到内存中之后,这个调用才结束。

阻塞 I/O 造成 CPU 等待 I/O,浪费等待时间,CPU 的处理能力不能得到充分利用。为了提高性能,内核提供了非阻塞 I/O。非阻塞 I/O 跟阻塞 I/O 的差别为调用之后会立即返回。

操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件。内核在进行文件 I/O 操作时,通过文件描述符进行管理,而文件描述符类似于应用程序与系统内核之间的凭证。应用程序如果需要进行 I/O 调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。此处非阻塞 I/O 与阻塞 I/O 的区别在于阻塞 I/O 完成整个获取数据的过程,而非阻塞 I/O 则不带数据直接返回,要获取数据,还需要通过文件描述符再次读取。

非阻塞 I/O 返回之后,CPU 的时间片可以用来处理其它事务,此时的性能提升时明显的。

但非阻塞 I/O 也存在一些问题。由于完整的 I/O 并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用 I/O 操作来确认是否完成。这种重复调用判断操作是否完成的技术叫轮询,下面我们就简要介绍这种技术。

任何技术都并非完美的。阻塞 I/O 造成 CPU 等待浪费,非阻塞带来的麻烦确实需要轮询去确认是否完全完成数据获取,它会让 CPU 处理状态判断,是对 CPU 资源的浪费。这里我们且看轮询技术是如何演进的,以减少 I/O 状态判断的 CPU 损耗。

现存的轮询技术主要有以下几种:

  • read。它是最原始、性能最低的一种,通过重复调用来检查 I/O 的状态来完成完整数据的读取。再得到最终数据前,CPU 一直好用在等待上。

  • select。它是在 read 的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断。

    select 轮询具有较弱的限制,那就是由于它采用的是一个 1024 长度的数组来存储状态,所以它最多可以同时检查 1024 个文件描述符。

  • epoll。该方案是 Linux 下效率最高的 I/O 事件通知机制,在进入轮询的时候如果没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费 CPU,执行效率高。

  • kqueue。该方案的实现方式与 epoll 类似,不过它仅在 FreeBSD 系统下存在

轮询技术满足了非阻塞 I/O 确保获取完整数据的需要,但是对于应用程序而言,它仍然只是一种同步,因为引用程序仍然需要等待 I/O 完全返回,依旧花费了很多时间来等待。等待期间,CPU 要么用于遍历文件描述符状态,要么用于休眠等待事件发生。结论是,他们不够好。

3.2.2 理想的非阻塞异步 I/O

尽管 epoll 已经利用了事件来降低 CPU 的耗用,但是休眠期间 CPU 几乎是闲置的,对于当前线程而言利用率不够。那么是否有一种理想的异步 I/O 呢?

我们期望的完美异步 I/O 应该是应用程序发起非阻塞调用,无需通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在 I/O 完成后通过信号或回调将数据传递给应用程序即可。

幸运的是,在 Linux 下存在这样一种方式,它原生提供的一种异步 I/O 方式(AIO)就是通过信号或回调来传递数据的。

但是不幸的是,只有 Linux 下有,而且它还有缺陷——AIO 仅支持内核 I/O 中的 O_DIRECT 方式读取,导致无法利用系统缓存。

3.2.3 现实的异步 I/O

现实比理想更要骨感一些,但是要达成异步 I/O 的目标,并非难事。前面我们将场景限定在了单线程的状况下,多线程的方式回实另一番风景。通过让部分线程进行阻塞 I/O 或者非阻塞 I/O,加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将 I/O 得到的数据进行传递,这样就轻松实现了异步 I/O(尽管它是模拟的)。

glibc 的 AIO 便是典型的线程池模拟异步 I/O。然而遗憾的是,它存在一些难以忍受的缺陷和 bug,不推荐采用。libev 的作者 Marc Alexander Lehmann 重新实现了一个异步 I/O 库:libeio。libeio 实质上依然采用线程池与阻塞 I/O 模拟异步 I/O。最初,Node 在*nix 平台下采用了 libeio 配合 libev 实现 I/O 部分,实现了异步 I/O。在 Node v0.9.3 中,自行实现了线程池来完成异步 I/O。

另一种我迟迟没有透露的异步 I/O 方案则是 Windows 下的 IOCP,它在某种程度上提供了理想的异步 I/O:调用异步方法,等待 I/O 完成后的通知,执行回调,用户无需考虑轮询。但是它的内部其实仍然是线程池原理,不同之处在于这些线程池由系统内核接收管理。

IOCP 的异步 I/O 模型与 Node 的异步调用模型十分近似。在 Windows 平台采用了 IOCP 实现异步 I/O。

由于 Windows 平台和*nix 平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断由这一层来完成,并保证了上层的 Node 和下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间回判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中。

需要强调一点是,这里的 I/O 不仅仅只限于磁盘文件的读写。*nix 将计算机抽象了一番,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件,因此这里描述的阻塞和非阻塞的情况同样适合于套接字等。

另一个需要强调的地方在于我们时常提到 Node 是单线程的,这里的单线程仅仅只是 JavaScript 执行在单线程中罢了。在 Node 中,无论是*nix 还是 Windows 平台,内部完成 I/O 任务的另有线程池。

3.3 Node 的异步 I/O

介绍完系统对异步 I/O 的支持后,我们将继续介绍 Node 是如何实现异步 I/O 的。这里我们除了介绍异步 I/O 的实现外,还将讨论 Node 的执行模型。完成整个异步 I/O 环节的有事件循环、观察者和请求对象等。

3.3.1 事件循环

首先我们着重强调以下 Node 自身的执行模型——事件循环,正式它使得回调函数十分普遍。在进程启动时,Node 便会创造一个类似于 while(true)的循环,每执行依次循环体的过程我们称为 Tick。每个 Tick 的过程就是查看是否有事件等待处理,如果有,就去除事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下一个循环,如果不再有事件处理,则退出进程。

3.3.2 观察者

在每个 Tick 的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有需要处理的事件。

这个过程就如同饭馆的厨房,厨房一轮一轮地制作菜肴,但是要具体制作哪些菜肴取决于收银台收到的客人下单。厨房每做完一轮菜肴,就回去问收银台小妹,接下来有没有要做的才,如果没有的话,就下班打烊了。在这个过程中,收银台的小妹就是观察者,她收到的客人点单就是关联的回调函数。当然如果饭馆经营有方,它可能有多个收银员,就如同事件循环中有多个观察者一样。收到下单就是一个事件,一个观察者里可能有多个事件。

浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。观察者将事件进行了分类。

事件循环时一个典型的生产者/消费者模型。异步 I/O、网络请求等则是事件的生产者,源源不断为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者哪里取出事件并处理。

在 Windows 下,这个循环基于 IOCP 创建,而*nix 下则基于多线程创建。

3.3.3 请求对象

在这一节中,我们将通过解释 Windows 下异步 I/O(利用 IOCP 实现)的简单例子来探寻从 JavaScript 代码到系统内核之间都发生了什么。

对于一般的(非异步)回调函数,函数由我们自行调用:

var forEach = function () {
  for (var i = 0; i < list.length; i++) {
    callback(list[i], i, list);
  }
};

对于 Node 中的异步 I/O 调用而言,回调函数却不由开发者来调用。那么从我们发出调用后,到回调函数被执行,中间发生了什么呢?事实上,从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,它叫请求对象

下面我们以最简单的fs.open()方法来作为例子,探索 Node 与底层之间时如何执行异步 I/O 调用以及回调函数究竟时如何被调用执行的:

fs.open = function (path, flags, mode, callback) {
  // ...
  binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);
};

fs.open()的作用时根据指定路径和参数去打开一个文件,从而得到了一个文件描述符,这是后续所有 I/O 操作的初始操作。从前面的代码可以看出,JavaScript 层面的代码通过调用 C++核心模块进行下层的操作。

从 JavaScript 调用 Node 的核心模块,核心模块调用 C++内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里经典的调用方式。这里 libuv 作为封装层,有两个平台的实现,实质上时调用了uv_fs_open()方法。在uv_fs_open()的调用过程中,我们创建了一个FSReqWrap请求对象。从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中,其中我们最为关注的回调函数则被设置在这个对象的oncomplete_sym属性上:

req_wrap->object_->Set(oncomplete_sym, callback);

对象包装完毕后,在 Windows 下,则调用QueueUserWorkItem()方法将这个FSReqWrap对象推入线程池中等待执行,该方法的代码如下:

QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT);

QueueUserWorkItem()方法接收三个参数:第一个参数是将要执行的方法引用,这里引用的是uv_fs_thread_proc,第二个参数是uv_fs_thread_proc方法运行时所需要的参数;第三个参数是执行的标识。当线程池中有可用线程时,我们回调用uv_fs_thread_proc()方法。uv_fs_thread_proc()方法回根据传入参数的类型调用相应的底层函数。以uv_fs_open()为例,实际调用fs__open()方法。

至此,JavaScript 调用立即返回,有 JavaScript 层面发起的异步调用的第一阶段就此结束。JavaScript 线程可以继续执行当前任务的后续操作。当前的 I/O 操作在线程池中等待执行,不管是否阻塞 I/O,都不会影响到 JavaScript 线程的后续执行,如此就达到了异步的目的。

请求对象是异步 I/O 过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及 I/O 操作完毕后的回调处理。

3.3.4 执行回调

组装好请求对象、送入 I/O 县城支持等待执行,实际上完成了异步 I/O 的第一部分,回调通知是第二部分。

线程池中的 I/O 操作调用完毕后,会将获取的结果存储在req->result属性上,然后调用PostQueuedCompletionStatus()通知 IOCP,告知当前对象操作已经完成:

PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped));

PostQueuedCompletionStatus()方法的作用是向 IOCP 提交执行状态,并将线程归还线程池。通过PostQueuedCompletionStatus()方法提交的状态,可以通过GetQueuedCompletionStatus()提取。

在这个过程中,我们其实还动用了事件循环的 I/O 观察者。在每次 Tick 的执行中,它会调用 IOCP 相关的GetQueuedCompletionStatus()方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入 I/O 观察者队列中,然后将其当作事件处理。

I/O 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,取出oncomplete_sym属性作为方法,然后调用执行,依次达到调用 JavaScript 中传入的回调函数的目的。

事件循环、观察者、请求对象、I/O 线程池这四者共同构成了 Node 异步 I/O 模型的基本要素。

Windows 下主要通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,依次完成异步 I/O 的过程。在 Linux 下通过 epoll 实现这个过程,FreeBSD 下通过 kqueue 实现,Solaris 下通过 Event ports 实现。不同的是线程池在 Windows 下由内核(IOCP)直接提供,*nix 系列下由 libuv 自行实现。

3.3.5 小结

从前面实现异步 I/O 的过程描述中,我们可以提取出异步 I/O 的几个关键词:单线程、事件循环、观察者和 I/O 线程池。这里单线程与 I/O 线程池之间看起来有些悖论的样子。由于我们直到 JavaScript 是单线程的,所以按常识很容易理解为它不能充分利用多核 CPU。事实上,在 Node 中,除了 JavaScript 是单线程外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的 I/O(磁盘 I/O 和网络 I/O 等)则是可以并行企鹅来的。

3.4 非 I/O 的异步 API

尽管我们在介绍 Node 的时候,多数情况下会提到异步 I/O,但是 Node 中其实还存在一些与 I/O 无关的异步 API,这一部分也指的略微关注下,它们分别是setTimeout()setInterval()setImmediate()process.nextTick()

3.4.1 定时器

setTimeout()setInterval()与浏览器中的 API 是一致的,分别用于单词和多次定时执行任务。它们的实现原理与异步 I/O 比较类似,只是不需要 I/O 线程池的参与。调用setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次 Tick 执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时事件,如果超过,就形成一个事件,它的回调函数立即执行。

定时器的问题在于,它并非精确的(在容忍范围内)。尽管事件循环十分快,但是如果某一次循环占用的事件较多,那么下次循环实,它也许超时很久了。譬如通过setTimeout()设定一个任务在 10ms 后执行,但是 9ms 后,有一个任务占用了 5ms 的 CPU 时间片,再次轮到定时器执行时,事件就已经过期 4ms 了。

3.4.2 process.nextTick()

在未了解process.nextTick()之前,很多人也许为了立即异步执行一个任务,会这样调用setTimeout()来达到所需的效果:

setTimeout(function () {
  // TODO
}, 0);

由于事件循环自身的特点,定时器的精确度不够。而事实上,采用定时器需要动用红黑树,创建定时器对象和迭代等操作,而setTimeout(fn, 0)的方式较为浪费性能。实际上,process.nextTick()方法的操作相对较为清凉,具体代码:

process.nextTick = function (callback) {
  // on the way out, don't bother
  // it won't get fired anyway
  if (process._existing) 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 时取出执行。定时器中采用红黑树的操作事件复杂度未 0(lg(n)),nextTick()的时间复杂度为 0(1)。相比较之下,process.nextTick()更高效。

3.4.3 setImmediate()

setImmediate()方法与process.nextTick()方法十分类似,都是讲回调函数延迟执行。在 Node v0.9.1 之前,setImmediate()还没有实现,那时候实现类似功能主要通过process.nextTick()来完成,该方法代码如下:

process.nextTick(function () {
  console.log('延迟执行');
});
console.log('正常执行');

输出结果为:

正常执行 延迟执行

而用setImmediate()实现时,相关代码如下:

setImmediate(function () {
  console.log('延迟执行');
});
console.log('正常执行');

其执行结果完全一样。

但是两者之间其实是由细微差别的。将它们放在一起时,优惠是怎样的优先级呢:

process.nextTick(function () {
  console.log('nextTick延迟执行');
});
setImmediate(function () {
  console.log('setImmediate延迟执行');
});
console.log('正常执行');

其执行结果如下:

正常执行 nextTick 延迟执行 setImmediate 延迟执行

从结果来看,process.nextTick()中的回调函数执行的优先级要高于setImmediate()。这里的原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于 idle 观察者,setImmediate()属于 check 观察者。在每一个轮循环检查中,idle 观察者先于 I/O 观察者,I/O 观察者先于 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('强势插入');
  });
});
setImmediate(function () {
  console.log('setImmediate延迟执行2');
});
console.log('正常执行');

其执行结果如下:

正常执行 nextTick 延迟执行 1 nextTick 延迟执行 2 setImmediate 延迟执行 1 强势插入 setImmediate 延迟执行 2

从执行结果看出,当第一个setImmediate()的回调函数执行后,并没有立即执行第二个,而是进入了下一轮循环,再次按照process.nextTick()优先、setImmediate()次后的顺序执行。之所以这样设计,是为了保证每轮循环能够较快地执行结束,防止 CPU 占用过多而阻塞后续 I/O 调用情况。

3.5 事件驱动与高性能服务器

前面主要介绍了异步地实现原理,在这个过程中,我们也基本勾勒出了事件驱动的实质,即通过主循环加事件触发的方式来运行程序。

尽管本章只用了fs.open()方法作为例子来阐述 Node 如何实现异步 I/O。而实质上,异步 I/O 不仅仅应用在文件操作中。对于网络套接字的处理,Node 也应用到了异步 I/O,网络套接字上侦听道德请求都会形成事件交给 I/O 观察者。事件循环会不停地处理这些网络 I/O 事件。如果 JavaScript 有传入回调函数,这些事件将会最终传递到业务逻辑层进行处理。利用 Node 构建 Web 服务器,正式在这样一个基础上实现的。

下面为几种经典地服务器模式,这里对比下它们的优缺点。

  • 同步式。对于同步式的服务,依次只能处理一个请求,并且其余请求都处于等待状态。
  • 每进程/每请求。为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多。
  • 每线程/每请求。为每个请求启动一个线程来处理。尽管线程比进程要轻量,但是由于每个线程豆芽占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。

每线程/每请求的方式目前还被 Apache 所采用。Node 通过事件驱动的方式处理请求,无需为每个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,这是 Node 高性能的一个原因。

事件驱动带来的高效已经渐渐开始为业界所重视。知名服务器 Nginx,也摒弃了多线程的方式,采用了和 Node 相同的事件驱动。如今,Nginx 大有取代 Apache 之势。Node 具有与 Nginx 相同的特性,不同之处在于 Nginx 采用纯 C 编写,性能较高,但是它仅适合用于 Web 服务器,用于反向代理或负载均衡,在处理具体业务方面较为欠缺。Node 则是一套高性能的平台,可以利用它构建与 Nginx 相同的功能,也可以处理各种具体业务,而且与背后的网络保持异步畅通。两者相比,Node 没有 Nginx 在 Web 服务器方面那么装也,但场景更大,自身性能也不错。在实际项目中,我们可以结合它们各自有点,以达到应用的最优性能。

事实上,Node 的异步 I/O 并非首创,但确实第一个成功的平台。在那之前,也有一些知名的基于事件驱动的实现:

  • Ruby 的 Event Machine。
  • Perl 的 AnyEvent。
  • Python 的 Twisted。

在这些平台上采用事件驱动的方式时,需要花一定精力了解这些库。这些库没能成功地原因则是同步 I/O 的存在。本章描述的是异步 I/O 实现,其住址是使 I/O 操作与 CPU 操作分离。奈何这些语言平台上的标准 I/O 库都是阻塞式的,一旦事件循环中存在阻塞 I/O,将导致其余 I/O 无法立即进行,性能急剧下讲,其效果类似于同步式服务,其它请求将不能立即处理。

因为在这些成熟的语言平台上,异步不是主流,尽管有这些事件驱动的实现库,但开发者总会习惯性地采用同步 I/O 库,这导致预想地高性能直接落空。Ryan Dahl 在评估他最早地选型时,Lua 一度时最贴近他选型的语言,但是由于标准 I/O 库时同步 I/O,他知道即使完成这样一个事件驱动的实现,也将不会得到较大范围的使用。在 Node 广泛流行之后,社区的 Tim Caswell 讲 Node 的这套思想重新移植到了 Lua 平台,该项目叫 luavit。

JavaScript 中的作用域和函数在浏览器已有成熟的应用,也很好地帮助了 Ryan Dahl 实现它的想法。JavaScript 在服务器端近乎空白,使得 Node 没有任何包袱,而 Node 在性能上的表现使得它一下子就在社区中流行起来了。

3.6 总结

本章介绍了异步 I/O 和另一些非 I/O 的异步方法。可以看出,事件循环时异步实现的核心,它与浏览器中的执行模型基本保持了一致。而像古老的 Rhino,尽管时较早能在服务器端运行的 JavaScript 运行时,但是执行模型并不像浏览器采用事件驱动,而是像其他语言一般采用同步 I/O 作为主要模型,这造成了它在性能上务所发挥。Node 正是依靠构建了一套完善的高性能异步 I/O 框架,打破了 JavaScript 在服务器端止步不前的局面。