前言
近几年,「异步」与「非阻塞」这两个概念在服务端应用开发中广泛提及。很多时候大家都喜欢将其合在一起描述,导致许多人可能会混淆了对这两个词的理解。本文试着从 Linux I/O
的角度讲解这两者之间的恩怨情仇。
本文涉及以下内容:
- Linux 的 I/O 基础知识;
- I/O 模型含义与现有的几类:
- 阻塞 I/O;
- 多线程阻塞 I/O;
- 非阻塞 I/O;
- I/O多路复用:select/poll/ epoll;
- 异步 I/O
- libuv 中如何解决 I/O 的问题。
另外,本文所涉及的例子,已托管在 GITHUB,欢迎下载试运行。
Linux 的 I/O 基础知识
从读取文件的例子开始
现在有个需求,通过 shell 脚本实现文件的读取。我相信大部分人能够马上完成实现:
$ cat say-hello.txt
而现在我们要求用 C
来实现同样的功能,以求揭露更多细节。
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char const *argv[])
{
char buffer[1024];
int fd = open("say-hello.txt", O_RDONLY, 0666);
int size;
if (fd < 0) {
perror("open");
return 1;
}
while ((size = read(fd, buffer, sizeof(buffer))) > 0)
{
printf("%sn", buffer);
}
if (size < 0) {
perror("read");
}
close(fd);
return 0;
}
调用 open 函数取得以一个数字,通过将其用于 write 与 read 操作,最后调用 close,这就是最基础的 LinuxI/O 操作 流程。
这边常写 JavaScript,而不熟悉 c 的人可能会有两个问题。
- open 方法返回的数字是什么?
- read 操作会从硬盘读取资源,read 之后的代码需要等待,如何做到的(好像和 Node.js 里面不太一样)。
带着疑问我们开始下面的知识。
文件操作符
我们知道 Linux 有句 slogan 叫做 “一切皆文件”。体现这个特点的很重要一点就是文件描述符机制。
我们来总结下常见的 I/O 操作,包括:
- TCP / UDP
- 标准输入输出
- 文件读写
- DNS
- 管道(进程通信)
Linux采用了文件操作符机制来实现接口一致,包括:
- 引入文件操作符( file descriptor,以下简称 fd);
- 统一对 read 和 write 方法进行读写操作。
上文例子提到 open 函数返回一个数字,就是文件描述符,用于对应到当前进程内唯一的文件。
Linux 在运行中,会在 进程控制块(PCB) 使用一个固定大小的数组,数组每一项指向内核为每一个进程所维护的该进程打开文件的记录表。
struct task_struct {
...
/* Open file information: */
struct files_struct *files;
...
}
/*
* Open file table structure
*/
struct files_struct {
spinlock_t file_lock ____cacheline_aligned_in_smp;
unsigned int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
unsigned long full_fds_bits_init[1];
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};
而 fd 其实是对应文件结构在这个数组中的序号,这也是 fd 会从 0 开始的原因 。
所以在 read 或 write 操作时传入 fd,Linux自然能找到你需要操作的文件。
如何感知 fd 的存在,可以使用 lsof 命令,比如使用以下命令打印 Chrome 浏览器当前打开的 fd 情况(如果你有使用 Chrome 浏览器访问当前页面的话)。
$ lsof -p$(ps -ef |grep Chrome|awk 'NR==1{print $2}')
上面例子引入的第二个问题:
read 操作会从硬盘读取资源,read 之后的代码需要等待,如何做到的(好像和 Node.js 里面不太一样)。
在 Linux 运行过程中,程序运行的主体是进程 or 线程(Linux 内核 2.4 之前是进程,之后调度的基础单位变成了线程,进程成为线程的容器)。
进程在运行过程中会有基础运行状态是这样子的
结合上面的例子,在 read 函数执行后,进程进入阻塞态,并在 I/O
结束后由系统中断重新将进程解除阻塞态,进入就绪态,并等待时间片分配后,进入执行状态。
也就是说我们的进程会在 I/O 操作发生时阻塞住,这就是上面问题的解释。
I/O 模型
上面介绍的这个 I/O 机制 就是我们I/O 模型中的 阻塞 I/O 机制 。
阻塞 I/O
阻塞 I/O 是 read / write 函数的默认执行机制,会在读写操作执行时将进程置为阻塞态,I/O 完成后,由系统中断将其置为就绪态,等待时间片分配,并执行。
但阻塞 I/O 的机制存在一个问题,就是无法并发地执行 I/O 操作,或者在 I/O 操作执行的同时执行 CPU 的计算。如果在 web 请求/响应场景下,如果一个请求读取状态发生阻塞,那么其他请求则无法处理。
我们需要解决这个问题。
多线程阻塞 I/O
第一个思路是使用多线程。
我们预先初始化一个线程池,利用信号量的 wait 原语进入阻塞状态。等到有 I/O 操作需求时,通过信号量signal将线程唤醒并执行相关的 I/O 操作。详细的操作请看代码。
但多线程非阻塞 I/O 有个弊端,就是当连接数达到很大的一个程度时,线程切换也是一笔不小的开销。
所以,期望能够在一个线程内解决 I/O 的等待操作,避免开启多个线程而造成的线程上下文切换的开销。有没有这样的方式呢,所以就可以引入非阻塞 I/O 的模式了。
非阻塞 I/O
非阻塞 I/O 是一种机制,允许用户在调用 I/O 读写函数后,立即返回,如果缓冲区不可读或不可写,直接返回 -1。这里有一个非阻塞 I/O 构建 web 服务器的例子,可以看代码。
关键性的函数是这段:
fcntl(fd, F_SETFL, O_NONBLOCK);
可以看到此处的进程 STATE 不再进入了阻塞状态,I/O 操作执行的同时可以进行其他 CPU 运算。
非阻塞 I/O 能够帮我们解决在一个线程并发执行 I/O 操作的需求,可同样会带来问题:
- 如果 while 循环轮询等待执行的操作,会造成不必要的 CPU 运算的浪费,因为此时 I/O 操作未完成,read 函数拿不到结果;
- 如果使用 sleep/usleep 的方式强行让进程睡眠一段时间,又回造成 I/O 操作的返回不及时。
所以,系统有没有一种机制来允许我们原生地等待多个 I/O 操作的执行呢。答案是有的,需要引入我们的 I/O 多路复用。
I/O 多路复用
I/O 多路复用,故名意思是在一个进程内同时执行多个 I/O 操作。本身也有一段进化的过程,分别是 select, poll, epoll(macos 上的替代品是 kqueue) 这几个阶段。我们依次来介绍。
select
select
使用一般包含三个函数(详细介绍,戳这):
void FD_SET(int fd, fd_set *fdset);
int select(int nfds, fd_set *restrict readfds,
fd_set *restrict writefds, fd_set *restrict errorfds,
struct timeval *restrict timeout);
int FD_ISSET(int fd, fd_set *fdset);
select 作用是可以批量监听 fd,当传入的 fd_set 中任何一个 fd 的缓冲区进入可读/可写状态时,解除阻塞,并通过 FD_ISSET 来循环定位到具体的 fd。
fd_set tmpset = *fds;
struct timeval tv;
tv.tv_sec = 5;
int r = select(fd_size, &tmpset, NULL, NULL, &tv);
if (r < 0) do {
perror("select()");
break;
} while(0);
else if (r) {
printf("fd_size %dn", r);
QUEUE * q;
QUEUE_FOREACH(q, &loop->wq->queue) {
watcher_t * watcher = QUEUE_DATA(q, watcher_t, queue);
int fd = watcher->fd;
if (FD_ISSET(fd, &tmpset)) {
int n = watcher->work(watcher);
}
}
}
else {
printf("%d No data within five seconds.n", r);
}
这里有一个 select 构建 web 服务器的例子,可以看看。
select 函数虽然能解决 I/O 多路复用的问题,但同时还存在一些瑕疵:
- fd_set 结构允许传入的最大 fd 数量是 1024,如果超过这个数字,可能依然需要使用多线程的方式来解决了;
- 性能开销
- select 函数每次的执行,都存在 fd_set 从用态到内核态的拷贝;
- 内核需要轮询 fd_set 中 fd 的状态;
- 返回值在用户态中也需要进行轮询确定哪些 fd 进入了可读状态;
poll
第一个问题由 poll 解决了,poll 函数接收的 fd 集合改成了数组, 不再有 1024 大小的限制。poll 函数的定义如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
具体的用法和 select 非常像:
struct pollfd * fds = poll_init_fd_set(loop, count);
int r = poll(fds, count, 5000);
if (r < 0) do {
perror("select()");
break;
} while(0);
else if (r) {
QUEUE * q;
QUEUE_FOREACH(q, &loop->wq->queue) {
watcher_t * watcher = QUEUE_DATA(q, watcher_t, queue);
int fd = watcher->fd;
if (watcher->fd_idx == -1) {
continue;
}
if ((fds + watcher->fd_idx)->revents & POLLIN) {
watcher->work(watcher);
}
}
}
else {
printf("%d No data within five seconds.n", r);
}
这里有一个 poll 构建 web 服务器的例子,可以看看。
但上述在 select 提到的性能开销,问题仍然存在。而在 epoll 上,问题得到了解决。
epoll
详细介绍可以看这里。简单来讲,epoll 将 select, poll 一步完成的操作分成了三步:
- epoll_create ,创建一个 epoll_fd,用于 3 阶段监听;
- epoll_ctl ,将你要监听的 fd 绑定到 epoll_fd 之上;
- epoll_wait,传入 epoll_fd,进程进入阻塞态,在监听的任意 fd 发生变化后进程解除阻塞。
我们来看下 epoll 是如何解决上述提到的性能开销的:
- fd 的绑定是在 epoll_ctl 阶段完成,epoll_wait只需要传入 epoll_fd,不需要重复传入 fd 集合;
- epoll_ctl 传入的 fd 会在内核态维护一颗红黑树,当由 I/O 操作完成时, 通过红黑树以 O(LogN) 的方式定位到 fd,避免轮询;
- 返回到用户态的 fd 数组是真实进入可读,可写状态的 fd 集合,不再需要用户轮询所有 fd。
如此看来,epoll 方案是多路复用方案的最佳方案了。这有个 epoll 构建 web 服务器的例子,可以看下。
但 epoll 就没有缺陷吗?也有:
- epoll 目前只支持 pipe, 网络等操作产生的 fd,暂不支持文件系统产生的 fd。
异步 I/O
上面介绍的,无论是阻塞 I/O 还是 非阻塞 I/O 还是 I/O 多路复用,都是同步 I/O。都需要用户等待 I/O操作完成,并接收返回的内容。而操作系统本身也提供了异步 I/O 的方案,对应到不同的操作系统:
- Linux
- aio,目前比较被诟病,比较大缺陷是只支持 Direct I/O(文件操作)
- io_uring, Linux Kernel 在 5.1 版本加入的新东西,被认为是 Linux 异步
I/O
的新归宿
- windows
- iocp,作为 libuv 在 windows 之上的异步处理方案。(笔者对 windows 研究不多,不多做介绍了。)
至此,介绍了常见的几种 I/O 模型。
而目前在 Linux 上比较推荐的方案还是 epoll 的机制。但 epoll 不支持监听文件 fd 的问题,还需要动点脑筋,我们来看看 libuv 怎么解决的。
libuv I/O 模型
libuv 使用 epoll 来构建 event-loop 的主体,其中:
- socket, pipe 等能通过 epoll 方式监听的 fd 类型,通过 epoll_wait 的方式进行监听;
- 文件处理 / DNS 解析 / 解压、压缩等操作,使用工作线程的进行处理,将请求和结果通过两个队列建立联系,由一个 pipe 与主线程进行通信, epoll 监听该 fd 的方式来确定读取队列的时机。
到这里,本文要结束了。做个简短的总结:
首先,我们介绍了文件描述符的概念,紧接着介绍了 Linux 基础进程状态的切换。然后引入阻塞 I/O 的概念,以及缺陷,从而引入了多线程阻塞 I/O,非阻塞 I/O,以及I/O多路复用和异步 I/O的概念。最后结合上面的知识,简单介绍了 libuv 内部的 I/O 运转机制。
最后,打个「小广告」。字节跳动诚邀优秀的前端工程师和 Node.js
工程师加入做有趣的事情,欢迎有意者私信联系,或发送简历至 xujunyu.joey@bytedance.com。
好了,我们下期再会。