Node.js 之 IO 与 Libuv

May 01, 2023

介绍

相信很多人刚开始接触 Node.js 的时,通过别人介绍或者某个文章了解到它的优势,大概率会听到「异步非阻塞式 IO」「IO 密集型友好」「并发能力强」这些关键词和标签。

以及 JavaScript 开发者们经常谈到的「异步」,比如回调、Promise、事件循环相关的话题。其实这些都和 libuv 有很大关系,或者说这个 Node.js 团队打造的 libuv 提供了大部分这些能力。

今天通过这篇文章,从 libuv 入手,聊聊 IO 与事件循环。

IO 基础

先从 IO 基础讲起,I/O 即 Input/Output,到底是输入输出什么呢?

关于这个问题就要提起著名的「冯洛伊曼模型」,这个模型解释了计算机其实就是一台不断重复「取指令」->「执行指令」->「输出结果」的机器。

这些指令是算术指令、逻辑运算指令、控制指令等 CPU 支持的任意指令,上层应用的功能都是由许多这样的基础指令组成的。这个过程中的输入和输出(指令和数据)就是我们常说的 IO,不过这里讲的是狭义上的 IO,从「内存」->「寄存器」->「CPU」->「寄存器」->「内存」除开 CPU 实际运算的整个过程。

广义上来说,IO 主要泛指「数据」或「指令」花费在传输过程中的这段流程。「磁盘」->「内存」与「内存」->「磁盘」可以称为 IO,「网卡」->「内存」与「内存」->「网卡」可以称为 IO,「A 用户」->「B 用户」也可称为 IO。

在 Web 领域主要指客户端一次请求到服务端响应,在服务端内部可能微服务与微服务之间也会产生调用和传输,这个过程中除开实际运算的过程称为一次 IO。

von-leumann-model

通常应用或服务根据业务场景不同,一般服务可分为 IO 密集型服务和计算密集型服务。这个区分方法就是根据一次请求任务中,计算和 IO 在其中的耗时占比来划分的。我们讨论 C/C++ 运算很快,主要是讨论计算性能快,它们比较适合计算密集型服务。

如果希望 IO 处理起来很快,主要的衡量标准不是单个 IO 的处理时长,而是单位时长内处理的 IO 数量。单个 IO 处理时长受限于网络、硬盘、内存传输速度和服务响应速度,并不会因为编程语言的不同,产生多大的改变。所以 IO 的性能在满足单个请求及时处理的情况下,还要求处理请求等待过程能够抽时间去处理其他请求,通过并行并发和异步的方式同时处理更多请求。

是什么在制约 IO 的处理时长?

从系统内部来看:「存储金字塔」这张结构图展示了不同存储介质的容量和成本关系,我们将硬盘的数据交给 CPU 处理需要一步步将数据读到内存,再存到寄存器。由于不同的速度差,此过程中会产生比较多的难以避免的等待和空闲时间。

存储金字塔

从系统外部来看:「带宽」与「延迟」是影响网络传输必要的两个因素,要提升带宽速度可以通过换更高传输速度的网线(或增加更多根网线)。而要提升减少延迟就难了,光纤是目前最快的传输方式,我们只能减少传输排队和处理时间,无法减少传输的时间,毕竟不管是一束光还是两束光到达的时间都是一样的。

减少空闲和等待的时间

既然等待和空闲无法被避免,那在这个期间让 CPU 干点别的事情成了最优解。主要的方式是通过多进程、多线程、协程、IO 多路复用,这几种方式粒度不同,作用也不尽相同。

多进程:

多进程让我们就算只有一个处理器也照样可以运行多个应用程序,这主要是划分时间片的功劳,但多进程并不是只有时间片。

程序在运行时处于 IO 等待过程中,如果 CPU 空转是一个及其浪费资源的事情,所以这个时候理所当然应该让出 CPU。当 IO 等待结束时如何让 CPU 切换回来继续执行?轮询是一个方式,不过需要固定的时间间隔,时间间隔小性能消耗大,时间间隔大处理延迟高,总归不是一个两全其美的方法。另一种方式就是主动告诉 CPU 不需要了,「中断」技术担起了这个大任。

进程状态流转

进程调度

进程调度

未完待续…