UNIX中IO操作的两个阶段:(1) 等待数据准备就绪;(2) 数据从内核复制到用户空间。
select/poll/epoll都是IO多路复用模型,select/poll需要轮询fd是否就绪,epoll基于事件驱动,性能更高。 select使用数组保存文件描述符,每次都要把所有fd从用户空间复制到内核空间,内核遍历所有fd以判断是否准备好;poll使用链表保存文件描述符,没有了fd数量的限制;epoll使用红黑树和双向链表。
select
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout)
select系统调用可以让程序监控多个文件描述符(fd)。进程通过将一个或多个fd传递给select系统调用,阻塞在select操作上,直到:(1)一个或多个fd变为就绪;(2)调用被信号打断;(3)超时时间到了。
fd_set结构体中包含一个fd数组,系统提供FD_SET、FD_CLR、FD_ISSET、FD_ZERO方法进行操作。
特点:
用户通过3个参数分别传入感兴趣的可读、可写以及异常事件,内核通过修改这些参数来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数。
select采用轮询fd的方式来检测事件就绪,而且支持的fd数量有限。
select使用例子:
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sockfd, ...);
listen(sockfd, ...);
int fds[] = 存放需要监听的socket;
while (1) {
int n = select(..., fds, ...);
for (int i = 0; i < fds.count; i++) {
if (FD_ISSET(fds[i], ...)) {
// fds[i]的数据处理
}
}
}
}
poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // file descriptor
short events; // requested events
short revents; // returned events
};
fds是一个pollfd结构体数组,存放需要监控的socket fd。
poll类似于select,也是阻塞的。
特点:
poll统一处理所有事件类型,因此只需要一个事件参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中的就绪事件。
epoll
// 创建一个epoll实例,返回指向新创建的epoll实例的fd。
int epoll_create(int size);
int epoll_create1(int flags);
// 事件注册。添加、修改、删除要监控的socket和事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// op: EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL
// fd: 监听端口的socket对应的fd
// events: EPOLLIN/EPOLLOUT/EPOLLRDHUP/EPOLLPRI/EPOLLERR/EPOLLHUP/EPOLLET/EPOLLONESHOT/EPOLLWAKEUP
// 等待事件产生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// events
// maxevents本次可以返回的最大事件数目
创建epoll实例就是在内核申请一块空间(eventpoll结构体),其中包含了一个红黑树和双向链表rdlist。红黑树中存放要监控的fd事件,链表中存放就绪的fd事件。
struct eventpoll {
struct rb_root rbr;
struct list_head rdlist;
}
通过epoll_ctl来注册、修改、删除要监控的socket fd和事件类型,也就是把这些参数交给内核来维护红黑树。
epoll_wait等待事件的发生,然后将rdlist中的内容从内核空间拷贝到用户空间events数组中。
epoll使用例子:
imt main() {
// 获取监听的socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定端口
bind(listenfd, addr, addrlen);
// 开始监听
listen(listenfd, 20);
// socket设置为nonBlock的
setNonBlock(listenfd);
// 创建epoll并添加监听事件
int epollfd = epoll_create(1);
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev); // ev是sockfd的EPOLLIN事件
// 处理事件
for (;;) {
struct epoll_event activeEvs[100];
int n = epoll_wait(epollfd, activeEvs, MAX_EVENTS, waitms);
for (int i = 0; i < n; i++) {
int fd = activeEvs[i].events;
if (events & (EPOLLIN | EPOLLERR)) {
if (fd == listenfd) { // 监听socket
// handleAccept
int connectfd = accept(fd, raddr, rsz);
setNonBlock(connectfd);
epoll_ctl(epollfd, EPOLL_CTL_ADD, connectfd, &ev); // ev是connectfd的EPOLLIN事件
} else { // 普通socket
handleRead(epollfd, fd);
}
} else if (events & EPOLLOUT) {
handleWrite(epollfd, fd);
}
}
}
}
监听socket是服务端用来处理连接请求的socket;普通socket是已建立好连接的socket。 通过socket()函数创建的监听socket,本质就是一个文件描述符,对该文件描述符的IO操作方式分为阻塞和非阻塞。
水平触发和边缘触发
epoll除了提供select/poll那种IO事件的水平触发LT外,还提供了边缘触发ET。epoll默认的模式是LT。
水平触发(也叫条件触发):当epoll_wait检测到某fd事件就绪时会通知应用程序,应用程序可不立即处理该事件,下次调用epoll_wait时,会再次通知。
边缘触发:当epoll_wait检测到某fd事件就绪时会通知应用程序,如果应用程序未作处理,下次调用epoll_wait时不会再次通知。 ET模式下只能使用非阻塞IO,因为ET模式下每次都需要循环write或read直到返回EAGAIN错误,如果使用阻塞IO会导致程序阻塞在最后一次write或read。
速记:LT-条件满足则一直触发;ET-由不满足变为满足才会触发一次。
数字电路中,电平触发是在高或低电平保持的事件内触发;边沿触发是由高到低或由低到高的一瞬间触发。
应用
对于监听的fd,最好使用LE,高并发情况下ET模式会导致有的客户端连接不上。 对于读写fd,ET必须配合非阻塞IO,并要求一次性地完整读写全部数据。
Nginx中监听socket是LT模式,而普通(accept)socket采用ET模式。 监听socket采用LT模式是因为ET模式高并发下可能丢失连接请求。 普通socket采用ET模式是因为在LT模式下,如果写数据量常常比较大,无法一次写完,就得频繁监听和移除EPOLLOUT事件。
EPOLLOUT: 可写
EPOLLIN: 可读