Node.js 之 IO 与 Libuv

May 01, 2023

介绍

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

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

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

IO 基础

由于 Libuv 属于比较底层的库,需要对操作系统一些原理和 API 十分了解,学习起来才能得心应手。下面就让我们学习回顾一下操作系统基础原理(当然你很熟悉这块内容可以跳过):

先从 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 不需要了,「中断」技术担起了这个大任。

进程状态流转

进程调度

进程调度

网络基础

Libux 作为异步 IO 库,网络传输是主要功能。

套接字

绝大部分上层网络协议都直接或间接依赖了 TCP 和 UDP 协议。套接字接口是操作系统提供了实现标准(这里主要指类 Unix 系统)。