同步 I/O 和异步 I/O、阻塞 I/O 和非阻塞 I/O 分别是什么?到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文:本文讨论的背景是 Linux 环境下的 Network I/O。

注:本文是对众多博客的学习和总结,可能存在理解错误。请带着怀疑的眼光,同时如果有错误希望能指出。

概念说明

在进行解释之前,首先要说明几个概念:

  • 用户空间和内核空间
  • 进程切换
  • 进程的阻塞
  • 文件描述符
  • 缓存 I/O

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。

为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间;而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存 CPU 上下文,包括程序计数器和其他寄存器。
  2. 更新 PCB 信息。
  3. 把进程的 PCB 移入相应的队列,如就绪队列、在某事件阻塞的等待队列。
  4. 选择另一个进程执行,并更新其 PCB。
  5. 更新内存管理的数据结构。
  6. 恢复 CPU 上下文。

注:总而言之就是 很耗资源

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,例如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作可做时,会 主动执行 阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是 进程自身的一种主动行为。也因此,只有处于运行态的进程(获得 CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。

文件描述符

文件描述符(File descriptor, fd)是计算机科学中的一个术语,是一个 用于表述指向文件的引用 的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是,文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存(page cache)中。也就是说,数据会先被拷贝到内核的缓冲区中,然后才会从内核的缓冲区拷贝到应用程序的地址空间

缓存 I/O 的缺点 :数据在传输过程中需要在应用程序地址空间和内核之间 进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

I/O 模式

刚才说了,对于一次 I/O 访问(以 read 为例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个 read 操作发生时,它会经历两个阶段:

  1. 等待数据准备好(Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

正式因为这两个阶段,Linux 系统产生了以下五种网络模式的方案:

  • 阻塞 I/O(blocking I/O)
  • 非阻塞 I/O(nonblocking I/O)
  • I/O 多路复用( I/O multiplexing)
  • 信号驱动 I/O( signal driven I/O)
  • 异步 I/O(asynchronous I/O)

注:由于 signal driven I/O 在实际中并不常用,所以我这只提及剩下的四种 I/O 模式。

阻塞 I/O

在 Linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程大概是这样:

阻塞 I/O(blocking I/O)模式

当用户进程调用了 recvfrom 这个系统调用,kernel 就开始了 I/O 的第一个阶段:准备数据(对于网络 I/O 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的 UDP 包。这个时候 kernel 就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。

在用户进程这边,整个进程会被阻塞(当然,这是进程自己选择的阻塞)。当 kernel 等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

所以,blocking I/O 的特点就是在 I/O 执行的两个阶段都被 block 了。

非阻塞 I/O

在 Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

非阻塞 I/O(nonblocking I/O)模式

当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它 可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking I/O 的特点是用户进程需要 不断地主动询问 kernel 数据好了没有。

I/O 多路复用

I/O multiplexing 就是我们说的 select, poll, epoll 系统调用,有些地方也称这种 I/O 方式为 event driven I/O。它们的 好处就在于单个 process 就可以同时处理(监听)多个网络连接的 I/O。它的基本原理就是通过 selectpollepoll 系统调用,会不断地轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。

I/O 多路复用( I/O multiplexing)模式

当用户进程调用了 select,那么整个进程会被 block,同时 kernel 会“监视” select 负责的所有 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,可以直接将数据从 kernel 拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制让一个进程能同时等待多个 fd,而这些 fd(socket fd)只要任意一个进入读就绪状态,select 系统调用就可以返回。

这个图和 blocking I/O 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (selectrecvfrom),而 blocking I/O 只调用了一个 system call (recvfrom)。但是,select 的优势在于它可以同时处理多个 connection

所以,如果处理的连接数不是很高的话,使用 selectepoll 的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延迟还更大。selectepoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接

在 I/O multiplexing Model 中,通常会将每个 socket 设置为 non-blocking 模式。这意味着当调用 socket 相关的 I/O 操作时,如果数据尚未准备好,该操作将立即返回而不会阻塞进程。然而,整个用户进程在等待事件发生时仍然会被阻塞。区别在于,这种阻塞是由于系统调用 select(或者其他 I/O 多路复用的系统调用,如 poll、epoll)而不是由于单个 socket I/O 操作导致的。因此,用户进程的阻塞状态是由 I/O 多路复用机制控制的,而不是由单个 socket I/O 操作控制的。

异步 I/O

Linux 的 asynchronous I/O 其实用得很少。先看一下它的流程:

异步 I/O(asynchronous I/O)模式

用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它收到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存。当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。

总结

blocking 和 non-blocking 的区别

调用 blocking I/O 会一直 block 住对应的进程直到操作完成,而 non-blocking I/O 在 kernel 还准备数据的情况下会立刻返回。

synchronous I/O 和 asynchronous I/O 的区别

在说明 synchronous I/O 和 asynchronous I/O 的区别之前,需要先给出两者的定义。POSIX 的定义是这样子的:

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes.
  • An asynchronous I/O operation does not cause the requesting process to be blocked.

两者的区别就在于 synchronous I/O 做 I/O 操作的时候会将 process 阻塞。按照这个定义,之前所述的 blocking I/O/, non-blocking I/O, /I/O multiplexing 都属于 synchronous I/O。

有人会说,non-blocking I/O 并没有被 block 啊。这里有个非常 “狡猾” 的地方,定义中所指的 “I/O operation” 是指真实的 I/O 操作,就是例子中的 recvfrom 这个 system call。non-blocking I/O 在执行 recvfrom 这个 system call 的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。但是,当 kernel 中数据准备好的时候,recvfrom 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内,进程是被 block 的。

而 asynchronous I/O 则不一样,当进程发起 I/O 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说 I/O 完成。在这整个过程中,进程完全没有被 block。

各个 I/O Model 的比较如图所示:

五种 I/O 模式比较

通过上图,可以发现 non-blocking I/O 和 asynchronous I/O 的区别还是很明显的。在 non-blocking I/O 中,虽然进程大部分时间都不会被 block,但是它仍然要求进程去主动的 check,并且当数据准备完成以后,也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存。而 asynchronous I/O 则完全不同。它就像是用户进程将整个 I/O 操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查 I/O 操作的状态,也不需要主动的去拷贝数据。

I/O 多路复用

多路复用概念

I/O 多路复用就是通过一种机制,一个进程 就可以 监视 多个 fd,一旦某个 fd 就绪(通常是读就绪 / 写就绪),便可通知进程进行相应的 I/O 操作。这 避免了每个 I/O 操作都阻塞在一个线程上,从而提高了资源利用率和系统的并发处理能力。

select, poll, epoll 都是实现 I/O 多路复用的机制,但本质上都是同步 I/O,因为它们都需要在 读写事件就绪后自己负责进行读写,也就是说这个读写数据的过程是阻塞的(参考 I/O 多路复用 小节的配图)。

异步 I/O 则无需自己负责进行读写,它会负责把数据从内核拷贝到用户空间。

select

系统调用定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <sys/select.h>

/* 替换宏定义,化简后的结构 */
typedef struct {
long int fds_bits[32]; // 32 位系统上 32*(4*8bits)=1024bits
} fd_set;


int select(int nfds,
fd_set *_Nullable restrict readfds,
fd_set *_Nullable restrict writefds,
fd_set *_Nullable restrict exceptfds,
struct timeval *_Nullable restrict timeout);

DESCRIPTION
select() allows a program to monitor multiple file descriptors, <- 最多 1024 个 fd
waiting until one or more of the file descriptors become "ready" <- 至少一个 fd 就绪后,便可返回
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.

RETURN VALUE
On success, select() and pselect() return the number of file <- 三类集合的就绪数量和
descriptors contained in the three returned descriptor sets (that
is, the total number of bits that are set in readfds, writefds,
exceptfds). The return value may be zero if the timeout expired
before any file descriptors became ready.

On error, -1 is returned, and errno is set to indicate the error;
the file descriptor sets are unmodified, and timeout becomes
undefined.

select 函数监视的 fd 分 3 类,分别是 writefds、readfds 和 exceptfds。调用后 select 函数会阻塞,直到有 fd 就绪(有数据可读、可写或者有 except)或者超时(timeout 指定等待时间,如果立即返回设为 NULL 即可),函数返回。当 select 函数返回后,可以通过遍历各类 fdset,来找到就绪的 fd。

select 使用“参数 - 值”传递的方式,即参数分别是 writefds、readfds 和 exceptfds,它们各自对应着一个位图(bitmap)值。这些位图表示了要监视的 fd 集合的状态,值为 1 表示就绪。

select 系统调用相关的函数:

1
2
3
4
5
6
7
8
9
10
11
// 清空(从集合中删除所有 fd),它应该作为初始化 fd 集的第一步使用
void FD_ZERO(fd_set *set);

// 将 fd 添加到集合中,向集合中添加已经存在的 fd 是一个空操作,并不会产生错误
void FD_SET(int fd, fd_set *set);

// 从集合中移除 fd,移除一个不存在于集合中的 fd 是一个空操作,并不会产生错误
void FD_CLR(int fd, fd_set *set);

// 检查 fd 是否仍然存在于集合中(是否有活动:I/O 操作准备就绪),如果存在于集合中,则返回非零值,否则返回零
int FD_ISSET(int fd, fd_set *set);

来自 man 手册的示例代码:该代码监控标准输入(0),并设置超时时间为 5 秒。在超时时间到达前,若监控到标准输入有输入数据,则 select 返回值 1,表示有一个 fd 就绪,随之按返回结果提示不同的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>

int main(void)
{
int retval;
fd_set rfds;
struct timeval tv;

/* Watch stdin (fd 0) to see when it has input. */

FD_ZERO(&rfds);
FD_SET(0, &rfds);

/* Wait up to five seconds. */
tv.tv_sec = 5;
tv.tv_usec = 0;

retval = select(1, &rfds, NULL, NULL, &tv); // 阻塞等待就绪事件发生
/* 现在不再依赖于 tv 的值,当在超时时间内监控到标准输入有输入数据时,即可返回 */

if (retval == -1)
perror("select()");
else if (retval)
printf("Data is available now.\n");
/* FD_ISSET(0, &rfds) will be true. */
else
printf("No data within five seconds.\n");

exit(EXIT_SUCCESS);
}

select 优点:select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。

select 缺点:

  1. select 的一个缺点在于其只能监视最多 FD_SETSIZE (1024) 个 fd 数量(对于许多现代应用程序来说这是一个不合理的下限),并且此限制将不会改变(用户可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低)。所有现代应用程序都应该使用 pollepoll,它们不受此限制。
  2. fd_set rfds 不可重用:每次调用 select 之前或新的 fd 进来,需要重新 FD_ZERO 并 FD_SET。
  3. 用户态和内核态拷贝产生开销:用户需要监听事件时,select 会将 writefds、readfds 和 exceptfds 集合都拷贝到内核态;以及有就绪事件时,再次将整个集合(而非就绪的那几个事件)拷贝回用户态。
  4. 需要 O(n) 时间复杂度的轮询,来获取集合中就绪的 fd(即使只有一个就绪)。

poll

系统调用定义:

1
2
3
4
5
6
7
8
9
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

DESCRIPTION
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. The
Linux-specific epoll(7) API performs a similar task, but offers
features beyond those found in poll().

不同于 select 使用三个位图(bitmap)来表示三个 fdset 的方式,poll 使用一个 pollfd 结构体指针 fds 实现,nfds 为 fds 数组的长度。

1
2
3
4
5
6
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
/* The bits that may be set/returned in events and revents are defined in <poll.h>: POLLIN, POLLOUT, POLLHUP, et.al. */
};

pollfd 结构包含了要监视的 events 和发生的 revents,不再使用 select “参数 - 值” 传递的方式。同时 pollfd 并没有最大数量限制(但是数量过大后性能也会下降)。和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的 fd。

从上面看,selectpoll 都需要在返回后,通过遍历 fd 来获取已经就绪的 socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的 fd 数量的增长,其效率也会线性下降。

epoll

epoll 全称 eventpoll,是 linux 2.6 内核实现 IO 多路复用的一种实现,是之前的 selectpoll 的增强版本。相对于 selectpoll 来说,epoll 更加灵活、没有 fd 限制。

epoll 使用一个 epfd 句柄管理多个 fd,将用户关心的 fd 的事件存放到内核的一个事件表(内核数据结构 struct eventpoll)中,这样只需要一次从内核空间到用户空间的拷贝(且仅需拷贝就绪的那几个事件)

epoll 操作过程需要三个接口,分别如下:

1
2
3
4
5
#include <sys/epoll.h>

int epoll_create(int size); // 创建一个 epoll 的句柄 epfd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_create 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int epoll_create(int size);

DESCRIPTION
epoll_create() creates a new epoll(7) instance. Since Linux <- epoll 实例
2.6.8, the size argument is ignored, but must be greater than <- size 无用但要大于 0
zero; see HISTORY.

epoll_create() returns a file descriptor referring to the new <- 返回 epoll fd 句柄
epoll instance. This file descriptor is used for all the
subsequent calls to the epoll interface. When no longer
required, the file descriptor returned by epoll_create() should <- 不再需要时要 close(epfd)
be closed by using close(2). When all file descriptors referring
to an epoll instance have been closed, the kernel destroys the <- 关闭后,内核销毁 epfd 实例,释放相关的 fd 资源
instance and releases the associated resources for reuse.

RETURN VALUE
On success, these system calls return a file descriptor (a
nonnegative integer). On error, -1 is returned, and errno is set
to indicate the error.

创建一个 epoll 的句柄,size 用来告诉内核这个监听的数目一共有多大,这个参数不同于 select 中的第一个参数(给出最大监听的 fd 值再加一),这里参数 size 并不是限制了 epoll 所能监听的 fd 最大个数,只是对内核初始分配内部数据结构的一个建议。 自 Linux 2.6.8 后,size 参数被忽略,但应设置一个大于 0 的值。

当创建好 epoll 句柄后,它就会占用一个 fd 值。在 Linux 下,如果查看 /proc/process-id/fd/,是能够看到这个 fd 的,所以在使用完 epoll 后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。

epoll_ctl 接口

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

函数是对 epoll 句柄 epfd 执行 op 操作,即添加、修改或删除 epoll 句柄上的事件。

  • epfd:epoll 句柄,是 epoll_create() 的返回值。
  • op:操作类型,用三个宏来表示,可以是 EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL。
  • fd:需要进行上述 op 操作的目标 fd。
  • event:指向 epoll_event 结构指针,用户进程告诉内核需要监听什么事件(即 event->events 成员指定的宏的组合事件)。

epoll_event 结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};

typedef union epoll_data epoll_data_t;

成员 events 可以是以下几个宏的集合,通过 OR 运算组合而成:

events 标志 含义
EPOLLIN 对应的 fd 可以读(包括对端 SOCKET 正常关闭)
EPOLLOUT 对应的 fd 可以写
EPOLLPRI 对应的 fd 有紧急的数据可读
EPOLLERR 对应的 fd 发生错误
EPOLLHUP 对应的 fd 被挂起
EPOLLET 将 EPOLL 设置为边缘触发(Edge Triggered)模式,相对于水平触发(Level Triggered)
EPOLLONESHOT 只监听一次事件,当监听完该事件后,如果需要继续监听,需将该 socket fd 重新加入到 EPOLL 队列

epoll_wait 接口

1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

等待 epfd 上的 I/O 事件的发生,最多返回 maxevents 个事件。成功时返回就绪事件的数量(即 events 数组中有效条目的数量),失败时返回 -1 并设置 errno 错误码。

  • events:用户空间中用于从内核接收就绪事件的数组指针。
  • maxevents:可以返回的最大事件数。events 数组的大小应该至少是 maxevents。
  • timeout:超时时间,单位是毫秒,-1 表示一直阻塞直到有事件发生,0 表示立即返回(即使没有事件),大于 0 表示等待指定毫秒数后返回。

epoll 优点

selectpoll 中,进程只有在调用一定的方法后,内核才对所有监视的 fd 进行扫描,而 epoll 事先通过 epoll_ctl() 来注册一个 fd。一旦基于某个 fd 就绪时,内核会采用类似 callback 的回调机制,迅速激活这个 fd,当进程调用 epoll_wait() 时便得到通知。epoll 的魅力就在于它去掉了遍历 fd 的步骤,而是通过监听回调的机制

监视的 fd 数量不受限制

  • epoll 所支持的 fd 上限是最大可以打开文件的数目,这个数字一般远大于 2048。举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以通过命令 cat /proc/sys/fs/file-max 来查看。这个数目通常与系统内存的大小有很大关系。
  • select 的最大缺点是进程打开的 fd 数量是有限制的,对于连接数量较大的服务器来说,这种限制根本无法满足需求。虽然也可以选择多进程的解决方案(如 Apache 的实现方式),但尽管在 Linux 上创建进程的代价相对较小,但仍旧不可忽视,加上进程间数据同步远不如线程间同步高效,因此也不是一种完美的方案。

I/O 效率不会随着监视 fd 的数量增长而下降

  • epoll 不同于 selectpoll 的轮询方式,而是通过每个 fd 定义的回调函数来实现的。只有就绪的 fd 才会执行回调函数。
  • 如果没有大量的 idle-connection(空闲连接)或者 dead-connection(失效连接),epoll 的效率并不会比 selectpoll 高很多,但是当遇到大量的 idle-connection 时,就会发现 epoll 的效率大大高于 selectpoll

epoll 通过其独特的回调机制和对大数量 fd 的支持,显著提高了高并发场景下 I/O 操作的效率,是现代高性能网络服务器的首选 I/O 多路复用机制。

示例代码

示例一:这个程序使用 epoll 实现了一个简单的事件驱动服务器,可以处理多个客户端的连接和数据读写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <unistd.h>

#define MAX_EVENTS (10)
#define MAX_BUF (1024)
#define PORT (8080)

int main() {
int epoll_fd; // epoll create 分配的句柄
struct epoll_event ev; // 内核需要监听的事件
struct epoll_event my_events[MAX_EVENTS]; // 用于从内核得到就绪事件的集合
int ready_nfds; // epoll wait 返回的就绪事件的数量
int sockfd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t addrlen;
char buffer[MAX_BUF]; // 用于存储从客户端读取的数据
int retval;

// 创建服务器 socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);

// 设置服务器地址结构
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);

// 绑定 socket 到指定的端口,并监听客户端请求
retval = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
retval = listen(sockfd, MAX_EVENTS);

// 创建 epoll 实例
epoll_fd = epoll_create(MAX_EVENTS);

// 初始化用于存储就绪事件的集合
memset(my_events, 0, sizeof(struct epoll_event) * MAX_EVENTS);

// 添加服务器 socket 到 epoll 实例,监听客户端连接事件
ev.events = EPOLLIN;
ev.data.fd = sockfd;
retval = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &ev);

printf("Listening on port %d...\n", PORT);

while (1) {
ready_nfds = epoll_wait(epoll_fd, my_events, MAX_EVENTS, -1); // 阻塞等待就绪事件发生

// 遍历所有就绪事件,区分处理新的连接和已连接客户端的数据读写
for (int i = 0; i < ready_nfds; i++) {
// 处理新的连接:客户端首次连接时添加到 epoll 事件中进行监听
if (my_events[i].data.fd == sockfd) {
addrlen = sizeof(client_addr);
client_fd = accept(sockfd, (struct sockaddr*)&client_addr, &addrlen);

// 将新连接的 socket 添加到 epoll 实例
ev.events = EPOLLIN;
ev.data.fd = client_fd;
retval = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);

printf("Accepted connection from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
} else {
// 处理客户端 socket 的可读事件
memset(buffer, 0, MAX_BUF);
client_fd = my_events[i].data.fd;
ssize_t n = read(client_fd, buffer, MAX_BUF);
if (n == -1) {
perror("read");
close(client_fd);
continue;
} else if (n == 0) {
printf("Client disconnected, fd %d\n", client_fd);
close(client_fd);
} else {
printf("Read from fd %d: %s", client_fd, buffer);
}
}
}
}

close(sockfd);
close(epoll_fd);
return 0;
}

使用 Chrome 和 Postman 分别访问该服务器,可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ ./ioepoll
Listening on port 8080...
Accepted connection from x.x.x.x:56613
Accepted connection from x.x.x.x:56614
Read from fd 5: GET / HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9

Accepted connection from x.x.x.x:56617
Read from fd 7: GET /df1231213232 HTTP/1.1
User-Agent: PostmanRuntime/7.37.3
Accept: */*
Postman-Token: 1d9c80eb-1f7b-46fc-a0aa-96439f0971c0
Host: 127.0.0.1:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

Client disconnected, fd 7
Client disconnected, fd 5

示例二:这个程序使用 epoll 实现了一个简单的事件驱动模型,通过 回调函数 处理标准输入(stdin)的可读事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <unistd.h>

#define MAX_EVENTS (10)

typedef void (*event_callback)(int fd, void* arg);

typedef struct {
int fd;
/* 一般回调函数定义,可供所有事件使用这一结构体,
在实际使用时,可按需求强制类型转换成不同的函数指针 */
unsigned long int callback;
void* arg;
} event_data_t;

void read_callback(int fd, void* arg) {
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
perror("read");
return;
} else if (n == 0) {
printf("EOF on fd %d\n", fd); // End of file
return;
}
buf[n] = '\0';
printf("Read from fd %d: %s", fd, buf);
return;
}

int main() {
int epoll_fd; // epoll create 分配的句柄
struct epoll_event ev; // 内核需要监听的事件
struct epoll_event my_events[MAX_EVENTS]; // 用于从内核得到就绪事件的集合
event_data_t* event_data = NULL; // 事件的具体数据:包含 fd、回调函数、参数
int ready_nfds; // epoll wait 返回的就绪事件的数量
int stdin_fd = STDIN_FILENO; // 标准输入 fd
event_callback fn_callback = NULL;
int retval;

// 初始化用于存储就绪事件的集合
memset(my_events, 0, sizeof(struct epoll_event) * MAX_EVENTS);

// 创建一个 epoll 实例,返回其句柄
epoll_fd = epoll_create(MAX_EVENTS);

// Allocate memory for event data ⭐
event_data = malloc(sizeof(event_data_t));
event_data->fd = stdin_fd;
event_data->callback = (unsigned long int)read_callback;
event_data->arg = NULL;

// 将标准输入 fd 注册到 epoll 实例中,并监听可读事件
ev.events = EPOLLIN; // 监听可读事件
ev.data.ptr = (void*)event_data; // 用于传递具体的事件数据 ⭐

retval = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stdin_fd, &ev) // 将 stdin 添加到 epoll 实例中

printf("Listening for input on stdin...\n");

while (1) {
ready_nfds = epoll_wait(epoll_fd, my_events, MAX_EVENTS, -1);

// 遍历所有就绪事件
for (int i = 0; i < ready_nfds; i++) {
// 监听到可读事件
if (my_events[i].events & EPOLLIN) {
event_data = (event_data_t*)my_events[i].data.ptr;
if (NULL != event_data) {
// 获取回调函数,执行相应的事件处理函数
fn_callback = (event_callback)event_data->callback;
if (NULL != fn_callback) {
fn_callback(event_data->fd, event_data->arg);
}
}
}
}
}

free(event_data);
close(epoll_fd);
return 0;
}

运行上述程序,在终端输入一些数据,可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./ioepoll2
Listening for input on stdin...
nihao
Read from fd 0: nihao
hello
Read from fd 0: hello
hi
Read from fd 0: hi
hello world!
Read from fd 0: hello world!
-=()&*&@&@%&!
Read from fd 0: -=()&*&@&@%&!

epoll 工作模式

epoll 对 fd 的操作有两种模式:LT (level trigger) 和 ET (edge trigger)。LT 模式与 ET 模式的区别如下:

  • LT 模式(默认模式):当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序 可以不立即处理该事件 。下次调用 epoll_wait 时, 会再次响应 应用程序并通知此事件。
  • ET 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序 必须立即处理该事件 。如果不处理,下次调用 epoll_wait 时, 不会再次响应 应用程序并通知此事件(除非该描述符上有新的事件发生)。

意思就是对于就绪事件,LT 可以不着急处理,下次 epoll_wait 返回时,还有这个就绪事件;而 ET 不处理,下次 epoll_wait 返回时,不会再有这个事件(除非有新的事件发生,再次就绪)。

LT 模式

LT 是缺省的工作方式,并且 同时支持 block 和 non-block socket。在这种做法中,内核告诉你一个 fd 是否就绪了,然后你可以对这个就绪的 fd 进行 I/O 操作。如果你不作任何操作,内核还是会继续通知你的

ET 模式

ET 是高速工作方式,只支持 non-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后,它会假设你知道 fd 已经就绪,并且不会再为那个 fd 发送更多的就绪通知,直到你做了某些操作导致那个 fd 不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 I/O 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。当 epoll 工作在 ET 模式时,必须使用 non-block socket,以避免在一个 fd 上进行阻塞的读或写操作时,导致无法及时处理其他 fd 上的任务,从而造成任务饥饿。

不同模式执行差异

假如有这样一个例子:

  1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到 epoll 描述符;
  2. 这个时候从管道的另一端被写入了 2KB 的数据;
  3. 调用 epoll_wait(),并且它会返回 RFD,说明它已经准备好读取操作;
  4. 然后我们读取了 1KB 的数据;
  5. 调用 epoll_wait() …

对于 LT 模式:在第 5 步调用 epoll_wait() 之后,仍然能收到通知。

对于 ET 模式:如果我们在第 1 步将 RFD 添加到 epoll 描述符的时候使用了 EPOLLET 标志,那么在第 5 步调用 epoll_wait() 之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候,ET 工作模式才会汇报事件。因此在第 5 步的时候,调用者可能会放弃等待仍存在于文件输入缓冲区内的剩余数据。

当使用 epoll 的 ET 模型来工作时,当产生了一个 EPOLLIN 事件后,读数据的时候需要考虑的是当 recv() 返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while (rs) {
buflen = recv(events[i].data.fd, buf, sizeof(buf), 0);
if (buflen < 0) {
// 由于是非阻塞的模式,所以当 errno 为 EAGAIN 时,表示当前缓冲区已无数据可读
// 在这里就当作是该次事件已处理完
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
} else {
return;
}
} else if (buflen == 0) {
// 这里表示对端的 socket 已正常关闭
rs = 0;
}

if (buflen == sizeof(buf)) {
rs = 1; // 很有可能是缓冲区还有数据未读完,需要再次尝试读取
} else {
rs = 0;
}
}

EAGAIN 错误码在 Linux 中的含义:

在 Linux 开发中,我们经常会遇到各种错误码(通过设置 errno),其中 EAGAIN 是一个比较常见的错误码,通常出现在进行非阻塞操作时。字面上,EAGAIN 的含义是“再试一次”。这个错误通常出现在应用程序进行非阻塞(non-blocking)操作时,如对文件、socket 或 FIFO 进行操作。

例如,当以 O_NONBLOCK 标志打开文件、socket 或 FIFO 时,如果连续执行 read 操作但没有数据可读,此时程序不会阻塞等待数据准备就绪,而是会返回一个错误码 EAGAIN,提示应用程序当前没有数据可读,请稍后再试。另一个例子是,当某些系统调用(如 fork)由于资源不足(如虚拟内存)而执行失败时,也会返回 EAGAIN,提示调用程序稍后重试,也许下一次调用会成功。

通过这些例子可以看出,EAGAIN 错误码通常用于指示当前操作无法立即完成,程序需要稍后重试。

参考资料

  1. 本文转载并修改自:https://segmentfault.com/a/1190000003063859
  2. https://www.bilibili.com/video/BV1qJ411w7du/
  3. https://notes.shichao.io/unp/ch6/