第九章 I/O 复用
I/O复用使程序能同时监听多个文件描述符,这对提高程序的性能至关重要。通常,网络程序在下列情况下需要使用 I/O 复用计数
- 客户端程序要同时处理多个
socket
。 比如接下来将要讨论的非阻塞connect
技术 - 客户端程序要同时处理用户输入和网络连接。比如聊天室程序
- TCP 服务器要同时处理监听
socket
和连接socket
。这是 I/O 复用使用最多的场合。 - 服务器要同时处理 TCP 和 UDP 请求。比如回射服务器
- 服务器要同时监听多个端口,或者处理多种服务,比如
xinetd
服务器
9.1 select 系统调用
select
:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。
9.1.1 select API
原型:
#include <sys/select.h>
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout );
-
nfds
指定被监听的文件描述符的总数。它通常被设置为select
监听的所有文件描述符中的最大值加 1 ,因为文件描述符是从 0 开始计数的。 -
readfds, writefds
和exceptfds
分别指向可读、可写和异常等事件对应的文件描述符集合。这三个参数是fd_set
结构指针类型。fd_set
结构体定义如下#include <typesizes.h> #define __FD_SETSIZE 1024 #include <sys/select.h> #define FD_SETSIZE __FD_SETSIZE typedef long int __fd_mask; #undef __NFDBITS #define __NFDBITS ( 8 * (int) sizeof (__fd_mask ) ) typedef struct { #ifdef __USE_XOPEN __fd_mask fds_bits[ __FD_SETSIZE / __NFDBITS ]; #define __FDS_BITS(set) ((set)->fds_bits) #else __fd_mask __fds_bits[ __FD_SETSIZE / __NFDBITS ]; #define __FDS_BITS(set) ((set)->__fds_bits) #endif } fd_set;
fd_set
结构体仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符。我们使用下面的一系列宏来访问fd_set
结构体中的位:#include <sys/select.h> FD_ZERO( fd_set* fdset); /* 清除 fdset 的所有位 */ FD_SET( int fd, fd_set* fdset ); /* 设置 fdset 的位 fd */ FD_CLR( int fd, fd_set* fdset ); /* 清除 fdset 的位 fd */ int FD_ISSET( int fd, fd_set* fdset ); /* 测试 fdset 的位 fd 是否被设置 */
-
timeout
设置select
的超时时间。是一个timeval
结构类型的指针。timeval
结构体的定义如下:struct timeval{ long tv_sec; /* 秒数 */ long tv_usec; /* 微秒数 */ }
如果给
timeout
变量的两个成员都传递 0, 则select
将立即返回。如果给timeout
传递NULL
,则select
将一直阻塞,直到某个文件描述符就绪。9.1.3 处理带外数据
socket
上接收到普通数据和带外数据都将使select
返回,但socket
处于不同的就绪状态:前者处于可读状态,后者处于异常状态。select.c
9.2 poll 系统调用
poll
系统调用和 select
类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。原型如下:
#include <poll.h>
int poll ( struct pollfd* fds, nfds_t nfds, int timeout );
-
fds
是一个pollfd
结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常事件。定义如下:struct pollfd{ int fd; /* 文件描述符 */ short events; /* 注册的事件 */ short revents; /* 实际发生的事件,由内核填充 */ }
其中,
fd
成员指定文件描述符:events
成员告诉poll
监听fd
上的哪些事件,它是一系列事件的按位或;revents
成员由内核修改,以通知应用程序fd
上实际发生了哪些事件。poll
支持的事件类型如下表所示:事件 描述 是否可作为输入 是否可作为输出 POLLIN 数据(包括普通数据和优先数据)可读 是 是 POLLRDNORM 普通数据可读 是 是 POLLRDBAND 优先级带数据可读(Linux不支持) 是 是 POLLPRI 高优先级数据可读,比如 TCP 带外数据 是 是 POLLOUT 数据(包括普通数据和优先数据)可写 是 是 POLLWRNORM 普通数据可写 是 是 POLLWRBAND 优先级带数据可写 是 是 POLLRDHUP TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入 是 是 POLLERR 错误 否 是 POLLHUP 挂起。比如管道的写端被关闭后,读端操作符上将收到POLLHUP事件 否 是 POLLNVAL 文件描述符没有打开 否 是 上表中,
POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND
由XOPEN
规范定义。 -
nfds
指定被监听事件集合fds
的大小。类型定义如下typedef unsigned long int nfds_t;
-
timeout
指定poll
的超时值,单位是毫秒。当timeout == -1
时,poll
调用将永远阻塞,直到某个时间发生;当timeout == 0
时,poll 调用将立即返回poll 系统调用的返回值的含义与
select
相同
9.3 epoll 系列系统调用
注意: listen fd
, 有连接请求会触发 EPOLLIN
。
9.3.1 内核事件表
epoll
是 Linux
特有的 I/O 复用函数。在实现上和 select、poll
有很大差异。epoll
是一组函数而不是单个函数来完成任务。其次,epoll
把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select
和poll
拿要每次调用都要重复传入文件描述符或事件集。但 epoll
需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用如下 epoll_create
函数创建:
#include <sys/epoll.h>
int epoll_create( int size )
size
现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有 epoll
系统调用的第一个参数,以指定要访问的内核事件表,用以下函数来操作 epoll
的内核事件表
#include <sys/epoll.h>
int epoll_ctl( int epfd, int op, int fd, struct epoll_event* event)
-
fd
是要操作的文件文件描述符 -
op
指定操作类型。操作类型有如下 3 种:EPOLL_CTL_ADD
往事件表中注册fd
上的事件EPOLL_CTL_MOD
修改fd
上的注册事件EPOLL_CTL_DEL
删除fd
上的注册事件
-
event
指定事件,它是epoll_event
结构指针类型。定义如下struct epoll_event{ __uint32_t events; /* epoll事件 */ epoll_data_t data; /* 用户数据 */ };
其中
events
成员描述事件类型。data
成员用于存储用户数据,其类型epoll_data_t
定义如下:typedef union epoll_data{ void* ptr; int fd; unit32_t u32; uint64_t u64; } epoll_data_t;
epoll_data_t
是一个联合体,其 4 个成员中使用最多的是fd
, 它指定事件所从属的目标文件描述符。ptr
可用来指定与fd
相关的用户数据。由于联合体的特性,ptr
和fd
成员不能同时使用,解决办法之一是:放弃fd
成员,而在ptr
指向的用户数据中包含fd
。
9.3.2 epoll_wait 函数
epoll
系列系统调用的主要接口是 epoll_wait
函数。它在一段超时事件内等待一组文件描述符上的事件,原型如下:
#include <sys/epoll.h>
int epoll_wait( int epfd, struct epoll_event* events, int maxevents, int timeout );
该函数成功时返回就绪的文件描述符的个数。maxevents
指定最多监听多少个事件,必须大于 0 。
epoll_wait
函数如果检测到事件,就将所有就绪的事件从内核事件表 ( 由 epfd
参数指定 ) 中复制到它的第二个参数 events
指向的数组中。这个数组只用于输出 epoll_wait
检测到的就绪事件,而不像 select
和 poll
的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。
poll & epoll --- difference
/* 如何索引 epoll 返回的就绪文件描述符 */
int ret = poll( fds, MAX_EVENT_NUMBER, -1 );
/* 必须遍历所有已注册文件描述符并找到其中的就绪者(可以利用 ret 稍作优化) */
for( int i = 0; i < MAX_EVENT_NUMBER; ++i){
if( fds[i].revents & POLLIN ) /* 判断第 i 个文件描述符是否就绪 */
{
int sockfd = fds[i].fd;
/* 处理 sockfd */
}
}
/* 如何索引 epoll 返回的就绪文件描述符 */
int ret = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, -1 );
/* 仅遍历就绪的 ret 个文件描述符 */
for( int i = 0; i < ret; i++ ){
int sockfd = events[i].data.fd;
/* sockfd 肯定就绪,直接处理 */
}
9.3.3 LT 和 ET 模式
epoll
对文件描述符的操作有两种模式: LT (Level Trigger, 电平触发)
模式和 ET( Edge Trigger, 边沿触发)
模式。LT
是默认的工作模式,这种模式下 epoll
相当于一个效率较高的 poll
。当往 epoll
内核事件表中注册一个文件描述符上的 EPOLLET
事件时,epoll
将以 ET
模式来操作该文件描述符。
对于 LT
,当 epoll_wait
检测到有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。而对于 ET
, 当 epoll_wait
检测到有事件发生并通知后,应用程序必须理解处理该事件,因为后续的 epoll_wait
调用不再向应用程序通知这一事件。由此可见 ET
比 LT
效率要高
注意: 每个 ET
模式的文件描述符都应该是非阻塞的。如果文件描述符时阻塞的,那么读或写操作将会因为没有后续的事件而一直处于阻塞状态(饥渴状态)
LTandET.cpp
g++ -o LTandET LTandET.cpp
./LTandET 192.***.***.*** 8088
addfd( epollfd, listenfd, true) //将 listenfd 这个监听文件符也加进了事件表,所以当新的连接要求加入时,也在触发 EPOLLIN 事件
9.3.4 EPOLLONESHOT 事件
在使用 ET
时,一个线程或进程在读取完某个 socket
上的数据后开始处理数据,而在这个过程中该 socket
上又有新数据可读(EPOLLIN再次触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket
的局面。所以,可以通过 epoll
的 EPOLLONESHOT
事件来实现
对于注册了 EPOLLONESHOT
事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl
函数重置该文件描述符上注册的 EPOLLONESHOT
事件。所以,当该 socket
被某个线程处理完毕,就应该立即重置 EPOLLONESHOT
事件。
EPOLLONESHOT.cpp
g++ -o EPOLLONESHOT EPOLLONESHOT.cpp
./EPOLLONESHOT 192.***.***.*** 8088
9.4 三组 I/O 复用函数的比较
select、poll、epoll
3 组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。select
的函数类型 fd_set
没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select
需要提供 3 个这种类型的参数来分别传入和输出可读、可写及集合的在线修改,应用程序下次调用 select
前不得不重置这 3 个 fd_set
集合。poll
参数类型 pollfd
把文件描述符和事件都定义其中,任何时间都被统一处理,从而使得编程结构简介得多。并且内核每次修改的时 epollfd
结构体的 revents
成员,而 event
成员保持不变,因此下次调用 poll
时应用程序无须重置 pollfd
类型的事件集参数。每次 select
和 poll
调用都返回整个用户注册的事件集和,复杂度高。epoll
则采用与 select
和 poll
完全不同的方式来管理用户注册的事件。在内核中维护一个事件表,并提供一个独立的系统调用 epoll_ctl
来控制往其中添加、删除、修改事件。这样,无须反复从用户空间读入这些事件。epoll_wait
系统调用的 events
参数仅用来返回就绪的事件,这使得应用程序所以就绪文件描述符的时间复杂度达到 O(1)。
select、poll
采用的是轮询的方式来执行事件。
epoll_wait
采用回调的方式。当内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。
所以在选择使用 select
还是 epoll
上,当场景为连接量少,并且都比较活跃时,选择 select | poll
会比 epoll
更好
系统调用 | select | poll | epoll |
---|---|---|---|
事件集合 | 用户通过 3 个参数分别传入感兴趣的可读、可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用 select 都要重置这 3 个参数 | 同一处理所有事件类型,因此只需要一个事件集参数。用户通过 pollfd.events 传入感兴趣的事件,内核通过修改 pollfd.revents 反馈其中就绪的事件 | 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用 epoll_wait 时,无须反复传入用户感兴趣的事件。epoll_wait 系统调用的参数 events 仅用来反馈就绪的事件 |
应用程序索引就绪文件描述符的事件复杂度 | O(n) | O(n) | O(1) |
最大支持文件描述符数 | 一般有最大值限制 | 65535 | 65535 |
工作模式 | LT | LT | 支持 ET 高效模式 |
内核实现和工作效率 | 采用轮询方式来检测就绪事件,算法时间复杂度为 O(n) | 采用轮询方式来检测就绪事件,算法时间复杂度为 O(n) | 采用回调方式来检测就绪事件,算法时间复杂的为 O(1) |
9.5 I/O复用的高级应用一:非阻塞 connect
connect
出错时有一种 errno 值:EINPROGRESS。这种错误发生在对非阻塞的 socket
调用 connect
,而连接又没有立即建立时。根据 man
文档的解释,在这种情况下,我们可以调用 select、poll
等函数来监听跟这个连接失败的 socket
上的可写事件。当 select、poll
等函数返回后,再利用 getsockopt
来读取错误码并清除该 socket
上的错误。
通过上面描述的非阻塞 connect
方式,就可以同时发起多个连接并一起等待。
connect.cpp
然,这种方法存在几个移植性问题。首先,非阻塞的 socket
可能导致 connect
始终失败。其次, select
对处于 EINPROGRESS
状态下的 socket
可能不起作用。最后,对于出错的 socket,getsockopt
在有些系统( 比如 Linux ) 上返回 -1,而有些返回 0.
9.6 I/O复用的高级应用二:聊天室程序
9.6.1 客户端
客户端程序使用 poll
同时监听用户输入和网络连接,并利用 splice
函数将用户输入内容直接定向到网络连接上以发送之,从而实现数据零拷贝,提高了程序执行效率。
源代码在文章开头链接
client.cpp
server.cpp
9.7 I/O 复用的高级应用三:同时处理 TCP 和 UDP 服务
在实际应用中,有不少服务器程序能同时监听多个端口,比如超级服务 inetd
和 android
的调试服务 adbd
。
从 bind
系统调用的参数来看,一个 socket
只能与一个 soscket
地址绑定,即一个 socket
只能用来监听一个端口。因此,服务器如果要同时监听多个端口,就必须创建多个 socket
, 并将他们分别绑定到各个端口上。这样一来,服务器程序就需要同时监听处理该端口上的 TCP 和 UDP 请求,则也需要创建两个不同的 socket
:一个是流 socket
,另一个是数据报 socket
,并将他们都绑定到该端口上。
TCP_UDP.cpp
9.8 超级服务 xinetd
Linux
因特网服务 inetd
是超级服务,它同时管理着多个子服务,即监听多个端口。现在 Linux
系统上使用 inetd
服务程序通常是其升级版本 xinetd
。xinetd
程序的原理与 inetd
相同,但增加了一些控制选项,并提高了安全性。
9.8.1 xinetd 配置文件
xinetd
采用 /etc/xinetd.conf
主配置文件和 etc/xinetd.d
目录下的子配置文件来管理所有服务。主配置文件包含的是通用选项,这些选项将被所有子配置文件继承,并且可以被子配置文件覆盖。每个子配置文件用于设置一个自服务的参数。比如 telnet
子服务的配置文件 /etc/xinetd.d/telnet
的典型内容如下:
# default: on
# description: The telnet server serves telnet sessions; it uses \
# unencrypted username/password pairs for oauthentication.
service telnet {
flags = REUSE
socket_type = stream
wait = no
user = root
server = /usr/sbin/in.telnetd
log_on_failure = USERID
disable = no
}
上面每一项的含义如下:
项目 | 含义 |
---|---|
service | 服务名 |
flags | 设置连接的标志。REUSE 表示复用 telnet 连接的 socket 。该标志已经过时。每个连接都默认启用 REUSE 标志 |
socket_types | 服务类型 |
wait | 服务采用单线程方式 ( wait = yes ) 还是多线程方式 ( wait = no )。单线程方式表示 xinetd 只 accept 第一次连接,此后将由子服务进程来 accept 新连接。多线程方式表示 xinetd 一直负责 accept 连接,而子服务进程仅处理连接 socket 上的数据读写 |
user | 子服务进程将以 user 指定的用户身份运行 |
server | 子服务程序的完整路径 |
log_on_failure | 定义当服务不能启动时输出日志的参数 |
disable | 是否启动该子服务 |
9.8.3 xinetd 工作流程
xinetd
管理的子服务中有的是标准服务,比如时间日期服务 daytime
, 回射服务 echo
和 丢弃服务 discard
。xinetd
服务器在内部直接处理这些服务。还有的子服务则需要调用外部的服务器程序来处理。xinetd
通过调用 fork
和 exec
函数来加载运行这些服务器程序。比如 telnet、ftp
服务都是这种类型的子服务。
查看 xinetd
守护进程的 PID
$ cat /var/run/xinetd.pid
4862