Linux epoll完全图解,彻底搞懂epoll机制

377 阅读8分钟

1.从内核看epoll机制

select和poll虽然能够实现IO复用的功能,但是由于设计的缺陷,select和poll无法处理海量的网络连接,并且随着网络连接数量的增加,select和poll效率越来越低。

此时急需一种更为高效的IO复用机制解决海量并发请求问题,epoll机制就是为了解决该问题而诞生的。要理解epoll机制并不容易,很多同学一直学不好epoll,一个很重要的原因是不理解底层实现原理,我们从内核的角度观察epoll具体做了哪些事情,有了这个基础再去学习epoll编程,学习过程将会变得非常简单。

image.png 图1 epoll内核实现原理

如图1所示,epoll机制分为两个部分:用户态部分和内核态部分。

用户态部分通过3个系统调用:epoll_create,epoll_ctl,epoll_wait和内核进行交互。内核态部分实现比较复杂,我们将围绕struct eventpoll内核对象来讲解。struct eventpoll对象是epoll机制实现的关键数据结构,包含三个重要成员:rbr(红黑树),rdlist(就绪队列),wq(等待队列)。

  • 红黑树:用于记录用户程序注册的epoll事件。

  • 等待队列:epoll线程休眠后,用于唤醒epoll线程。

  • 就绪队列:socket接收和发送数据后,就绪队列会记录socket读事件和写事件。

用户程序调用epoll_create函数后,会在内核创建struct eventpoll对象,同时会返回一个文件描述符给用户,该文件描述符用于查询进程文件表,找到对应的文件,再通过文件找到struct eventpoll对象。

用户程序通过epoll_ctl函数添加,修改,删除socket事件,注册成功的socket事件会插入红黑树。socket事件添加成功后,epoll才能监听socket读写事件。

如果epoll就绪队列有就绪事件,用户程序调用epoll_wait函数会成功获取到就绪事件。如果没有就绪事件,则epoll线程陷入休眠。

当socket接收到数据后,通过socket等待队列可以唤醒休眠的epoll线程,并将socket封装成epoll就绪事件插入就绪队列。此时epoll线程已经被唤醒,epoll线程可以将就绪事件拷贝至用户程序。

以上就是epoll内核工作原理,该部分建议反复阅读。

2.epoll编程实战

有了前面epoll内核工作原理的分析,我们对epoll有了更深入的理解。学习epoll编程需要熟练掌握epoll 3个接口:epoll_create、epoll_ctl、epoll_wait。

2.1 编程接口

(1)epoll_create函数

epoll_create函数是一个系统调用,用于在内核创建struct eventpoll实例。

#include <sys/epoll.h>
int epoll_create(int size);

参数:size参数并没有实际意义,但一定要大于0。

返回值:成功返回epoll文件描述符;失败返回-1,并设置errno。

我们看一下内核源码实现:

SYSCALL_DEFINE1(epoll_create, int, size)
{
    if (size <= 0return -EINVAL; //size小于等于0,返回错误
    return do_epoll_create(0); //传入参数没用到size
}

(2)epoll_ctl函数

epoll_ctl函数用于向 epoll 实例中添加、修改或删除文件描述符(通常代表一个网络连接或者文件),并设置这些文件描述符感兴趣的事件类型,如可读、可写或者有异常发生。

如图2所示,epoll_ctl函数添加socket事件时,主要做了两件事:

  • 插入socket事件节点至红黑树。

  • 创建一个等待队列项插入socket等待队列,用于socket接收数据时唤醒epoll线程。

image.png 图 2 epoll_ctl工作原理

epoll_ctl函数原型:

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

‌    epfd‌:指向由epoll_create 创建的 epoll 实例的文件描述符。

‌    op‌:表示要对目标文件描述符执行的操作,可以是以下几个值之一:

  • EPOLL_CTL_ADD:向 epoll 实例中添加一个新的文件描述符。

  • EPOLL_CTL_MOD:修改已存在文件描述符的事件类型。

  • EPOLL_CTL_DEL:从 epoll 实例中删除一个文件描述符。

‌    fd‌:需要操作的目标文件描述符。

event‌:指向struct epoll_event结构的指针,该结构指定了需要监听的事件类型。

返回值:成功返回0;失败返回-1,并设置errno。

struct epoll_event结构体定义如下:

struct epoll_event {
    uint32_t events;
    epoll_data_t data;
};

**‌events‌:指定要监听的事件类型,**常见事件类型见表1。

表 1  epoll事件表

image.png

data‌:用户自定义的数据,通常用于存储与文件描述符相关的上下文信息,获取就绪事件成功后,事件数组会记录data数据。

struct epoll_data结构体定义如下:

typedefunion epoll_data { 
    void *ptr; 
    int fd;           //设置socket文件描述符
    uint32_t u32; 
    uint64_t u64;
} epoll_data_t;

(3)epoll_wait函数

epoll_wait函数用于等待在 epoll 实例上注册的文件描述符上发生的事件。这个函数会阻塞调用线程,直到有事件发生或超时。

image.png 图 3 epoll_wait工作原理

如图3所示,用户程序调用epoll_wait后,内核循环检测就绪队列是否有就绪事件,如果有就绪事件,将就绪事件返回给用户,否则继续往下执行,判断epoll是否超时,超时返回0,如果没有超时则将epoll线程挂起,epoll线程陷入休眠状态,同时插入一个epoll等待队列项。

当socket接收到数据后,会通过socket等待队列回调函数去检测epoll等待队列项,并将epoll线程唤醒,epoll线程被唤醒成功后,epoll线程再次查询就绪队列,此时就能成功返回socket事件。

epoll_wait函数原型:

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
 int maxevents, int timeout);

参数:

epfd:epoll文件描述符。

events:epoll事件数组。

maxevents:指定events 数组的大小,即可以存储的最大事件数。

timeout:超时时间。

  • -1:表示无限等待,直到有事件发生。

  • 0:表示立即返回,不等待任何事件。

  • 正数:表示等待的最大时间(毫秒)。

返回值:小于0表示出错;等于0表示超时;大于0表示获取事件成功,返回就绪事件个数。

3.epoll编程流程

前面我们已经学会了使用epoll 3个接口,接下来我们要实现一个完整epoll编程示例,如图4所示,该流程是一个epoll编程流程,我们按照这样一个流程去编写代码,思路会很清晰,不容易出错。

image.png 图 4 epoll编程流程图

epoll示例代码如下,为了节省篇幅和易于理解,部分非关键代码已省略。

intmain(int argc, char *argv[]){
    structepoll_eventev, events[MAX_EVENTS];
    int sock_fd, ret0;
    int efd = epoll_create(10); //创建epoll实例
    ev.data.fd = sock_fd;
    ev.events = EPOLLIN;
    //注册监听套接字事件
    epoll_ctl(efd, EPOLL_CTL_ADD, sock_fd, &ev);
    while (1) {
        //超时1000毫秒,获取就绪事件
        int nfds = epoll_wait(efd, events, MAX_EVENTS, 1000);
        if (nfds == -1) return-1; //获取失败退出
        elseif (nfds == 0) continue; //超时,继续下一轮事件获取
        for (int i0; i < nfds; i++) {//轮询就绪事件数组
            int fd = events[i].data.fd;
            if (fd == sock_fd) { //监听套接字
                new_fd = accept(sock_fd, (struct sockaddr *)&peer, &addrlen);
                setnonblocking(new_fd); //设置新套接字为非阻塞模式
                ev.data.fd = new_fd;
                ev.events = EPOLLIN|EPOLLET;
                //添加新套接字
                epoll_ctl(efd, EPOLL_CTL_ADD, new_fd, &ev);
            } else { //业务套接字
                if (events[i].events & EPOLLIN) { //EPOLLIN事件
                    recv(fd, recv_buf, len, 0); //业务套接字接收数据
                } 
            }
        }
    }
    return0;
}

4.epoll常见问题?

(1)ET模式和LT模式区别?

ET模式称为边缘触发,LT模式称为水平触发。

添加socket事件时如果设置为ET模式,当socket接收数据后,epoll就绪队列只会插入一次socket就绪事件,epoll_wait检测到socket读事件后,必须一次性把socket缓冲区数据全部读完,否则数据可能丢失。

如果设置为LT模式,此次调用epoll_wait没有读socket缓冲区,下一次调用epoll_wait依然能够检测到socket就绪事件,直到socket缓冲区数据被读完。

我们通过内核源码观察二者区别。

.......
if(!(epi->event.events & EPOLLET)) { // LT模式
    //将取出来的就绪事件,继续插入就绪队列
    list_add_tail(&epi->rdllink, &ep->rdllist); 
}

(2)epoll高效的秘密?

epoll之所以高效,主要有以下原因:

  • epoll等待队列机制,当就绪队列没有socket事件时主动让出CPU,阻塞进程,提高CPU利用率,就绪队列收到socket事件后,唤醒epoll线程处理。

  • 红黑树提高epoll事件增加,删除,修改效率。

  • 任务越多,进程出让CPU概率越小,epoll线程工作效率越高,所以epoll非常适合高并发场景。

(3)epoll为阻塞模式是否影响性能?

当epoll_wait未检测到epoll事件时会出让CPU并阻塞进程,这种阻塞是非常有必要的,如果不及时出让CPU会浪费CPU资源,导致其他任务无法抢占CPU,只要socket接收到数据后,及时唤醒epoll进程,就不会影响epoll性能。

(4)socket设置成阻塞和非阻塞?

socket采用非阻塞方式。socket设置成阻塞模式会存在以下几个问题:

  • IO复用通常是一个进程处理多个网络连接,如果socket为阻塞模式,那么其中一个socket阻塞会导致进程阻塞,其他socket也无法读写数据。

  • 阻塞的本质是进程状态和上下文切换,频繁的阻塞会把让CPU一直处于上下文切换的状态中,浪费CPU资源。