IO复用,使程序能同时监听多个文件描述符,这对提高程序的性能至关重要。虽说IO复用可同时监听多个文件描述符,但它本身是阻塞的:若有多个文件描述符同时就绪,如果不采取额外的措施,程序就只能按顺序依次处理其中的每个文件描述符,这使得服务器看起来像是串行工作。若要实现并发,只能使用多进程或多线程等编程手段。
通常,网络程序在以下情况需要用IO复用技术:
- 客户端程序要同时处理多个socket
- 客户端程序要同时处理用户输入和网络连接
- TCP服务器要同时处理监听socket和连接socket
- 服务器要同时处理TCP请求和UDP请求
- 服务器要同时监听多个端口,或处理多种服务
疑问:select, poll, epoll何时返回?
1. 文件描述符就绪条件
1.1 socket可读
- socket内核接收缓冲区中的字节数不小于其低水位标记SO_RCVLOWAT
- socket通信的对端关闭连接
- 监听socket上有新的连接请求
- socket上有未处理的错误(可用getsockopt读取并清除错误)
1.2 socket可写
- socket内核发送缓冲区中的可用字节数不小于其低水位标记SO_SNDLOWAT
- socket写操作被关闭(对写操作被关闭的socket执行写将触发SIGPIPE信号)
- socket使用非阻塞connect连接成功或失败
- socket有未处理的错误(可用getsockopt读取并清除错误)
2. select
在设定的timeout时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件。
#include <sys/select.h>
// nfds为指定的被监听的文件描述符总数,通常被设置为监听的所有文件描述符中的最大值加1(因为文件描述符从0计数)
// timeval结构体提供了微秒级的超时控制,若其成员变量均设置为0,则select立即返回;若timeout设置为NULL,则select一直阻塞到某个文件描述符就绪。
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
struct timeval {
long tv_sec;
long tv_usec;
};
select返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。
// 以下为简化版代码,重在说明问题
#include <typesizes.h>
#define __FD_SETSIZE 1024
#inlude <sys/select.h>
#define __NFDBITS (8 * (int)sizeof(long int))
typedef struct {
long int __fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
由上述定义可知,fd_set是个位图结构,每个bit代码一个文件描述符,fd_set最多可描述1024个文件描述符,这个值可以手动修改,但会产生意想不到的后果。
因位操作过于烦琐,linux提供了一系列宏来访问fd_set的位:
#include <sys/select.h>
FD_ZERO(fd_set* fdset);
FD_SET(int fd, fd_set* fdset);
FD_CLR(int fd, fd_set* fdset);
int FD_ISSET(int fd, fd_set* fdset);
socket上接收到普通数据和带外数据都将使select返回,但socket处于不同的状态
- 当接收到普通数据时,内核会修改可读fd_set
- 当接收到带外数据时,内核会修改异常fd_set
3. poll
#include <poll.h>
// timeout单位毫秒,-1则阻塞至某个文件描述符就绪,0则立即返回
int poll(struct pollfd* fds, unsigned long nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 注册的事件
short revents; // 实际发生的事件,由内核填充
};
通常,应用程序需根据recv返回值区分socket接收到的是有效数据还是对端的连接关闭请求。不过,自Linux内核2.6.17开始,GNU为poll增加了POLLRDHUP事件,它在socket上接收到对方关闭连接的请求之后触发。
4. epoll
epoll是Linux特有的IO复用函数,它在实现上与poll及select有很大差异:
- epoll使用一组函数来完成任务,而不是单个函数
- epoll把关心的文件描述符的事件(包含感兴趣事件及其文件描述符)放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。但epoll需要额外的文件描述符来唯一标识这个事件表。
- epoll_wait若检测到事件,就将所有就绪的事件从内核事件表中复制到epoll_event数组中。这个数组只保存就绪事件,不像select和poll那样保存所有的文件描述符。
#include <sys/epoll.h>
// 返回值用于epoll_ctl和epoll_wait的第一个参数,表示事件表描述符
int epoll_create(int size);
// op取值:EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
// timeout与poll含义一致
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
struct epoll_event {
__uint32_t events; // 感兴趣事件
epoll_data_t data; // 用户数据
};
// 用的最多是fd,表示与事件所从属的文件描述符
// ptr用来指定与fd相关的用户数据,以实现快速的数据访问
typdef union epoll_data {
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
};
4.1 EPOLLET
epoll对文件描述符的操作有两种模式:
- LT模式:默认工作模式,事件通知到应用程序后,若未被应用程序处理则会被再次通知。当epoll_wait检测到有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通知该事件,直到该事件被处理为止。
- ET模式:当通过epoll_ctl往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。采用ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。
可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率比LT高。
值得注意的是,每个使用ET模式的文件描述符都应该是非阻塞的,如果文件描述符是阻塞的,那么读或写操作将会因为无数据可读或无空间可写而一直处于阻塞状态。
4.2 EPOLLONESHOT
待续……