I/O多路复用(select/poll/epoll)
通过一个进程来维护多个Socket,也就是I/O多路复用,是一种常见的并发编程技术,它允许单个线程或进程同时监视多个输入/输出(I/O)流(例如网络连接、文件描述符)。当任何一个I/O流准备好进行读写操作时,该机制会通知应用程序,从而使得应用程序可以在不为每个连接创建单独线程或进程的情况下,高效地处理多个并发连接。
常见的系统调用有select/poll/epoll,进程可以通过一个系统调用函数从内核中获取多个事件。
select
通过一种“轮询”或者说“检查”的方式工作。应用程序需要告诉内核它关心哪些文件描述符的哪些事件(可读、可写、异常)。其实现多路复用的方式是,将已连接的 Socket 通过FD_SET放入到要监视的文件描述符集中,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
select需要用户自己维护一个文件描述符数组,需要手动设置关心的事件以及处理完后重设状态,并且这个数组的大小是固定的,因为所要的参数类型是fd_set类型的,这个类型是有固定大小的,即使最终是通过位图来标识文件描述符是否准备就绪的,最大的大小也是sizeof(fd_set) * 8;
其次,select需要遍历文件描述符集合,一次是在内核态里,一个次是在用户态里,而且还会发生2次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
所以从操作者实用角度和来回拷贝还有文件描述符数量的限制来看,select是有很多缺点的。
poll
poll 不再用位图来存储所关注的文件描述符,取而代之用动态数组,应用程序创建一个 struct pollfd 类型的数组。每个 struct pollfd 结构包含三个主要成员:
int fd
: 要监视的文件描述符。short events
: 一个位掩码,表示应用程序关心该FD上的哪些事件(如POLLIN
表示可读,POLLOUT
表示可写等)。short revents
: 一个位掩码,由内核在poll
返回时设置,表示该FD上实际发生的事件。
以链表形式来组织,突破了select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的区别,都需要遍历文件描述符集合来找到可读或可写的Socket,时间复杂度为 0(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll 是对 select 和 poll 的重大改进,特别是在需要监视大量文件描述符时,性能远超前两者。它不再是简单的轮询,而是采用了一种基于事件通知的机制,并且内核会维护一个“就绪列表”。
epoll会用到三个系统调用:
epoll_create:
int epoll_create(int size);创建一个epoll模型,参数 size 在早期版本的内核中用于提示内核期望监视的FD数量,但在现代内核中此参数已被忽略(但仍需大于0)。
返回值是一个文件描述符,后续的操作都将围绕它进行。内核会为这个 epoll 实例维护一个数据结构(通常是红黑树)来存储被监视的FD及其关心的事件,以及一个链表来存放已就绪的FD。
epoll_ctl:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
第一个参数是epoll_create()的返回值;第二个参数表示动作,用三个宏来表示,第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事。第二个参数的取值:
EPOLL_CTL_ADD :注册新的fd到epfd中;
EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
EPOLL_CTL_DEL :从epfd中删除一个fd;
struct epoll event *event:指向一个结构,该结构定义了要监听epoll event的事件类型(如 EPOLLIN,EPOLLOUT,EPOLLET等)以及可选的用户数据(epoll_data_t)。
在epoll中,不同于select和poll的是:
epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是0(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket的数据结构,所以select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
epoll_wait:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
events:是一个由应用程序分配的 epoll_event 结构数组,内核会将已就绪的FD及其发生的事件填充到这个数组中。
maxevents:告诉内核 events 数组的大小,即最多可以返回多少个就绪事件。
timeout:超时时间(毫秒)。-1表示无限期阻塞,0表示立即返回。
epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件(就绪队列),当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中(从红黑树链入到就绪队列中),当用户调用 epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合来找哪一个就绪了,大大提高了检测的效率。
所以总结一下就是:
epoll_create:创建epoll模型
epoll_ctl:在红黑树上增删改
epoll_wait:从就绪队列(这个队列中都是就绪的)中把就绪事件拿出来
epoll支持边缘触发 (Edge Triggered, ET) 和水平触发 (Level Triggered, LT) 两种模式
LT (默认模式):只要FD处于可读/可写状态,epoll_wait
每次都会返回该FD。如果一次没有完全读/写完数据,下次调用 epoll_wait
仍然会通知该FD就绪。
ET:当FD从未就绪变为就绪状态时,epoll_wait
会通知一次。之后即使FD仍然可读/可写,在没有新的事件(例如新的数据到达)之前,epoll_wait
不会再通知该FD。这要求应用程序在收到通知后必须将该FD上的数据一次性处理完毕(通常是循环读/写直到返回 EAGAIN
或 EWOULDBLOCK
)。ET模式通常效率更高,但编程更复杂。
在使用ET模式的时候,就需要把对应的文件描述符设置成非阻塞的才可以,因为我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,一次性读完所有的内容,那么如果文件描述符是阻塞读取的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/0 搭配使用,程序会一直执行I/0 操作,直到系统调用(如 read和 write)返回错误,错误类型为EAGAIN或EWOULDBLOCK。