操作系统-select、poll与epoll的区别

参考:

引言

最近在学习libhv,学习到事件循环IO多路复用,介于之前对于此方面了解不足,故深入学习一下。

简述

首先我们先清楚两个概念:

  • 事件循环;
  • IO多路复用;

事件循环

对于事件循环,我想学过Qt的一定很熟悉,其为驱动Qt运行的底层机制。

简单来说就是,对于长时间运行的程序,必然会有主循环的存在,此处以Windows窗口消息机制来说明:

1
2
3
4
5
6
MSG msg;
while(GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}

此循环所在线程我们称为GUI线程(即窗口所在线程)Qt的事件循环也是将其封装。

IO多路复用

GUI线程有事件循环,那么网络IO线程亦是如此。

我们都知道,IO分为阻塞BIO非阻塞NIO

对于BIO而言,伪代码如下:

1
2
3
4
5
6
7
8
for(;;)
{
readbytes = read(fd, buff, len);
// do something...
...
writebytes = write(fd, buff, len);
// do something...

因为独写都是阻塞的,所以一个IO线程只能处理一个fd;对于客户端而言尚可,但对于服务端来说,每一个accept就需要创建一个IO线程去读写这个套接字,当并发量变大如几千时,那么就需要创建几千个线程,线程上下文切换的开销都会把系统占满。

所以IO多路复用应运而生,一句话解释就是:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。

详解

了解了上面的概念,那么就不难知道一个道理:非阻塞NIO搭配IO多路复用机制是高并发的钥匙

到这里,我们可以开始了解这三个东西了。

selectpollepoll本质上都是同步IO,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步IO则无需自己负责进行读写,异步IO的实现会负责把数据从内核拷贝到用户空间。

select

概述

首先,我们先了解他的机制,为线性扫描,即轮询,效率较低;仅知道有IO事件发生,却不知发生位置,只会无差异轮询所有流,找出能读/写数据的流进行操作。同时处理的流越多,轮询的时间越长O(n)

并且,单个进程可监视的fd数量是受限制的,即能监听的端口数量有限;单个进程能打开的最大连接数由FD_MAXSIZE宏定义。

socket过多时,每次select都需要通过遍历FD_MAXSIZEsocket,不管其是否活跃;这会浪费大量cpu时间。

分析

再来分析一下select函数:

1
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
  • 参数解析
    • int maxfdp1: 指定待测试的文件描述字个数,它的值是待测试的最大描述字加1;
    • fd_set *readset , fd_set *writeset , fd_set *exceptset fd_set: 可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。中间的三个参数指定我们要让内核测试读、写和异常条件的文件描述符集合。如果对某一个的条件不感兴趣,就可以把它设为空指针;
    • const struct timeval *timeout: timeout告知内核等待所指定文件描述符集合中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
  • 返回值
    • int: 若有就绪描述符返回其数目,若超时则为0,若出错则为-1;

运行机制

select的机制中提供一种fd_set的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select的进程哪一Socket或文件可读。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socketIO请求。

问题

  1. 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那么这个开销也会很大;
  2. socket扫描时是线性扫描,即采用轮询的方式,效率极低;
  3. 为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,大小不可改变(除非重新编译内核),即上面提到的FD_MAXSIZE

poll

概述

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。

分析

poll中描述fd的集合从fd_set转换为pollfd

1
2
3
4
5
6
struct pollfd
{
int fd;
short events;
short revents;
}

函数原型:

1
int poll(pollfd *fds, nfds_t nfds, int timeout);
  • 参数解析
    • struct pollfd *fds: 是一个struct pollfd类型的数组,用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;一个pollfd结构体表示一个被监视的文件描述符,通过传递fds指示poll监视多个文件描述符。其中,结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域,结构体的revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域;
    • nfds_t nfds: 记录数组fds中描述符的总数量;
  • 返回值
    • int: 函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;

运行机制

本质上pollselect没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

问题

  1. 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义;
  2. poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd;

epoll

概述

epoll模型修改主动轮询为被动通知,当有事件发生时,被动接收通知。

相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

分析

linux中提供的epoll函数如下:

1
2
3
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);
  • epoll_create: 创建一个epoll句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1;
  • epoll_clt: 注册要监听的事件类型。四个参数解释如下:
    • int epfd: epoll句柄;
    • int op: 操作:
      • EPOLL_CTL_ADD: 注册新的fdepfd中;
      • EPOLL_CTL_MOD: 修改已注册的fd的监听事件;
      • EPOLL_CTL_DEL: 从epfd中删除一个fd
    • int fd: 监听的描述符;
    • struct epoll_event *event: 要监听的事件;
  • epoll_wait: 等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。四个参数解释如下:
    • int epfd: epoll句柄;
    • struct epoll_event events: 表示从内核得到的就绪事件集合;
    • int maxevents: 告诉内核events的大小;
    • int timeout: 表示等待的超时事件;

而对于上面的epoll_event结构体,其定义如下;

1
2
3
4
5
6
7
8
9
10
11
12
13
struct epoll_event
{
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

typedef union epoll_data
{
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;

运行机制

epoll模型注册套接字后,主程序可做其他事情,当事件发生时,接收到通知后再去处理。

可理解为event pollepoll会把哪个流发生哪种I/O事件通知我们。所以epoll是事件驱动(每个事件关联fd),此时我们对这些流的操作都是有意义的。复杂度也降到O(1)

此外,epoll除了提供select/poll那种IO事件的水平触发(Level Triggered, LT)外,还提供了边缘触发(Edge Triggered, ET),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait的调用,提高应用程序效率。

  • 水平触发LT: 默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件;
  • 边缘触发ET: 当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。

ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。

优点

  1. 没有最大并发连接的限制,能打开的fd的上限远大于1024(1G的内存上能监听约10万个端口);
  2. 效率提升,不是轮询,不会随fd数目增加而效率下降。只有活跃可用的fd才会调用callback函数 即epoll最大优点在于它只关心“活跃”连接,而跟连接总数无关;因此实际网络环境中,Epoll效率远高于select、poll
  3. 内存拷贝,利用mmap文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销;

总结

select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 红黑树(RB-Tree)
效率 O(n) O(n) O(1)
最大连接数 1024(x86)/2048(x86) 无上限 无上限
fd拷贝 每次调用都要将fd从用户态拷贝到内核态 每次调用都要将fd从用户态拷贝到内核态 仅在调用epoll_clt时将fd拷贝进内核态并保存

epollLinux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超selectpoll。但是,select低效也是相对的,视情况而定,也可通过良好的设计改善。