Linux - epoll实现机制详解
2023年03月21日 14:03:31 [原创]
Epoll 作为 Linux 下高性能网络服务器的必备技术至关重要,Nginx、Redis、Skynet 和大部分游戏服务器都使用到这一多路复用技术,Epoll 很重要,但是 Epoll 与 Select 的区别是什么呢?Epoll 高效的原因是什么? 本文将一探究竟。

fd、file、socket、sock关系图
1、fd、file、socket、sock关系
了解epoll机制前我们先熟悉下应用程序是怎么和内核协议栈关联的,这里涉及到几个结构:
fd:文件描述符,本质是一个数组下标,就是个数字,同时包含一个指向对应file结构体的指针,应用程序成功打开一个文件后会返回一个文件描述符,后续应用程序都是直接通过文件描述符来读写文件。
file:file结构体在内核中用于表示一个打开的文件,里面包含了各种打开文件相关联的信息,比如文件操作方法集ops、path、inode、权限标志、引用计数、文件锁等信息。
socket:分为监听套接字和连接套接字,监听套接字用于监听绑定端口上的请求,一个应用程序正常只有一个监听套接字,如果监听套接字接收队列上收到请求就调用accept进行后续处理,连接套接字是应用程序和内核协议栈的一个接口,每个连接都有一个单独的连接套接字,用于关联file结构体和sock结构。
struct socket {
socket_state state;
unsigned long flags;
struct socket_wq __rcu *wq;
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};
sock:内核真正表示连接信息的结构,包含了类型、协议和各种操作函数,tcp的具体sock是tcp_sock,如下图通过sock ->inet_sock -> inet_connection_sock -> tcp_sock封装而来。

内核sock结构
应用程序通过fd->file->socket->sock链路从对应的队列中读写数据,数据到达>sk_receive_queue队列后,会调用注册的回调函数如sk_data_ready,回调函数检测当前sock队列可读后会唤醒该socket等待队列上的进程进行后续的收包处理,因为每个sock都有自己的等待队列,所以传统的处理机制中,每个sock的等待队列中都需要应用分配一个单独的进程来阻塞读取数据,也就是每个socket都需要分配一个单独的进程来读写数据,所以效率是很低的。
2、epoll机制

epoll机制结构图
epoll机制:epoll是一种目前使用最广泛的多路复用机制,在epoll机制下,epoll其实充当的是内核协议栈和应用程序的中介,socket的接收队列收到数据后,对应的回调函数是直接和epoll交互的而不是应用程序,所以应用程序不需要每个socket都单独分配一个进程来读写数据,且只有数据准备好后epoll才会通知应用来读取数据,这期间应用程序可以去干其他的事情。所谓多路复用就是一个进程可以同时处理多个socket连接上的请求,这样即使很少的进程也能处理大量的网络连接,在epoll机制里1个或者多个epoll_wait就可以处理所有的网络连接了,而不需要应用再为每个连接分配一个单独的进程来处理。
fd->file->eventpoll,这是epoll的访问链路,它自己实现了一个文件系统,有自己独立的一个fd,并关联到file结构体,通过file结构体指向了eventpoll,eventpoll代表了一个epoll实例。这个fd被写入应用程序进程的打开文件表里,所以应用程序可以通过epoll实例的fd来获取epoll返回的数据,这里返回的数据也就是需要读取的fd列表,然后应用程序再通过这个fd列表去读取对应各个fd对应sock队列里的数据。
eventpoll结构代表一个epoll实例
struct eventpoll {
/* 等待队列 */
wait_queue_head_t wq;
/* Wait queue used by file->poll() */
wait_queue_head_t poll_wait;
/* 就绪队列 */
struct list_head rdllist;
/* 红黑树 */
struct rb_root rbr;
/* 对应的文件 */
struct file *file;
/* used to optimize loop detection check */
int visited;
struct list_head visited_list_link;
};
1:wait_queue_head_t wq:等待队列,epoll_wait函数会自己阻塞在这个队列上,等待rdllist队列有可读事件发生。
2:struct list_head rdllist:就绪队列,当一个socket的队列上有可读事件后,其会被添加到该队列,等待这样应用进程只需要读取rdllist就能找到就绪socket,而不用去遍历整棵树。
3:struct rb_root_cached rbr:红黑树,用于管理epoll实例管理的socket集合,epoll会通过epoll_ctl函数把需要管理的socket都添加到该红黑树上面,由于是红黑树结构所以海量连接下的查找、插入和删除非常高效。
1:epoll_create:主要用于创建一个epoll实例。
2:epoll_ctl:用于添加一个epoll事件,也就是添加socket到其需要管理的集合中,这个集合使用一个红黑树结构表示。
3:epoll_wait:阻塞等待rdllist就绪队列上的可读事件,socket上有数据到达队列变的可读后,会通过epoll注册的回调函数将其添加到rdllist队列中,rdllist队列如果不为空,epoll_wait被唤醒后会直接读取并返回给应用,应用程序根据获取的fd列表读写对应的sock队列。
下面是一个epoll_wait获取可读事件并返回应用处理的示例:
for( ; ; )
{
nfds = epoll_wait(epfd,events,20,500);
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd) //有新的连接
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
ev.data.fd=connfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
}
else if( events[i].events&EPOLLIN ) //接收到数据,读socket
{
n = read(sockfd, line, MAXLINE)) < 0 //读
ev.data.ptr = md; //md为自定义类型,添加数据
ev.events=EPOLLOUT|EPOLLET;
//修改标识符,等待下一个循环时发送数据,异步处理的精髓
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
{
struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取数据
sockfd = md->fd;
send( sockfd, md->ptr, strlen((char*)md->ptr), 0 ); //发送数据
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
}
else
{
//其他的处理
}
}
}
我们来看下,当数据包接收完毕后,用户进程是如何被唤醒的。
当软中断将sk_buffer放到socket的接收队列上时,接着就会调用数据就绪函数回调指针sk_data_ready,这个函数指针在初始化的时候指向了sock_def_readble函数。
在sock_def_readable函数中会去获取socket->sock->sk_wq等待队列。在wake_up_common函数中从等待队列sk_wq中找出一个等待项wait_queue_t,回调注册在该等待项上的func回调函数(wait_queue_t->func),epoll机制中,这里的回调函数是ep_poll_callback。
由epoll_ctl 函数注册socket等待队列项,用wait_queue_t类型表示,wait_queue_t->func回调函数这里设置为了ep_poll_callback。ep_poll_callback中需要找到socket就绪的epitem(用来表示该socket的fd和该socket队列可读事件),将其放入epoll中的就绪队列中,而wait_queue_t无法关联到epitem。所以就出现了struct eppoll_entry结构体,它的作用就是关联socket等待队列中的等待项wait_queue_t和epitem。
struct eppoll_entry {
//指向关联的epitem
struct epitem *base;
// 关联监听socket中等待队列中的等待项 (private = null func = ep_poll_callback)
wait_queue_t wait;
// 监听socket中等待队列头指针
wait_queue_head_t *whead;
.........
};
该等待队列项被注册到socket的等待队列后,后续数据到达触发sk_data_ready回调函数后,回调函数会唤醒socket等待队列上的等待队列项并传递读写事件,这里sock的回调函数就是直接和epoll注册的等待队列项交互了而非用户进程。
1:epitem:红黑树节点对象,包含了socket对应的fd和epoll读写事件。fd用于表示是哪个socket,读写事件表示了事件的具体类型,比如可读or可写,这2者可以确定是操作哪个具体的socket和操作的具体类型。
struct epitem {
struct rb_node rbn; //红黑树节点
struct epoll_filefd ffd; //socket对应的fd
struct epoll_event event; //epoll读写事件
...
};
events读写事件宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
2:ep_poll_callback:
ep_poll_callback函数主要用于将就绪epoll事件节点添加至就绪队列rdllist,并唤醒eventpoll等待队列项,也就是执行epoll_wait。
LT模式:
LT模式又称水平触发,只要 socket 上有未读完的数据,就会一直产生 EPOLLIN 事件。比ET模式多执行了一个步骤,就是当epoll_wait获取完就绪队列epoll事件后,LT模式会再次将epoll事件添加到就绪队列,直到socket缓冲区数据清空为止。
LT模式下,epoll_wait获取完就绪队列epoll事件后,epoll事件会再次添加到就绪队列,所以只要sock队列可读则会一直触发可读事件,直到socket缓冲区数据清空为止。
ET模式:
ET模式又称边缘触发,socket 上每新来一次数据就会触发一次,如果上一次触发后,未将 socket 上的数据读完,也不会再触发,除非再新来一次数据。ET模式更加高效,Nginx使用的就是ET模式。
这里的触发是通过sock中的回调函数sk_data_ready来触发的,这个函数只有数据达到的时候才会触发,触发后不会再次触发所以应用程序必须一次把数据都取走,如果没有取完,则再次触发只能是下一次数据达到时候了。
ET模式下socket(不管是监听套接字还是连接套接字)必须设置为非阻塞IO,也就是文件描述符也设置为非阻塞,因为ET模式下应用需要一次把队列的数据全部读取完,所以必须要通过在一个循环一直读取,直到数据全部被读取,因为阻塞模式下读取完最后一个数据后由于队列为空就会一直阻塞在那里,导致循环无法正常结束,整个读取操作无法正常完成。当然也有其他的一些实现机制,比如读取的时候提前计算好长度等,但不推荐。
3、epoll编程流程

epoll处理流程图
这里listen后,不是直接调用accept阻塞等待接收新连接,而是通过epoll_create创建epoll实例,并通过epoll_ctl_add在监听套接字等待队列中添加一个socket等待队列项。
如果监听套接字接收队列上有新的请求到来,则会触发socket等待队列项中epoll注册的回调函数ep_poll_callback,来调用epoll_wait来获取可读事件并返回应用程序,应用程序再通过accept创建一个新连接,并同时调用epoll_add把该建的socket加入到epoll实例的监听集合(红黑树)中,后续新的数据到达该socket的接收队列后,则会通过epoll机制来通知应用程序来读取数据。
4、epoll优点总结
1:epoll 选择使用红黑树来管理文件描述符,实现高效的文件描述符管理。
红黑树的高效插入、删除和查找操作使得 epoll 能够快速地添加、删除和查找特定的文件描述符,从而有效地管理大量的连接。红黑树的有序性使得 epoll 可以快速地遍历树中的节点,找到就绪的文件描述符。红黑树的自平衡特性使得 epoll 能够在连接数量变化时保持高效的性能。
2:动态管理socket描述符,避免了海量的文件描述符集合在用户空间和内核空间来回复制。
select,poll每次调用时都需要传递全部的文件描述符集合,导致大量频繁的拷贝操作。
3:epoll仅会通知IO就绪的socket,避免了在用户空间遍历的开销。
select,poll只会在IO就绪的socket上打好标记,依然是全量返回,所以在用户空间还需要用户程序再一次遍历全量集合找出具体IO就绪的socket。
4:epoll通过在socket的等待队列上注册回调函数ep_poll_callback通知用户程序IO就绪的socket,仅在数据到达后才会触发,避免了内核中轮询的开销。
大部分情况下socket上并不总是IO活跃的,在面对海量连接的情况下,select,poll采用内核轮询的放回寺获取IO活跃 的socket,无疑是性能底下的核心原因。