如上文所说,select/poll/epoll本质上都是同步阻塞的,但是由于实现了IO多路复用,在处理聊天室这种需要处理大量长连接但是每个连接上数据事件较少的场景时,相比最原始的为每个连接新开一个线程的服务模式要高效许多。
但是我们也经常听到一个说法:select效率低下,在工程实践中从不使用select,而是使用效率更高的epoll
本文会尝试分析一下造成这种现象的原因
SELECT
select范例
先给出select的官方文档
可以看到关键函数如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
先举个简单的实例帮助理解
场景:使用select来监听多个fd上的read事件
1. 创建一个fd_set
2. 调用FD_ZERO清空这个fd_set
3. 多次调用FD_SET将需要监听的fd写入fd_set中
4. 调用select方法,传入fd_set
5. select函数会在有fd可读的时候返回,返回值为可读的fd的个数
6. 用户需要调用FD_ISSET来遍历之间写入fd_set中的所有fd,如果某个fd被设置,说明这个fd可读,于是可以读取数据了
select存在的问题
1. select支持的fd数量太少
默认情况下fd的数值只能小于1024,也就是说同时支持的fd总数必然小于1024(一般0,1,2号fd都是标准输入/输出/错误),这样也就直接导致如果使用select来做网络服务器的话,一个进程最多只能支持1000个左右的并发连接。
官方文档对此的描述如下:
An fd_set is a fixed size buffer. Executing FD_CLR() or FD_SET() with a value of fd that is negative or is equal to or larger than FD_SETSIZE will result in undefined behavior. Moreover, POSIX requires fd to be a valid file descriptor.
这种现象的原因是fd_set内部维护了一个长度为FD_SETSIZE的bitmap,所以如果传入的fd的数值 >= FD_SETSIZE,会出现越界行为。
但是我也看到某些fd_set的实现有所不同,内部维护了一个长度为FD_SETSIZE的int数组,在这种实现中就能存储数值上不受限的fd了。
但是出于保险起见,还是不要存储数值上大于等于1024的fd为好。
2. select开销过高
a. 每次调用select都需要重新设置一次fd_set
b. 每次调用select,都需要将整个fd_set对象在用户空间与kernel中来回拷贝
c. 内核中需要遍历整个fd_set才能知道是否有fd准备就绪
d. select返回后,又需要遍历所有的fd才能知道具体是哪个fd真正就绪了
3. 总结
select效率低下,不适合于高连接数的应用场景。
如果是自己写的测试代码,拿来玩玩倒是不错的,毕竟逻辑简单,易于理解。
POLL
与select相比,poll主要修正了fd的数值必须小于FD_SETSIZE的问题,本质上并没有太大的区别
select中存在的几个问题poll都有,在这里我们就不详细介绍了
EPOLL
linux kernel 2.6中引入了epoll,它彻底解决了select/poll中存在的问题,是真正实用的、可以处理大连接数的IO复用工具
epoll范例
epoll的关键函数如下:
int epoll_create1(int flags);
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);
epoll_create1函数会创建一个epoll的fd并返回,在使用完epoll后必须将其关闭,否则会一直占用这个fd
epoll_ctl可以对传入的fd进行监听操作,各个参数含义如下:
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么类型的事件,struct epoll_event结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};//events可以是以下几个宏的集合,可以用 | 运算符组合多种事件:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll_wait则是在epfd上等待,直到有关联的fd就绪为止,返回值为就绪的fd的数目,参数events则包含了就绪的fd的信息
如果使用epoll来监听多个fd上的read事件,其工作流程如下所示:
1. 调用epoll_create1创建一个epoll的fd
2. 多次调用epoll_ctl,将需要监听的fd与上一步中创建的epfd关联起来,同时将epoll_ctl的event参数的data.fd域设置为需要监听的fd
3. 循环调用epoll_wait
4. epoll_wait返回且返回值大于0,说明已经有fd准备就绪,根据返回值遍历epoll_wait的参数events,获取所有准备就绪的fd
5. 从fd上读取数据
epoll的优势
epoll解决了上文提到的select中存在的所有问题
1. 对于需要监听的fd,只需要在初始化的时候调用一次epoll_ctl将fd与epfd相关联,后续就能循环调用epoll_wait监听事件了。无需像select一样,每次调用select方法的时候都要重复设置并传入待监听的fd集合。这样可以减少重复设置fd_set、以及将fd_set在用户空间与kernel之间来回拷贝带来的开销
2. epoll_wait方法返回的时候,可以直接从events参数中获取就绪的fd的信息,无需遍历整个fd集合。这样可以减少遍历fd_set带来的开销
3. 在调用epoll_create1时,会在kernel中建立一颗fd红黑树与一个就绪fd链表,后续调用epoll_ctl中放入的fd会被挂载到这棵树上,同时也会在kernel的中断处理函数中注册一个回调函数。一旦某个正在监听的fd上有数据可读,kernel在把数据拷贝到内核缓存区中之后,还会将这个fd插入到就绪fd链表中。这样kernel就不用在有fd就绪的时候遍历整个fd集合,从而减少开销。
水平触发与边缘触发
Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
这两种触发的区别与epoll的实现机理有关。
在ET模式下,每次调用epoll_wait方法的时候,系统会直接将就绪链表清空,这样只有新就绪的fd才会被插入就绪链表并返回。
在LT模式下,每次调用epoll_wait方法时,系统只将就绪链表中的事件已经被处理完毕的fd(socket关联的kernel缓冲区数据已经被读取完毕)移除,如果某个fd上还有未被处理的数据,它会被保留在就绪链表中,并在epoll_wait返回时放在events参数中回送给用户。
ps. Java的nio就是用的水平触发。
总结
在高连接数,且大部分连接都不活跃的应用场景中(聊天室),epoll是实现IO多路复用的最优解。在经过调优的系统上,使用epoll可以无压力的处理百万级别的长连接。
但在连接数很少,且每个连接都处于高度活跃状态的应用场景(内网下载文件),select可能就是更好的选择了。
参考资料