同步 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 上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
保存 CPU 上下文,包括程序计数器和其他寄存器。
更新 PCB 信息。
把进程的 PCB 移入相应的队列,如就绪队列、在某事件阻塞的等待队列。
选择另一个进程执行,并更新其 PCB。
更新内存管理的数据结构。
恢复 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 操作发生时,它会经历两个阶段:
等待数据准备好(Waiting for the data to be ready)
将数据从内核拷贝到进程中(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 ,一个典型的读操作流程大概是这样:
当用户进程调用了 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 执行读操作时,流程是这个样子:
当用户进程发出 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 。它的基本原理就是通过 select
,poll
,epoll
系统调用,会不断地轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。
当用户进程调用了 select
,那么整个进程会被 block,同时 kernel 会“监视” select
负责的所有 socket,当任何一个 socket 中的数据准备好了,select
就会返回。这个时候用户进程再调用 read 操作,可以直接将数据从 kernel 拷贝到用户进程。
所以,I/O 多路复用的特点是通过一种机制让一个进程能同时等待多个 fd,而这些 fd(socket fd)只要任意一个进入读就绪状态,select
系统调用就可以返回。
这个图和 blocking I/O 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select
和 recvfrom
),而 blocking I/O 只调用了一个 system call (recvfrom
)。但是,用 select
的优势在于它可以同时处理多个 connection 。
所以,如果处理的连接数不是很高的话,使用 select
或 epoll
的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延迟还更大。select
或 epoll
的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接 。
在 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 其实用得很少。先看一下它的流程:
用户进程发起 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 的比较如图所示:
通过上图,可以发现 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 ]; } 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 void FD_ZERO (fd_set *set ) ;void FD_SET (int fd, fd_set *set ) ;void FD_CLR (int fd, fd_set *set ) ;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 ; FD_ZERO(&rfds); FD_SET(0 , &rfds); tv.tv_sec = 5 ; tv.tv_usec = 0 ; retval = select(1 , &rfds, NULL , NULL , &tv); if (retval == -1 ) perror("select()" ); else if (retval) printf ("Data is available now.\n" ); else printf ("No data within five seconds.\n" ); exit (EXIT_SUCCESS); }
select 优点:select
目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
select 缺点:
select
的一个缺点在于其只能监视最多 FD_SETSIZE (1024) 个 fd 数量 (对于许多现代应用程序来说这是一个不合理的下限),并且此限制将不会改变(用户可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低)。所有现代应用程序都应该使用 poll
或 epoll
,它们不受此限制。
fd_set rfds 不可重用 :每次调用 select
之前或新的 fd 进来,需要重新 FD_ZERO 并 FD_SET。
用户态和内核态拷贝产生开销 :用户需要监听事件时,select
会将 writefds、readfds 和 exceptfds 集合都拷贝到内核态;以及有就绪事件时,再次将整个集合(而非就绪的那几个事件)拷贝回用户态。
需要 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; short events; short revents; };
pollfd 结构包含了要监视的 events 和发生的 revents,不再使用 select
“参数 - 值” 传递的方式。同时 pollfd 并没有最大数量限制(但是数量过大后性能也会下降)。和 select
函数一样,poll
返回后,需要轮询 pollfd 来获取就绪的 fd。
从上面看,select
和 poll
都需要在返回后,通过遍历 fd 来获取已经就绪的 socket 。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的 fd 数量的增长,其效率也会线性下降。
epoll
epoll 全称 eventpoll,是 linux 2.6 内核实现 IO 多路复用的一种实现,是之前的 select
和 poll
的增强版本。相对于 select
和 poll
来说,epoll 更加灵活、没有 fd 限制。
epoll 使用一个 epfd 句柄管理多个 fd,将用户关心的 fd 的事件存放到内核的一个事件表(内核数据结构 struct eventpoll)中,这样只需要一次从内核空间到用户空间的拷贝(且仅需拷贝就绪的那几个事件) 。
epoll 操作过程需要三个接口,分别如下:
1 2 3 4 5 #include <sys/epoll.h> int epoll_create (int size) ; 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_data_t data; }; 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 优点
在 select
和 poll
中,进程只有在调用一定的方法后,内核才对所有监视的 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 不同于 select
和 poll
的轮询方式,而是通过每个 fd 定义的回调函数来实现的。只有就绪的 fd 才会执行回调函数。
如果没有大量的 idle-connection(空闲连接)或者 dead-connection(失效连接),epoll 的效率并不会比 select
或 poll
高很多,但是当遇到大量的 idle-connection 时,就会发现 epoll 的效率大大高于 select
或 poll
。
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; struct epoll_event ev ; struct epoll_event my_events [MAX_EVENTS ]; int ready_nfds; int sockfd, client_fd; struct sockaddr_in server_addr , client_addr ; socklen_t addrlen; char buffer[MAX_BUF]; int retval; 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); retval = bind(sockfd, (struct sockaddr*)&server_addr, sizeof (server_addr)); retval = listen(sockfd, MAX_EVENTS); epoll_fd = epoll_create(MAX_EVENTS); memset (my_events, 0 , sizeof (struct epoll_event) * MAX_EVENTS); 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++) { if (my_events[i].data.fd == sockfd) { addrlen = sizeof (client_addr); client_fd = accept(sockfd, (struct sockaddr*)&client_addr, &addrlen); 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 { 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); return ; } buf[n] = '\0' ; printf ("Read from fd %d: %s" , fd, buf); return ; } int main () { int epoll_fd; struct epoll_event ev ; struct epoll_event my_events [MAX_EVENTS ]; event_data_t * event_data = NULL ; int ready_nfds; int stdin_fd = STDIN_FILENO; event_callback fn_callback = NULL ; int retval; memset (my_events, 0 , sizeof (struct epoll_event) * MAX_EVENTS); epoll_fd = epoll_create(MAX_EVENTS); event_data = malloc (sizeof (event_data_t )); event_data->fd = stdin_fd; event_data->callback = (unsigned long int )read_callback; event_data->arg = NULL ; ev.events = EPOLLIN; ev.data.ptr = (void *)event_data; retval = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stdin_fd, &ev) 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 上的任务,从而造成任务饥饿。
不同模式执行差异
假如有这样一个例子:
我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到 epoll 描述符;
这个时候从管道的另一端被写入了 2KB 的数据;
调用 epoll_wait(),并且它会返回 RFD,说明它已经准备好读取操作;
然后我们读取了 1KB 的数据;
调用 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 ) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break ; } else { return ; } } else if (buflen == 0 ) { 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 错误码通常用于指示当前操作无法立即完成,程序需要稍后重试。
参考资料
本文转载并修改自:https://segmentfault.com/a/1190000003063859
https://www.bilibili.com/video/BV1qJ411w7du/
https://notes.shichao.io/unp/ch6/