- select函数:
int select(
int nfds, // 监控的文件描述符集里最大文件描述符加1
fd_set *readfds, // 监控有读数据到达文件描述符集合,引用类型的参数
fd_set *writefds, // 监控写数据到达文件描述符集合,引用类型的参数
fd_set *exceptfds, // 监控异常发生达文件描述符集合,引用类型的参数
struct timeval *timeout); // 定时阻塞监控时间
select的执行流程:
如上图,假设进程A同时要监听socket文件描述符3、4、5,如果这三个连接都没有数据到达时,则进程A会让出CPU,进入阻塞状态,同时会将进程A的文件描述符和被唤醒时用到的回调函数组成等待队列加入到socket3、4、5的进程等待队列中。注意,这是select调用时,被监控的文件描述符集合(readfds/writefds/exceptfds)会从用户空间拷贝到内核空间。
当网卡接收网线传来的数据,经过DMA传输,IO通路选择等处理后,将收到的数据写入到内存,网卡将接收到的网络数据写入内存后,网卡向CPU发出一个中断信号,CPU捕获这个信号后,执行相应的中断处理程序,中断处理程序主要做了两件事:
1、将网络数据写入到对应socket的数据接收队列里;
2、唤醒队列中的等待进程A,重新将进程A放入CPU的运行队列中。
假设socket3、5有数据到达网卡(注意此时select调用结束时,被监控的文件描述符集合会从内核空间拷贝到用户空间,全量拷贝。),则执行以下流程:
由此可见,select有以下缺点:
1、性能开销大:①调用select时会陷入内核,这时需要将被监听的文件描述符从用户空间拷贝到内核空间;select执行完毕后,还需要将文件描述符从内核空间拷贝到用户空间,高并发场景下这样的拷贝会消耗极大资源(epoll优化为不拷贝);②进程被唤醒后,不知道哪些连接已经就绪(即收到数据),需要遍历传递出来的所有文件描述符的每一位,不管他们是否就绪(epoll优化为异步事件通知);③select只返回就绪文件的个数,具体哪个文件可读还需要遍历(epoll优化为只返回就绪的文件描述符,无需做无效的遍历)
2、同时能监控的文件描述符太少,受限于sizeof(fd_set)大小,在编译内核时就确定了且无法更改。一般32位操作系统是1024,64位操作系统是2048
- epoll相关函数:
int epoll_create(int size); // 创建一个 eventpoll 内核对象
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 将连接到socket对象添加到 eventpoll 对象上,epoll_event是要监听的事件
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout); // 等待连接 socket 的数据是否到达
1、epoll_create(int size):
创建一个struct evenpoll内核对象,evenpoll对象的内部结构如下:
evenpoll主要包含三个字段:
struct eventpoll {
//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
struct list_head rdllist;
//红黑树,管理用户进程下添加进来的所有 socket 连接
struct rb_root rbr;
......
}
①wq:等待队列链表,如果当前进程没有数据需要处理,会把当前进程描述符和回调函数default_wake_function构造一个等待队列项,放入当前wq队列,软中断数据就绪的时候,会通过wq来找到阻塞在epoll对象上的用户进程;
②rbr:一颗红黑树,管理用户进程下添加进来的所有socket连接;
③rdlist:就绪的文件描述符链表。当有socket的连接数据就绪时,内核会把就绪的连接放到rdlist链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用取遍历整颗树。
2、epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)添加socket:
epoll_ctl函数主要把服务端和客户端建立的socket连接注册到eventpoll里,会做三件事:
①创建要给epitem对象,主要包含两个字段:socket fd,连接的文件描述符;所属的eventpoll对象的指针;
struct epitem {
//红黑树节点
struct rb_node rbn;
//socket文件描述符信息
struct epoll_filefd ffd;
//所归属的 eventpoll 对象
struct eventpoll *ep;
//等待队列
struct list_head pwqlist;
}
②将一个数据到达时用到的回调函数添加到socket的进程等待队列中,其回调函数是ep_poll_callback
③将epitem插入到epoll对象的红黑树里;
完事之后的数据结构如下:
3、epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
epoll_wait首先会检查eventpoll对象的就绪链表rdlist上是否有数据到达,如果没有就把当前的进程描述符添加到eventpoll的等待队列里,然后把自己阻塞掉就完事。
- epoll处理到达数据
前面epoll_ctl函数执行时,内核为每一个socket上都添加了一个等待队列。在epoll_wait运行完的时候,又在eventpoll对象上添加了等待队列元素。目前结构图如下:
数据到达的处理流程如下:
①socket的数据接收队列有数据到达时,会通过进程等待队列的回调函数ep_poll_callback唤醒红黑树的节点epitem
②ep_poll_callback函数将有数据到达的epitem添加到eventpoll对象的就绪队列rdlist中
③ep_poll_callback函数检查eventpoll对象的进程等待队列上是否有等待项,如果有,通过default_wake_function唤醒这个进程,进行数据的处理
④当进程醒来后,继续从epoll_wait时暂停的代码继续执行,把rdlist中就绪的事件返回给用户进程,让用户进程调用recv把已经到达内核socket等待队列的数据拷贝到用户空间使用
- 总结
在epoll相关的函数里,内核运行环境分为两部分:①用户进程内核态。进程调用epoll_wait函数时,会将进程陷入内核态来执行,这部分代码负责查看接收队列,以及负责把当前进程阻塞掉,让出CPU。②硬软中断上下文:在这些组件中将数据包从网卡接收过来进行处理,然后放到socket的接收队列。对于epoll来说,再找到socket关联的epitem,再把它添加到epoll对象的就绪链表rdlist中,这个时候再捎带检查一下epoll上是否有被阻塞的线程,如果有则唤醒之。
另外,我们可以看到wake up回调函数机制被频繁使用:
一是阻塞IO中数据到达socket的等待队列时,通过回调函数唤醒进程;
二是epoll数据到达socket数据接收队列时,通过回调函数ep_poll_callback找到eventpoll中红黑树的epitem节点,将其加入到就绪队列rdlist
三是通过回调函数default_wake_function唤醒用户进程,并将rdlist传递给用户进程,让用户进程读取数据。从中可知,这种回调机制能够定向准确的通知程序要处理的事件,而不需要每次都循环遍历查找数据是否到达,以及该由哪个进程处理,提高了程序效率。
在实践中,只要活足够多,epoll_wait根本就不会让进程阻塞,用户进程会一直干活,知道epoll_wait实在没活可干,参会主动让出CPU。这就是epoll高效的地方。
参考:https://cloud.tencent.com/developer/article/2188691
https://cloud.tencent.com/developer/article/1964472