由于篇幅较长,故一共分为三篇博客
深入学习IO多路复用 select/poll/epoll 实现原理 - 第一篇
深入学习IO多路复用 select/poll/epoll 实现原理 - 第二篇
深入学习IO多路复用 select/poll/epoll 实现原理 - 第三篇
3. epoll 实现原理
epoll 是对 select 和 poll 的改进,解决了“性能开销大”和“文件描述符数量少”这两个缺点,是性能最高的多路复用实现方式,能支持的并发量也是最大。
epoll 的特点是:
1)使用红黑树存储一份文件描述符集合,每个文件描述符只在添加时传入一次,无需用户每次都重新传入;—— 解决了 select 中 fd_set 重复拷贝到内核的问题
2)通过异步 IO 事件找到就绪的文件描述符,而不是通过轮询的方式;
3)使用队列存储就绪的文件描述符,且会按需返回就绪的文件描述符,无须再次遍历;
epoll 的基本用法是:
int main(void)
{
struct epoll_event events[5];
int epfd = epoll_create(10); // 创建一个 epoll 对象
......
for(i = 0; i < 5; i++)
{
static struct epoll_event ev;
.....
ev.data.fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); // 向 epoll 对象中添加要管理的连接
}
while(1)
{
nfds = epoll_wait(epfd, events, 5, 10000); // 等待其管理的连接上的 IO 事件
for(i=0; i<nfds; i++)
{
......
read(events[i].data.fd, buff, MAXBUF)
}
}
主要涉及到三个函数:
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 的数据是否到达
epoll_create
epoll_create 函数会创建一个 struct eventpoll 的内核对象,类似 socket,把它关联到当前进程的已打开文件列表中。
eventpoll 主要包含三个字段:
struct eventpoll {
wait_queue_head_t wq; // 等待队列链表,存放阻塞的进程
struct list_head rdllist; // 数据就绪的文件描述符都会放到这里
struct rb_root rbr; // 红黑树,管理用户进程下添加进来的所有 socket 连接
......
}
wq:等待队列,如果当前进程没有数据需要处理,会把当前进程描述符和回调函数 default_wake_func 构造成一个等待队列项,放入当前 wq 等待队列,软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
rdllist:就绪的描述符的链表。当有的连接数据就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪的连接,而不用去遍历整棵树。
rbr:一棵红黑树,管理用户进程下添加进来的所有 socket 连接。
eventpoll 的结构如图 2.3 所示:
![](https://i-blog.csdnimg.cn/blog_migrate/cc9c8afb2ce63ea30400e6fff687a001.jpeg)
epoll_ctl
epoll_ctl 函数主要负责把服务端和客户端建立的 socket 连接注册到 eventpoll 对象里,会做三件事:
1)创建一个 epitem 对象,主要包含两个字段,分别存放 socket fd 即连接的文件描述符和所属的 eventpoll 对象的指针;
2)将数据到达时用到的回调函数添加到 socket 的进程等待队列中,注意,跟第 1.1 节的阻塞 IO 模式不同的是,这里添加的 socket 的进程等待队列结构中,只有回调函数,没有设置进程描述符,因为在 epoll 中,进程是放在 eventpoll 的等待队列中,等待被 epoll_wait 函数唤醒,而不是放在 socket 的进程等待队列中;
3)将第 1)步创建的 epitem 对象插入红黑树;
![](https://i-blog.csdnimg.cn/blog_migrate/a511cc9f02ccead2e6890a4baf2be71e.jpeg)
图2.4 epoll_ctl执行结果
epoll_wait
epoll_wait 函数的动作比较简单,检查 eventpoll 对象的就绪的连接 rdllist 上是否有数据到达。如果没有就把当前的进程描述符添加到一个等待队列项,然后加入到 eventpoll 的进程等待队列里,然后阻塞当前进程,等待数据到达时通过回调函数被唤醒。
当 eventpoll 监控的连接上有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:
1)socket 的数据接收队列有数据到达,会通过进程等待队列的回调函数 ep_poll_callback 唤醒红黑树中的节点 epitem;
2)ep_poll_callback 函数将有数据到达的 epitem 添加到 eventpoll 对象的就绪队列 rdllist 中;
3)ep_poll_callback 函数检查 eventpoll 对象的进程等待队列上是否有等待项,通过回调函数 default_wake_func 唤醒这个进程,进行数据的处理;
4)当进程醒来后,继续从 epoll_wait 时暂停的代码继续执行,把 rdlist 中就绪的事件返回给用户进程,让用户进程调用 recv 把已经到达内核 socket 等待队列的数据拷贝到用户空间使用。
![](https://i-blog.csdnimg.cn/blog_migrate/23b4132cf2b1e3e679ad83dccf17151d.jpeg)
图2.5 epoll_wait 在有数据到达 socket 时、依次通过两个回调函数唤醒进程
3. 总结
从阻塞 IO 到 epoll 的实现中,我们可以看到 wake up 回调函数机制被频繁的使用
至少有三处地方:
一是阻塞 IO 中数据到达 socket 的等待队列时,通过回调函数唤醒进程
二是 epoll 中数据到达 socket 的等待队列时,通过回调函数 ep_poll_callback 找到 eventpoll 中红黑树的 epitem 节点,并将其加入就绪列队 rdllist
三是通过回调函数 default_wake_func 唤醒用户进程 ,并将 rdllist 传递给用户进程,让用户进程准确读取数据
从中可知,这种回调机制能够定向准确地通知程序要处理的事件,而不需要每次都循环遍历检查数据是否到达以及数据该由哪个进程处理,提高了程序效率,在日常的业务开发中,我们也可以借鉴下这一机制。