来源 https://cloud.tencent.com/developer/article/1005481
函数简介
select
函数原型如下:
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
fd_set是包含有文件描述符的集合,是一个位图,总共有n位。这意味这每次调用就会拷贝一次文件描述符集合到内核。
poll
函数原型如下:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的数组实现。传入他的长度,以及超时。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。
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);
epoll_create是用于创建epoll的句柄。size是建议内核预先分配的文件描述符数目,不会限制实际运行的数目。
epoll_ctl会控制epoll的句柄,我们可以向其中添加、删除、修改文件描述符。ctl与epoll_wait相分离的机制使得只用复制一次事件集合。
epoll_wait可以用于获得从内核得到的事件的集合。
socket 事件
在Linux 2.6内核事件中,设置了wakeup callback机制。当socket在等待事件发生时,由内核的socket睡眠队列管理。当socket事件发生时,内核会顺序遍历socket睡眠队列上的每个process,通知该进程事件发生。通知时,会依次调用该事件的回调函数。
最初的select
select最初只是一个朴素的实现,来尝试解决多个文件描述符的检查问题。
select做了什么?
select调用后,做了以下事情:
- 将参数传进来的文件描述符集合拷贝到内核空间
- 依次遍历文件描述符,查看是否有事件可读,如果可读就返回
- 没有可读的文件描述符,开始睡眠,等待内核socket事件发生
- 被唤醒,再次检查到底是哪个文件描述符发生了操作
select的问题?
- 每次调用socket都会拷贝一次到内核空间,性能低下
- 任意一个socket被唤醒都需要遍历所有的socket,浪费时间
select的改进?
- 被监控的fds集合限制为1024,1024太小了,我们希望能够有个比较大的可监控fds集合
- fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝
- 被监控的fds中某些有数据可读的时候,我们希望通知更加精细一点,就是我们希望能够从通知中得到有可读事件的fds列表,而不是需要遍历整个fds来收集。
鸡肋的poll
poll只解决了第一个问题。fds的大小是1024限制的问题。仅仅是传入的参数的接口变了而已。
成熟的epoll
epoll解决了第二个和第三个问题。
-
对于第二个问题
- 拆分函数调用,再细分函数调用
对于IO多路复用,我们会发现,每次调用select或poll都在重复地准备(集中处理)整个需要监控的fds集合。然而对于频繁调用的select或poll而言,fds集合的变化频率要低得多,我们没必要每次都重新准备(集中处理)整个fds集合。
于是,epoll引入了epoll_ctl系统调用,将高频调用的epoll_wait和低频的epoll_ctl隔离开。同时,epoll_ctl通过(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)三个操作来分散对需要监控的fds集合的修改,做到了有变化才变更,将select或poll高频、大块内存拷贝(集中处理)变成epoll_ctl的低频、小块内存的拷贝(分散处理),避免了大量的内存拷贝。
- 拆分函数调用,再细分函数调用
-
使用红黑树
另外,epoll通过epoll_ctl来对监控的fds集合来进行增、删、改,那么必须涉及到fd的快速查找问题,于是,一个低时间复杂度的增、删、改、查的数据结构来组织被监控的fds集合是必不可少的了。在linux 2.6.8之前的内核,epoll使用hash来组织fds集合,于是在创建epoll fd的时候,epoll需要初始化hash的大小。于是epoll_create(int size)有一个参数size,以便内核根据size的大小来分配hash的大小。在linux 2.6.8以后的内核中,epoll使用红黑树来组织监控的fds集合,于是epoll_create(int size)的参数size实际上已经没有意义了。 -
对于第三个问题
- 使用回调机制
通过上面的socket的睡眠队列唤醒逻辑我们知道,socket唤醒睡眠在其睡眠队列的wait_entry(process)的时候会调用wait_entry的回调函数callback,并且,我们可以在callback中做任何事情。为了做到只遍历就绪的fd,我们需要有个地方来组织那些已经就绪的fd。为此,epoll引入了一个中间层,一个双向链表(ready_list),一个单独的睡眠队列(single_epoll_wait_list),并且,与select或poll不同的是,epoll的process不需要同时插入到多路复用的socket集合的所有睡眠队列中,相反process只是插入到中间层的epoll的单独睡眠队列中,process睡眠在epoll的单独队列上,等待事件的发生。同时,引入一个中间的wait_entry_sk,它与某个socket sk密切相关,wait_entry_sk睡眠在sk的睡眠队列上,其callback函数逻辑是将当前sk排入到epoll的ready_list中,并唤醒epoll的single_epoll_wait_list。而single_epoll_wait_list上睡眠的process的回调函数就明朗了:遍历ready_list上的所有sk,挨个调用sk的poll函数收集事件,然后唤醒process从epoll_wait返回。
- 使用回调机制
最后,边缘触发与水平触发
说到Epoll就不能不说说Epoll事件的两种模式了,下面是两个模式的基本概念
-
Edge Triggered (ET) 边沿触发
.socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
.socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件
仅在缓冲区状态变化时触发事件,比如数据缓冲去从无到有的时候(不可读-可读) -
Level Triggered (LT) 水平触发
.socket接收缓冲区不为空,有数据可读,则读事件一直触发
.socket发送缓冲区不满可以继续写入数据,则写事件一直触发
符合思维习惯,epoll_wait返回的事件就是socket的状态
网友评论