Linux内核机制之epoll详解

目录

简介:

一、IO 多路复用介绍

1、select,poll,epoll 引入

2、select,poll,epoll 区别分析

3、epoll 原理

3.1 epoll 相关函数介绍

1)epoll_create

2)epoll_ctl

3)epoll_wait

3.2 epoll 高效的原理

1)epoll 高效原因一

2)epoll 高效原因二

3.3 epoll 的水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)

1)水平触发(LT)

2)边缘触发(ET)

3.4 哪些 fd 可以用 epoll 来管理?


简介:


在 Linux 系统之中有一个核心武器:epoll 池,在高并发的,高吞吐的 IO 系统中常常见到 epoll 的身影。

一、IO 多路复用介绍


        1 个程序就可以负责管理多个 fd 句柄,负责应对所有的业务方的 IO 请求。这种一对多的 IO 模式我们就叫做 IO 多路复用。

多路是指?多个业务方(句柄)并发下来的 IO 。

复用是指?复用一个处理程序。

1、select,poll,epoll 引入


        写个 for 循环,每次都尝试 IO 一下,读/写到了就处理,读/写不到就 sleep 下。这样我们不就实现了 1 对多的 IO 多路复用嘛。

问题:for 循环每次要定期 sleep 1s,这个会导致吞吐能力极差,因为很可能在刚好要 sleep 的时候,所有的 fd 都准备好 IO 数据,而这个时候却要硬生生的等待 1s,可想而知。。。不sleep又会导致CPU占用过高。

       我们再梳理下 IO 多路复用的需求和原理。IO 多路复用就是 1 个线程处理 多个 fd 的模式。我们的要求是:这个 “1” 就要尽可能的快,避免一切无效工作,要把所有的时间都用在处理句柄的 IO 上,不能有任何空转,sleep 的时间浪费。

         Linux内核提供 select,poll,epoll 工具实现IO多路复用。

2、select,poll,epoll 区别分析


        Linux  内核提供了 3 种工具 select,poll,epoll 实现IO多路复用

为什么有 3 种?

历史不断改进,矬 -> 较矬 -> 卧槽、高效 的演变而已。

这 3 种都能够管理 fd 的可读可写事件,在所有 fd 不可读不可写无所事事的时候,可以阻塞线程,切走 cpu 。fd 有情况的时候,都要线程能够要能被唤醒。

而这三种方式以 epoll 池的效率最高。为什么效率最高?其实无非就是 epoll 做的无用功最少,select 和 poll 或多或少都要多余的拷贝,需遍历fd ,所以效率自然就低了。

举个例子,以 select 和 epoll 来对比举例:池子里管理了 1024 个句柄,loop 线程被唤醒的时候,select 都是蒙的,都不知道这 1024 个 fd 里谁 IO 准备好了。这种情况怎么办?只能遍历这 1024 个 fd ,一个个测试。假如只有一个句柄准备好了,那相当于做了 1 千多倍的无效功。

epoll 则不同,从 epoll_wait 醒来的时候就能精确的拿到就绪的 fd 数组,不需要任何测试,拿到的就是要处理的。

3、epoll 原理


下面我们看一下 epoll 的使用和原理。

3.1 epoll 相关函数介绍


epoll 的使用非常简单,只有下面 3 个系统调用。

  • epoll_create:负责创建一个池子,一个监控和管理句柄 fd 的池子;
  • epoll_ctl:负责管理这个池子里的 fd 增、删、改;
  • epoll_wait:就是负责打盹的,让出 CPU 调度,但是只要有“事”,立马会从这里唤醒;

1)epoll_create
int epoll_create(int size);

功能:创建一个epoll句柄

参数:

        size:监听个数

返回值:epoll 句柄,如果为-1 的话表示创建失败
2)epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:负责管理epoll里的 fd 增、删、改;
参数:
  • epfd: 要操作的 epoll 句柄,也就是使用 epoll_create 函数创建的 epoll 句柄
  • op: 表示要对 epfd(epoll 句柄)进行的操作,可以设置为:

EPOLL_CTL_ADD

向epfd添加文件参数fd表示的描述符
EPOLL_CTL_MOD修改参数fd的event事件
EPOLL_CTL_DEL从epfd删除fd描述符
  • fd:要监视的文件描述符
  • event: 要监视的事件类型,为 epoll_event 结构体类型指针, epoll_event 结构体类型如下所示:
struct epoll_event {
    uint32_t events; /* epoll 事件 */
    epoll_data_t data; /* 用户数据 */
};

        结构体 epoll_event 的 events 成员变量表示要监视的事件,这些事件可以进行“或”操作,也就是说可以设置监视多个事件:

EPOLLIN

有数据可以读取

EPOLLOUT

可以写数据
EPOLLPRI

有紧急的数据需要读取

EPOLLERR

指定的文件描述符发生错

EPOLLHUP

指定的文件描述符挂起
EPOLLET

设置 epoll 为边沿触发,默认触发模式为水平触发

EPOLLONESHOT一次性的监视,当监视完成以后还需要再次监视某个 fd,那么就需要将fd 重新添加到 epoll 里面
返回值:0,成功; -1,失败,并且设置 errno 的值为相应的错误码
3)epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

功能:等待事件的发生

参数:
  • epfd: 要等待的 epoll
  • events: 指向 epoll_event 结构体的数组,当有事件发生的时候 Linux 内核会填写 events,调用者可以根据 events 判断发生了哪些事件
  • maxevents: events 数组大小,必须大于 0
  • timeout: 超时时间,单位为 ms
返回值:0,超时; -1,错误;其他值,准备就绪的文件描述符数量

    3.2 epoll 高效的原理


            Linux 下,epoll 的实现几乎没有做任何无效功,因此 epoll 作为高并发 IO 实现的秘密武器。 我们从使用的角度切入来一步步分析下。

    首先,epoll 的第一步是创建 epoll 池。这个使用 epoll_create 来做:

    示例:

    epollfd = epoll_create(1024);
    if (epollfd == -1) {
        perror("epoll_create");
        exit(EXIT_FAILURE);
    }

    这个池子对我们来说是黑盒,这个黑盒是用来装 fd 的,我们暂不纠结其中细节。我们拿到了一个 epollfd ,这个 epollfd 就能唯一代表这个 epoll 池。注意,这里又有一个细节:用户可以创建多个 epoll 池。

    然后,我们就要往这个 epoll 池里放 fd 了,这就要用到 epoll_ctl 了

    示例:

    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, 11, &ev) == -1) {
        perror("epoll_ctl: listen_sock");    
        exit(EXIT_FAILURE);
    }

    上面,我们就把句柄 11 放到这个池子里了,op(EPOLL_CTL_ADD)表明操作是增加、修改、删除,event 结构体可以指定监听事件类型,可读、可写。

    1)epoll 高效原因一

    添加 fd 进池子也就算了,如果是修改、删除呢?怎么做到快速?

    这里就涉及到你怎么管理 fd 的数据结构了。最常见的思路:用 list 链表,可以吗?功能上可以,但是性能上拉垮。list 的结构来管理元素,时间复杂度都太高 O(n),每次要一次次遍历链表才能找到位置。池子越大,性能会越慢。

    epoll怎么做到快速增、删、改的?

    红黑树。Linux 内核对于 epoll 池的内部实现就是用红黑树的结构体来管理这些注册进程来的句柄 fd。红黑树是一种平衡二叉树,时间复杂度为 O(log n),就算这个池子就算不断的增删改,也能保持非常稳定的查找性能。

    2)epoll 高效原因二

    怎么才能保证数据准备好之后,立马感知呢?

    epoll_ctl 这里会涉及到一点。秘密就是:回调的设置。在 epoll_ctl 的内部实现中,除了把句柄结构用红黑树管理,另一个核心步骤就是设置 poll 回调

    思考来了:poll 回调是什么?怎么设置?

    先说说 file_operations->poll 是什么?

    这个是定制监听事件的机制实现。通过 poll 机制让上层能直接告诉底层,我这个 fd 一旦读写就绪了,请底层硬件(比如网卡)回调的时候自动把这个 fd 相关的结构体放到指定队列中,并且唤醒操作系统。

    举个例子:网卡收发包其实走的异步流程,操作系统把数据丢到一个指定地点,网卡不断的从这个指定地点掏数据处理。请求响应通过中断回调来处理,中断一般拆分成两部分:硬中断和软中断。poll 函数就是把这个软中断回来的路上再加点料,只要读写事件触发的时候,就会立马通知到上层,采用这种事件通知的形式就能把浪费的时间窗就完全消失了。

    划重点:这个 poll 事件回调机制则是 epoll 池高效最核心原理。

    划重点:epoll 池管理的句柄只能是支持了 file_operations->poll 的文件 fd。换句话说,如果一个“文件”所在的文件系统没有实现 poll 接口,那么就用不了 epoll 机制。

    第二个问题:poll 怎么设置?

    在 epoll_ctl 下来的实现中,有一步是调用 vfs_poll 这个里面就会有个判断,如果 fd 所在的文件系统的 file_operations 实现了 poll ,那么就会直接调用,如果没有,那么就会报告响应的错误码。

    static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt){
        if (unlikely(!file->f_op->poll))
            return DEFAULT_POLLMASK;
        return file->f_op->poll(file, pt);
    }
    你肯定好奇 poll 调用里面究竟是实现了什么?
    总结概括来说:挂了个钩子,设置了唤醒的回调路径。epoll 跟底层对接的回调函数是: ep_poll_callback,这个函数其实很简单,做两件事情:
    1. 把事件就绪的 fd 对应的结构体放到一个特定的队列(就绪队列,ready list);
    2. 唤醒 epoll ,活来啦!
    当 fd 满足可读可写的时候就会经过层层回调,最终调用到这个回调函数,把 对应 fd 的结构体放入就绪队列中,从而把 epoll 从  epoll_wait 出唤醒。

    这个对应结构体是什么?

    结构体叫做 struct epitem ,每个注册到 epoll 池的 fd 都会对应一个 epitem

    • 当用户调用epoll_create()时,会创建eventpoll对象(包含一个红黑树和一个双链表);
    • 而用户调用epoll_ctl(ADD)时,会在红黑树上增加节点(epitem对象);

    就绪队列需要用很高级的数据结构吗?

    就绪队列就简单了,因为没有查找的需求了呀,只要是在就绪队列中的 epitem ,都是事件就绪的,必须处理的。所以就绪队列就是一个最简单的双指针链表

    小结下:epoll 之所以做到了高效,最关键的两点:
    1. 内部管理 fd 使用了高效的红黑树结构管理,做到了增删改之后性能的优化和平衡;
    2. epoll 池添加 fd 的时候,调用 file_operations->poll ,把这个 fd 就绪之后的回调路径安排好。通过事件通知的形式,做到最高效的运行;

          3.epoll 池核心的两个数据结构:红黑树和就绪列表。红黑树是为了应对用户的增删改需求,就绪列表是 fd 事件就绪之后放置的特殊地点,epoll 池只需要遍历这个就绪链表,就能给用户返回所有已经就绪的 fd 数组;

    3.3 epoll 的水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)


            epoll 的水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)是两种不同的事件通知机制,它们定义了 epoll 如何向应用程序报告文件描述符上的事件。理解这两种模式的差异对于使用 epoll 处理并发网络连接是很重要的。

    代码设置epoll触发模式:

    int epollfd = epoll_create1(0);
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
    ev.data.fd = sockfd;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);
    
    1)水平触发(LT)

            在水平触发模式下,只要满足条件的事件仍然存在,epoll 就会重复通知这个事件。比如,如果一个文件描述符上有可读数据,那么只要没有读完,epoll_wait 就会不断报告该文件描述符是可读的。这种模式的特点是:

    • 容错性较好,不易丢失事件。
    • 更易于编程和理解。
    • 可以用于多线程程序中,多个线程可以共享同一个 epoll 文件描述符。

    2)边缘触发(ET)

            边缘触发模式下,事件只在状态变化时被通知一次,之后无论是否读完,也不会再次通知,直到状态再次发生变化才会再次触发。例如,只有当新数据到达使得文件描述符从非可读变为可读时,epoll_wait 才会报告可读事件。边缘触发模式的特点是:

    • 效率更高,因为它减少了事件的重复通知。
    • 需要更加小心地处理每次通知,确保处理所有的数据,否则可能会丢失未处理完的数据。
    • 更适合单线程或者每个线程使用独立 epoll 文件描述符的场景。

    若还不明白水平触发和边缘触发的差异,可以看下这篇文章的例子:

    https://zhuanlan.zhihu.com/p/719987328

    3.4 哪些 fd 可以用 epoll 来管理?


            由于并不是所有的 fd 对应的文件系统都实现了 poll 接口,所以自然并不是所有的 fd 都可以放进 epoll 池,那么有哪些文件系统的 file_operations 实现了 poll 接口?

    最常见的就是网络套接字:socket 。网络也是 epoll 池最常见的应用地点。Linux 下万物皆文件,socket 实现了一套 socket_file_operations 的逻辑( net/socket.c ):

    static const struct file_operations socket_file_ops = {
        .read_iter =    sock_read_iter,
        .write_iter =   sock_write_iter,    
        .poll =     sock_poll,    
        // ...
    };
    我们看到 socket 实现了 poll 调用,所以 socket fd 是天然可以放到 epoll 池管理的。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值