上一篇文章我们已经介绍过了集中常用的 IO 模型了。IO 多路复用模型是我们用的最多的一种 IO 模型。select,poll,epoll 都是 IO 多路复用的机制。I/O 多路复用就是通过一种机制,一个进程可以监视多个描述符。一旦某个描述符就绪(读或者写就绪),就通知程序进行相应的读写操作。
select,poll,epoll 本质上都是同步 I/O,因为它们都需要在读写事件就绪后自己负责读写,也就是说,这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 实现会负责把数据从内核拷贝到用户空间。
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分为三类,分别是 writefds,readfds 和 exceptfds。调用后 select 函数会阻塞,直到有文件描述符就绪(有数据可读,可写或者有 except),或者超时(timeout 指定等待时间,如果立即返回设置为 null 即可),函数返回。当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。
select 目前几乎在所有的平台上支持,其良好的跨平台也成为它的有点。select 的一个缺点在于单个进程能够监视的文件描述符最大为 1024。
select 本质上是通过设置或者检查存放 fd 标志位的数据结构来进行下一步处理。这样所带来的缺点是:
- 单个进程可监视的 fd 数量被限制,即能监听端口的大小有限。32 位是 1024,64 位是 2048。
- 对 socket 进行扫描时都是线性扫描,即采用轮询的方法,效率比较低。不管是不是活跃的,都选哟遍历一遍。
- 需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同于 select 使用三个位图来表示 fdset 的方式,poll 使用也给 pollfd 的指针实现。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select “参数-值”传递的方式。同时,pollfd 并没有最大数量限制(但是数量过大后性能也是会下降)。和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。
select 和 poll 都需要在返回后,通过遍历文件描述符来获取已经就绪的 socket。事实上,同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
poll 本质上和 select 并没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前线程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。
- poll 没有连接数限制,原因它是基于链表来存储的。
- 也是需要轮询来查询 fd 的状态,fd 越多,效率越差。
epoll
epoll 是在 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
epoll 操作过程
一个 epoll 操作过程需要三个接口,分别如下:
int epoll_create(int size);//创建一个epoll的句柄,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);
int epoll_create(int size)
创建一个 epoll 的句柄,size 用来告诉内核这个监听的数目一共多大,这个参数不同于 select() 中的第一个参数,给出最大监听的 fd+1 的值,参数 size 并不是限制了 epoll 所能监听描述符的最大个数,只是对内核初始分配内部数据结构的一个建议。
创建好 epoll 句柄后,它就会占用一个 fd 值,使用完 epoll 后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
函数是对指定描述符 fd 执行 op 操作。
- 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队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
等待 epfd 上的 io 事件,最多返回 maxevents 个事件。
参数 events 用来从内核得到事件的集合,maxevents 告之内核这个 events 有多大。该函数返回需要处理的事件数目,如返回 0 表示已超时。
工作模式
epoll 对文件描述符的操作有两种模式:LT(level trigger)和 ET(edge trigger)。
LT 模式(默认):当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。通过这种模式,系统不会充斥大量你不关心的就绪文件描述符。
ET模式(告诉):当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 都读光。
- epoll 没有最大并发连接的限制,能打开的 FD 的上线远大于 1024。
- 效率提升,不是轮询的方式,不会随着 FD 数目的增加效率下降,只有活跃可用的 FD 才会调用 callback 函数。
- epoll 最大的有点在于它只管你活跃的连接,而跟连接总数无关。因此在实际的网络环境中,epoll 的效率会远高于 select 和 poll。
- 内存拷贝,利用 mmap() 文件映射内存加速与内核空间的消息传递,即 epoll 使用 mmap 减少复制开销。
select,poll 和 epoll 的区别总结
-
支持一个进程能打开的最大连接数。
select 是 1024,poll 没有最大限制,底层是基于链表的。epoll 有上限,但hi很大,1G 内存的及其可以打开 10 万左右的连接,2G 内存可以打开 20 万左右的连接。
-
FD 剧增后带来的 IO 效率问题。
select 和 poll 每次调用都会对 FD 进行线性遍历,所以随着 FD 增加遍历的速度会越慢。
因为 epoll 内核中的实现是根据每个 fd 上的 callback 函数来上西安的,只有活跃的 socket 才会主动 callback,所以性能和总的监视的文件描述符无关。
-
消息传递方式。
select 和 poll 内核需要将消息传递到用户空间,都需要内核拷贝动作。epoll 通过内核和用户共享一块内存来实现。
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现 | 数组 | 链表 | 哈希表 |
IO效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) |
最大连接数 | 1024(x86)或2048(x64) | 无上限 | 无上限 |
fd拷贝 | 每次调用select,都需要把fd集合从用户态拷贝到内核态 | 每次调用poll,都需要把fd集合从用户态拷贝到内核态 | 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝 |
总结
表面上看 epoll 的性能最好,但是在连接数少并且都十分活跃的情况下,select 和 poll 的性能可能比 epoll 好,毕竟 epoll 的通知机制需要很多函数回调。
select 低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可以通过良好的设计改善。