linux 下 I/O 多路复用初探

本文内容整理自B站up主 free-coder 发布的视频:【并发】IO多路复用select/poll/epoll介绍

引入

一般来讲,服务器在处理IO请求(一般指的是socket编程)时,需要对socket的数据进行 accept, recv, send 等操作。

这些操作都是阻塞式的系统调用,线程会在调用处阻塞,等待OS返回。如果这时,目标socket还没做好准备,那么线程会一直处在waiting状态。这就是这种原始模式的致命缺点:线程阻塞被阻塞的时候,只能干等着,无法处理后续的其他客户端请求。

于是,为了高效的利用服务器的硬件资源,为了不让其他客户端干着急,大家想出了多进/线程IO模型——“每一个IO请求,交由一个执行体(进程/线程)处理”。

然而进/线程不能无限制地开辟,因为执行体创建,需要占用内存资源,执行体的切换也需要消耗CPU资源。过多的执行体会造成服务器整体吞吐量的下降,无法支撑大规模的IO请求。

为了避免上述的进/线程频繁切换问题,于是人们想到是否可以把所有的IO请求,都交由1个执行体操作?于是引入了IO多路复用的模型。(我对“多路复用”这个词的理解,就是“多路IO请求数据流,重复使用1个执行体收发”,类比于通信中的“多个信号复用同一个信道”)

多路复用(Multiplexing)的简单实现

设想一下,如果由我们自己实现单执行体操作所有IO的代码,我们可以怎么做呢?
见下面伪代码:

  int* fds[n];
  // 死循环,轮询各个fd,检查是否有数据
  while (1) {
    for (int i = 0; i < n; i++) {
      if (fds[i] is ready) {
        handle(fds[i]);
      }
    }
  }

上述代码中,while 循环内部是不停地对 fds 列表进行遍历轮询,针对每一个fd,都会检查其状态(如:是否有网络数据到达等),这个操作是一般会是一个系统调用(因为fd资源的管理一般是由操作系统来维护的,用户无法避开操作系统内核去取得fd的一些状态)。
由于大多数时间,fd的状态都是空闲的,所以上边轮询代码会导致大量的无效查询,导致CPU空转,浪费了服务器算力。

为了解决上述问题,linux的开发者们想出了一种 “select” 模型。

Multiplexing 之 select

先来看一下 select man page 的描述:

select() allows a program to monitor multiple file descriptors,
waiting until one or more of the file descriptors become "ready"
for some class of I/O operation (e.g., input possible). A file
descriptor is considered ready if it is possible to perform a
corresponding I/O operation (e.g., read(2), or a sufficiently
small write(2)) without blocking.

select() can monitor only file descriptors numbers that are less
than FD_SETSIZE;

select 让调用者可以监控多个文件描述符(即相应的网络socket)的状态。当 select 被调用时,调用线程会阻塞在此处,直到有 >= 1 个文件描述符就绪之后,select 系统调用才会返回。这里,“就绪”,意思是,该文件描述符可以被无阻塞地、顺畅地读取,或写入。select 能监控的文件描述符,其编号须小于 FD_SETSIZE (一般是1024),否则select的结果将是未定义的。

上边的描述中,有以下几个要点:

  1. select 可以监控多个文件描述符,这里,需要给select传递一个文件描述符集合,以告知OS去监控哪些描述符;
  2. select 是阻塞的,仅当有文件描述符就绪之后,才会返回;
  3. 只要 select 返回了,那么必有 >= 1 的文件描述符是可以无阻塞地读or写的;
  4. select 监听的描述符编号须小于 FD_SETSIZE (一般是1024);

下面,我们来看一组 man page 上的示例:

fd_set rfds;          // <-- 声明一个要监听的文件描述符集合 file-descriptors set
struct timeval v;
int retval;
// 监听 stdin (fd:0)
FD_ZERO(&rfds);        // <- 重置监听集合
FD_SET(0, &rfds);      // 将 fd:0 置位,即 rfds 中,代表 fd:0 的那一位被设置为 1
// 等待5秒
tv.tv_sec = 5;
tv.tv_usec = 0;

retval = select(1, &rfds, NULL, NULL, &tv);  // select (nfds, readfds, writefds, exceptfds, timeout)
                                              // nfds 的值为: readfds,writefds,exceptfds 3个集合中,最大的文件描述符编号,再加1
                                              // select 会根据文件描述符的状态,改写 rfds 中的标志位,如果目标描述符未就绪,那么对应的 rfds 中的标志位会被置零

if (retval == -1)
	perror("select()");
else if (retval)
	printf(Data is available now.\n");  // FD_ISSET(0, &rfds) ,检测 fd:0 是否置位,会返回 1
else
	printf("No data within five seconds.\n");

上边的代码中,做了这么几件事:

  1. 声明一个fd_set,和超时时间tv;
  2. 初始化 fd_set, 并对 fd_set 中表示 fd:0 的位置打上标记(置位,表明调用者要监听 fd:0 的IO事件)
  3. 调用 select,这里,OS 会监听 fd_set 中被标记的 fd,一旦有1或多个fd就绪,就对 fd_set 中重新置位,未就绪的fd,fd_set 中的对应标志位会被 OS 置零。(这里,OS对 fd_set 集合进行了覆盖性修改
  4. 检查 select 返回值,判断目标fd是否有数据

接下来,我们看一个网络编程的例子:

... // 此处是绑定&监听socket的代码
for (i = 0; i < 5; i++) {
	memset(&client, 0, sizeof(client));
	addrlen = sizeof(client);
	fds[i] = accept(sockfd, (struct sockaddr*) &client, &addrlen);    // <-- 此处,fds 是描述符数组
	if (fds[i] > maxfd) maxfd = fds[i];  // <-- maxfd 是最大fd编号
}

while (1) {
	FD_ZERO(&rset);              // <-- 此处,rset 是 readyset “读取”文件描述符集合
	for (i = 0; i < 5; i++) {
		FD_SET(fds[i], &rset);      // <-- 对每个要监听的fd,都在 rset 相应标志位上置位一下
	}

	select(max+1, &rset, NULL, NULL, NULL);

	for (i = 0; i < 5; i++) {
		if (FD_ISSET(fds[i], &rset)) {
		... // 此处,处理 fds[i] 上的数据
		}
	}
}

可以发现,代码主体的 while 循环中,主要做了3件事:

  1. 重置&重新初始化 fd_set;(因为每次 select 调用之后,OS 都会覆盖性修改 fd_set 的标志位);
  2. select;
  3. 循环遍历 fd_set,找出返回的集合中,就绪的 fd,并处理。

select 的编程相对来说,较好地实现了“单执行体同时处理多路IO”地目标。但是也有着如下的缺点:

  1. 监听的IO源(即 fd)数量有限(默认1024个)
  2. OS 会对 fd_set 进行覆盖性修改,所以:
    • 每次 select 都需要先从用户内存空间,将 fd_set 完整得拷贝到内核空间。返回时,再从内核空间把OS修改之后的 fd_set 拷贝回用户内存空间;
    • fd_set 被 OS 修改过,所以每次 select 之前,用户代码必须重新初始化 fd_set,把要监听的 fd 设置上。
  3. select 返回后,用户代码中,需要循环遍历整个 fd_set,才知道哪些 fd 可以被处理。

针对上述缺点的 1、2.2,人们提出了优化后的方案——poll

Multiplexing 之 poll (select 优化版)

先来看一下 poll man page 的描述:

poll() performs a similar task to select(2): it waits for one of
a set of file descriptors to become ready to perform I/O.

和 select 方式一样,poll 也是阻塞式的系统调用,仅当有 >= 1 个fd就绪后,poll才会返回。但是 poll 放弃了 fd_set 这一用位图表示监听fd集合的数据结构,而改用了 pollfd 数组(见下方代码):

struct pollfd {
	int   fd;         /* file descriptor */
	short events;     /* requested events */
	short revents;    /* returned events */
};

结构体 pollfd 中包含三个字段: fd,存储对应的文件描述符编号;events 存储调用者感兴趣的 IO 事件标记;revents,由 OS 负责设置,存储 fd 当前就绪的 IO 事件标记.

poll 也破除了 select 的 fd_set 位图“fd编号必须小于 FD_SETSIZE ”的限制,理论上,只要计算机硬件和操作系统允许,可以有无限制数量的 pollfd。

同时,由于 pollfd 结构体设定了 revents 字段,因此 OS 可以在不“覆盖性修改”的情况下,把 fd 的状态传递给用户空间,因而免除了 select 方案中“每次都要重新初始化 fd_set 标志位”的麻烦。

以下是 poll 的网络编程示例:

for (i = 0; i < 5; i++) {
	memset(&client, 0, sizeof(client));
	addrlen = sizeof(client);
	pollfds[i].fd = accept(sockfd, (struct sockaddr*) &client, &addrlen);
	pollfds[i].events = POLLIN;	// 设置该fd要监听的事件类型为读取(POLLIN)
}

while (1) {
	poll(pollfds, 5, 500);
	
	for (i = 0; i < 5; i++) {
		if (pollfds[i].revents & POLLIN) {
			... //  处理可读取数据
		}
	}
}

对比 select 的代码可以发现, 程序主体的 while 循环中,少了每次对要监听的fd集合进行置位的操作。但是,poll 还是会在用户内存空间内核内存空间来回地复制 pollfds 数组,且 poll 返回之后,用户程序还是需要对全部 pollfd 数组进行遍历,才能找到IO请求就绪的 fd 。

对于上述两点不足,人们又提出了一个改进方案,这就是 epoll。

Multiplexing 之 epoll (poll 优化版)

epoll man page 上是这么描述的:

The epoll API performs a similar task to poll(2): monitoring
multiple file descriptors to see if I/O is possible on any of
them. The epoll API can be used either as an edge-triggered or a
level-triggered interface and scales well to large numbers of
watched file descriptors.

epoll 同 poll 一样,也是监听多个(数量可以很大)文件描述符,以检查它们是否有IO事件就绪。同时,epoll 还支持“边缘触发”和“水平触发”两种方式。

“边缘触发”的意思是:IO 的读写缓冲区状态变化时(如由不可读->可读),触发相应事件,用来监听“变化”;“水平触发”意思是:IO 的读写缓冲区处于可读(可写)状态时,持续触发可读(可写)事件,用来监听“当前状态”。

The central concept of the epoll API is the epoll instance, an
in-kernel data structure which, from a user-space perspective,
can be considered as a container for two lists:

  • The interest list (sometimes also called the epoll set): the
    set of file descriptors that the process has registered an
    interest in monitoring.

  • The ready list: the set of file descriptors that are "ready"
    for I/O. The ready list is a subset of (or, more precisely, a
    set of references to) the file descriptors in the interest
    list. The ready list is dynamically populated by the kernel as
    a result of I/O activity on those file descriptors.

epoll 的核心概念:epoll 实例,是一种内核中的数据结构,从用户态角度看,可以把 epoll 实例视为两张列表:

  • 兴趣表(也叫 epoll 集合):用户程序注册的,需要 epoll 去监听的文件描述符集合;
  • 就绪表:IO 事件已就绪的fd集合。就绪表是兴趣表的子集(准确说,是兴趣表中fd的引用,的集合)

由于就绪表的存在,每次 epoll 返回的时候,就不用把所有注册的fd都复制一遍,相应的,用户程序也不用把所有的fd都遍历一遍。epoll 只返回 IO 事件就绪的fd,用户程序也只需处理这些fd。极大地方便了用户程序的编写和管理。

下边是 epoll 网络编程的示例:

struct epoll_event readyList[5];	// epoll 实例要返回的“就绪列表”
int epfd = epoll_create(10);	// 参数 10,在内核版本2.6.8之后无意义。但是必须传,切须>0

for (i = 0; i < 5; i++) {
	static sturct epoll_event ev;
	memset(&client, 0, sizeof(client));
	addrlen = sizeof(client);
	ev.data.fd = accept(sockfd, (struct sockaddr*) &client, &addrlen);
	ev.events = EPOLLIN;
	epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);	// 向 epoll 实例中的兴趣表注册该fd的 IO 事件
}

while(1) {
	nfds = epoll_wait(epfd, readyList, 5, 10000);	// epoll_wait返回当前就绪列表的fd数量
	for (i = 0; i < nfds; i++) {
		...// 挨个处理 readyList[i] 的 IO 事件
	}
}

结束

select, poll, epoll, 都是同步阻塞的方式,对IO进行了多路复用,它们是不同历史时期逐个发展出来的。了解它们各自出现的背景,以及相应的不足,再去审视它们设计细节,会容易理解得多。

posted @ 2021-04-11 21:40  leozmm  阅读(120)  评论(0编辑  收藏  举报