Node 异步 I/O

在底层系统中,异步通过信号量、消息等方式有了广泛的应用。但程序员们还是习惯采用同步的方式编写应用,在绝大多数高级编程语言中,异步并不多见。Java 中用多线程来应对高并发的场景,而 PHP 甚至连多线程都不提供。

Node 是首个将异步作为主要编程方式的运行平台。伴随着异步 I/O 的还有事件驱动和单线程,它们共同为 Node 的设计理念奠定基调,Node 由此成为首个大规模将异步 I/O 应用在应用层上的平台。

因此,想要深入理解 Node 的运作机制,就必须理解 Node 中的异步。对操作系统有学习经验的同学应该会更容易理解。

这篇博文在《深入浅出 Mode.js》第三章笔记的基础上,加上自己的思考与总结而写成。全是硬货,需得再啃。

选择异步的原因

选择异步的原因包括用户体验和资源分配两个方面。

从用户体验的角度分析,浏览器中 JavaScript 在单线程上执行,并且与 UI 渲染共用一个线程。因此采用异步请求将使下载资源期间,JavaScript 和 UI 的执行都不会处于等待状态,可以继续响应用户的交互行为。

而从资源分配角度来看,当遇到一组互不相关的任务需要完成的场景时,主流选择有以下两种:

  • 单线程串行依次执行:容易导致阻塞,硬件资源难以有效利用;
  • 多线程并行:创建线程和执行期线程上下文切换的开销较大,且经常面临锁、状态同步等问题(但多线程在多核 CPU 上能够有效提升 CPU 的利用率);

而 Node 给出的解决方案是:利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞以更好地利用 CPU。

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

从操作系统看异步 I/O

Node 的异步 I/O 不可否认地受到操作系统中异步 I/O 实现的启发。

要强调的是,从计算机内核 I/O 而言,异步/同步和阻塞/非阻塞实际上是两回事

阻塞/非阻塞 I/O

在调用阻塞 I/O 时,应用程序需要等待 I/O 完成时才返回结果。特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束,因此造成 CPU 等待 I/O,浪费等待时间,CPU 的处理能力不能得到充分利用

与阻塞 I/O 完成整个获取数据的过程相比,非阻塞 I/O不带数据直接返回,要获取数据还需要通过文件描述符再次读取。非阻塞 I/O 返回之后,CPU 的时间片可以用来处理其他事务。

但由于完整的 I/O 并没有完成,非阻塞 I/O 立即返回的仅仅是当前调用的状态。为了获取完整数据,需要轮询这种重复调用以判断操作是否完成的技术。

现存的轮询技术包括 read(通过重复调用检查 I/O 状态,性能最低,CPU 一直等待)、select(通过对文件描述符上的事件状态进行判断)、poll(比 select 有所改进,但性能仍较低)、epoll(Linux 下效率最高的 I/O 事件通知机制,进入轮询时休眠,直到事件发生将其唤醒)和kqueue(与 epoll 类似,仅在 FreeBSD 系统存在)。

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

补充:

在看廖雪峰的 Python 教程的异步 IO 这一章时,在评论区看到一个对于阻塞/非阻塞和同步/异步非常精妙的解释,十分容易理解和记忆,特摘录如下:

老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

1 老张把水壶放到火上,立等水开。(同步阻塞)
老张觉得自己有点傻

2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)
老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。

3 老张把响水壶放到火上,立等水开。(异步阻塞)
老张觉得这样傻等意义不大

4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)
老张觉得自己聪明了。

所谓同步异步,只是对于水壶而言。
普通水壶,同步;响水壶,异步。
虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。
同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。

所谓阻塞非阻塞,仅仅对于老张而言。
立等的老张,阻塞;看电视的老张,非阻塞。
情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。

异步 I/O 的实现

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

Linux 原生提供 AIO 这种符合要求的异步 I/O 方式,但只有 Linux 下有,且其无法利用系统缓存。因此现实情况下,想在单线程场景完美实现异步 I/O 有些难度。

而在多线程,通过让部分线程进行阻塞 I/O 或者非阻塞 I/O 加载轮询技术来完成数据获取,让一个线程进行计算处理,再通过线程间的通信将 I/O 得到的数据进行传递,可以模拟异步 I/O。

*nix 平台,Node 自行实现了线程池来完成异步 I/O;Windows 平台则采用 IOCP 实现。Node 提供了 libuv 作为抽象封装层,平台兼容性由这一层完成,并保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。

Node 在编译期间判断平台条件,选择性编译 unix 目录或 win 目录下的源文件到目标程序中。

  • 我们常说的“Node 是单线程的”里的“单线程”仅指 JavaScript 执行在单线程。而内部完成 I/O 任务的另有线程池,只是 I/O 线程使用的 CPU 较少。
  • 除了用户代码无法并行执行外,所有的 I/O (磁盘 I/O 和网络 I/O 等)可以并行。

Node 的异步 I/O

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

事件循环

每执行一次循环体的过程被称为 Tick,查看是否有事件待处理,若有则取出事件及相关回调函数。如果存在关联的回调函数,就执行它们。直到没有事件,进入下个循环。

观察者

每个事件循环中有一个或者多个观察者,判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件

事件循环是一个典型的生产者/消费者模型异步 I/O、网络请求是事件的生产者,事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。观察者相当于事件池

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

请求对象

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

执行回调

组装好请求对象、送入 I/O 线程池等待执行,构成了异步 I/O 的第一部分。而回调通知是第二部分。

线程池中的 I/O 操作调用完毕后,会调用方法向 IOCP 提交执行状态,并将线程归还线程池。

在每次 Tick 的执行中,I/O 观察者会调用方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到 I/O 观察者的队列中,然后将其当作事件处理。

整个异步 I/O 流程图如下:

非 I/O 的异步 API

Node 中还存在一些与 I/O 无关的异步 API:setTimeout()setInterval()setImmediate()process.nextTick()

定时器

setTimeout()setInterval()与浏览器 API 一致,创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次 Tick 执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件并执行其回调函数。注意:由于事件循环自身特点,定时器并非精确的。

process.nextTick() & setImmediate()

两者都可用于将回调函数延迟执行,以异步执行一个任务。两者的区别如下:

  • 优先级:process.nextTick()中回调函数执行优先级要高于setImmediate(),原因在于事件循环对观察者的检查是有先后顺序的,idle 观察者(process.nextTick()) > I/O 观察者 > check 观察者(setImmediate())。

  • 具体实现:process.nextTick()的回调函数保存在一个数组中,setImmediate()保存在链表中。

  • 行为:process.nextTick()在每轮循环中会将数组中的回调函数全部执行完,而setImmediate()在每轮循环中执行链表中的一个回调函数。这样的设计是为了保证每轮循环能够较快地执行结束,防止 CPU 占用过多而阻塞后续 I/O 调用的情况。

事件驱动与高性能服务器

事件驱动的实质:通过主循环加事件触发的方式来运行程序。

几种经典的服务器模型各有其优缺点:

  • 同步式:对于同步式的服务,一次只能处理一个请求,并且其他请求都处于等待状态。
  • 每进程/每请求:为每个请求启动一个进程,这样可以处理多个请求,但因为系统资源有限而不具备扩展性。
  • 每线程/每请求:为每个请求启动一个线程来处理。扩展性强于每进程/每请求,但由于每个线程都占用一定内存,大并发到来时内存还是会很快用光。

Apache 目前还采用每线程/每请求,而 Nginx 采用事件驱动。

Nginx 与 Node 比较:

  • Nginx 采用纯 C 写成,性能较高,但仅适合做 Web 服务器,用于反向代理或负载均衡等服务,在处理具体业务方面欠缺;
  • Node 是一套高性能平台,没有 Nginx 在 Web 服务器方面那么专业,但场景更大,可以处理各种具体业务。

参考资料

《深入浅出Node.js》