IO复用能使得程序能同时监听多个文件描述符,但它本身的阻塞的。当多个文件描述符同时就绪时,若不采取额外措施,程序就只能按顺序依次处理这些描述符。如果要实现并发,只能使用多进程或多线程等编程手段。
9.1 select系统调用
9.1.1 select函数
select系统调用的用途是在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。
#include <sys/select.h>
/*
nfds: 指定被监听的文件描述符的总数,通常被设置为上select监听的所有文件描述符的最大值加1;
readfds: 监听的可读事件文件描述符集合;
writefds: 监听的可写事件文件描述符集合;
exceptfds: 监听的异常事件文件描述符集合;
select调用返回时,内核将修改上述三个参数来通知应用程序哪些文件描述符已就绪。
timeout: 设置select函数的超时时间,结构体定义见下
成功时返回就绪文件描述符的总数,失败返回-1并设置errno,若在等待期间,程序收到信号则select立即返回-1并设置errno为EINTR
*/
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout );
/* fd_set结构体定义 */
/*
fd_set结构体仅包含一个整型数组,该数组每个元素的每一位标记一个文件描述符
fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符总量
*/
#include <typesizes.h>
#define __FD_SETSIZE 1024
#include <sys/select.h>
#include FD_SETSIZE __FD_SETSIZE
typedef long int __fd_mask;
#undef __NFDBITS
#define __NFDBITS ( 8 * (int) sizeo(__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结构体中的位 */
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是否被设置
/* timeval结构体 */
/*
tv_sec,tv_usec均为0, select将立即返回
若为NULL,则select将一直阻塞
*/
struct timeval
{
long tv_sec; // 秒数
long tv_usec; // 微秒数
}
9.1.2 文件描述符就绪条件
socket可读条件:
-
socket内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT,可无阻塞读该socket,读返回的字节数大于0。
-
socket通信对方关闭连接,对该socket的读返回0。
-
监听socket上有新的连接请求。
-
socket上有未处理的错误,此时可用getsockopt来读取和清除该错误。
socket可写条件:
-
socket内核发送缓冲区中可用字节数大于或等于其低水位标记SO_SEDLOWAT,可无阻塞写该socket,写返回字节数大于0。
-
socket的写操作被关闭,对该socket的写操作将触发SIGPIPE信号。
-
socket使用非阻塞connect连接成功或失败后。
-
socket上有未处理的错误,此时可用getsockopt来读取和清除该错误。
异常条件:
- 只有一种,socket接收到带外数据。
9.2 poll系统调用
poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。
#include <poll.h>
/*
fds: 指定要监听的可读、可写、异常事件文件描述符集合, pollfd结构体定义见下
nfds: 指定被监听事件集合fds大小
timeout: 指定poll的超时值,单位ms。值为-1时,poll将永远阻塞
*/
int poll( struct pollfd* fds, nfds_t nfds, int timeout );
struct pollfd
{
int fd; // 文件描述符
short events; // 注册的事件
short revents; // 实际发生的事件,由内核填充
}
poll支持的事件类型:
9.3 epoll系列
9.3.1 内核事件表
epoll是Linux特有的IO复用函数,但在实现和使用上与select、poll有很大差异。
首先epoll一组函数协调来完成任务;
其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,而不需像select那样每次调用都要重复传入,但epoll需要一个额外的文件描述符来标识内核中的这个事件表。
#include <sys/epoll>
/*创建指向内核事件表的文件描述符*/
/*
size并不实际起作用,只是提示内核这个事件表需要多大。
返回的描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表
*/
int epoll_create( int size );
/* 操作epoll内核事件表 */
/*
fd: 要操作的文件描述符
op: 指定操作类型,取值如下
EPOLL_CTL_ADD 往事件表中注册fd上的事件
EPOLL_CTL_MOD 修改fd上的注册事件
EPOLL_CTL_DEL 删除fd上的注册事件
event: 指定事件,定义见下
成功返回0,失败返回-1并设置errno
*/
int epoll_ctl( int epfd, int op, int fd, struct epoll_event* event );
struct epoll_event
{
__uint32_t events; // epoll事件类型,支持事件表与poll基本一致,只是宏名前加“E”
epoll_data_t data; // 用户数据
}
// 由于epoll_data_t是一个联合体,不能同时使用其ptr,fd成员。
typedef union epoll_data
{
void* ptr; // 指定与fd相关的用户数据
int fd; // 指定事件所从属的目的文件描述符
uint32_t u32;
uint32_t u64;
} epoll_data_t;
9.3.2 epoll_wait函数
epoll_wait是epoll系列的主要接口,它在一段超时时间内等待一组文件描述符上的事件。
#include <sys/epoll.h>
/*
events:epoll_wait如果检测到事件,就将所有就绪事件从内核事件表中复制到events指向的数组中
该数组只用于输出就出就绪事件,而不像select数组参数那样记录所有事件,因此提高了效率
maxevents: 指定最多监听多少个事件,必须大于0
成功时返回就绪文件描述符个数,失败返回-1并设置errno
*/
int epoll_wait( int epfd, struct epoll_event* events, int maxevents, int timeout );
下面代码可看出poll和epoll使用上的差别:
// poll
int ret = poll( fds, MAX_EVENT_NUMBER, -1);
for( int i = 0; i < MAX_EVENT_NUMBER; ++i) { // 需遍历所有已注册的文件描述符
if(fds[i].revents & POLLIN) { // 判断是否就绪
int socket = fds[i].fd;
... // 处理
}
}
// epoll
int ret = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, -1);
for( int i = 0; i < ret; ++i) { // 仅遍历就绪的文件描述符
int socket = events[i].data.fd;
... // 处理
}
9.3.3 lt和et模式
epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。
LT模式是默认的工作模式。对采用LT模式的文件描述符,当epoll_wait检测到其上有事件发生并通知到应用程序后,应用程序可不立即处理该事件。当应用程序下次调用epoll_wait时,epoll_wait还会再次向应用程序通知此事件,直到该事件被处理。
ET模式是epoll的高效模式。当往epoll内核事件表中注册一个文件描述符上的EPOLLLET事件后,epoll将以ET模式操作该文件描述符。此模式下,epoll_wait检测到有事件发生并通知到应用程序后,应用程序必须立即处理该事件,后续epoll_wait不会再通知此事件。可见,ET模式降低了同一个epoll事件被重复触发的次数,因此效率更高。
9.3.4 epolloneshot事件
即使使用ET模式,在并发状态下一个socket上的某个事件还是可能被触发多次。比如一个线程在读取完某个socket上的数据后进行处理,而在处理过程中该socket上又有新数据可读,EPOLLIN再次被触发,另一个线程被唤醒来读取这些新数据,这就出现多个线程同时操作一个socket的局面。
我们期望一个socket连接只被一个线程处理,这可通过在其文件描述符上注册EPOLLONESHOT事件来实现。注册EPOLLONESHOT事件后,该文件描述符上注册的可读、可写、异常事件至多只被触发一个,且只触发一次,除非使用epoll_ctl重置该文件描述符上的EPOLLONESHOT。
在处理完之后,线程应立即重置socket上的EPOLLONESHOT事件,以确保该socket的下次事件到来后能被其他线程处理。
9.4 三组对比
/* select */
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout );
/* poll */
int poll( struct pollfd* fds, nfds_t nfds, int timeout );
/* epoll */
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 );
从事件集、最大支持文件描述符数、工作模式、具体实现四个方面对比。
事件集:
select - select的参数类型fd_set仅仅是一个文件描述符集合,没有与事件绑定,因此需要三个fd_set来分别传入可读、可写、异常事件。一方面限制了select可处理的事件类型,一方面调用后内核直接修改fd_set,使得每次调用select都要重置这3个fd_set。
poll - poll参数类型pollfd将文件描述符与事件绑定,任何事件将被统一处理,因此poll接口更为简洁。并且内核修改的是pollfd结构体中的revents成员,而注册的events成员不变,因此不需每次调用重置pollfd的事件集参数。
epoll - 在内核中维护一个事件表,epoll_wait直接从内核事件表中取得用户注册的事件,而无须反复从用户空间读入这些事件。
最大支持文件描述符数:
select - 由fd_set的__FD_SETSIZE决定,默认1024个,用户可修改,但可能导致不可预期的后果;
poll - 可用nfds参数指定,可达系统允许打开的最大文件描述符数目65536;
epoll - 可用maxevents参数指定,可达系统允许打开的最大文件描述符数目65536;
工作模式:
select、poll - LT
epoll - LT/ET,支持EPOLLONESHOT事件,进一步减少事件触发次数。
实现方式:
select、poll - 采用轮询方式检测就绪事件,时间复杂度为O(n);
epoll - 采用回调方式检测就绪事件,事件复杂度为O(1);内核检测到就绪事件,触发回调函数,回调函数将该文件描述符上的对应事件插入内核就绪事件队列,然后内核在合适的时机将该就绪事件队列中的内容拷贝到用户空间。