Linux I/O多路转接—epoll

三、epoll

3.1 认识

epoll是"为处理大批量句柄而作了改进的poll"。epoll提供了三个系统调用,各司其职:

系统调用 作用
epoll_create 创建一个epoll句柄
epoll_ctl 注册/修改/删除要监听的事件
epoll_wait 等待就绪事件

3.2 系统调用

(1)epoll_create

创建一个epoll句柄

cpp 复制代码
int epoll_create(int size);

自Linux 2.6.8之后,size参数被忽略。用完后必须调用close()关闭。

(2)epoll_ctl

事件注册函数

cpp 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

不同于select在监听时才告诉内核要监听什么,epoll在这里先注册要监听的事件类型。

  • epfd:epoll_create的返回值

  • op:动作类型,用三个宏表示

  • EPOLL_CTL_ADD:注册新fd到epfd

  • EPOLL_CTL_MOD:修改已注册fd的监听事件

  • EPOLL_CTL_DEL:从epfd中删除fd

  • fd:需要监听的文件描述符

  • event:告诉内核需要监听什么事件

(3)epoll_wait

收集已发生的就绪事件

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events,
                int maxevents, int timeout);
  1. events:分配好的epoll_event结构体数组,内核将事件复制到这里

  2. maxevents:events数组大小,不能大于epoll_create时的size

  3. timeout:超时时间,0立即返回,-1永久阻塞

  4. 返回值:已就绪的描述符数目,0表示超时,< 0表示失败

3.3 epoll_event结构

epoll_event结构体:

宏的集合:

含义
EPOLLIN 对应文件描述符可读
EPOLLOUT 对应文件描述符可写
EPOLLPRI 有紧急数据可读
EPOLLERR 文件描述符发生错误
EPOLLHUP 文件描述符被挂断
EPOLLET 设为边缘触发模式
EPOLLONESHOT 只监听一次,监听完后需再次加入

3.4 工作原理

当进程调用epoll_create时,Linux内核会创建一个eventpoll结构体:

cpp 复制代码
struct eventpoll {
    /* 红黑树根节点,存储所有添加到epoll中需要监控的事件 */
    struct rb_root rbr;
    /* 双向链表,存放将要通过epoll_wait返回给用户的就绪事件 */
    struct list_head rdlist;
};

每一个添加到epoll中的事件,都会建立一个epitem结构体:

cpp 复制代码
struct epitem {
    struct rb_node rbn;          // 红黑树节点
    struct list_head rdllink;    // 双向链表节点
    struct epoll_filefd ffd;     // 事件句柄信息
    struct eventpoll *ep;        // 所属的eventpoll对象
    struct epoll_event event;    // 期待的事件类型
};

整体结构:

工作流程:

  1. 注册阶段:通过epoll_ctl将fd加入红黑树,同时与设备驱动建立回调关系

  2. 事件触发:当fd上有事件发生时,驱动调用回调方法ep_poll_callback,将对应epitem加入rdlist双向链表

  3. 获取就绪:epoll_wait只需检查rdlist是否为空,非空则将事件复制到用户态并返回,时间复杂度O(1)

注意:网上有些资料称epoll使用了内存映射机制来避免拷贝,这种说法不准确。

3.5 优点

与select/poll的缺点一一对应,epoll的优势体现在:

  1. 接口使用方便:虽拆分为三个函数,但无需每次循环重新设置关注的fd,输入输出参数分离

  2. 数据拷贝轻量:仅在EPOLL_CTL_ADD时将描述符结构拷贝到内核,操作不频繁;而select/poll每次循环都要拷贝

  3. 事件回调机制:不使用遍历,而是通过回调函数将就绪描述符加入就绪队列,epoll_wait直接访问就绪队列,时间复杂度O(1)

  4. 无数量限制:文件描述符数目无上限

3.6 LT与ET

epoll支持水平触发和边缘触发两种模式。用一个通俗的例子来说明:

你正在打游戏进入决赛圈,你妈饭做好了喊你吃饭:

  • 水平触发:喊你一次你没动,她会继续喊第二次、第三次......直到你去吃

  • 边缘触发:喊你一次你没动,她就不管你了

(1)水平触发(LT): epoll默认模式。

  • 检测到socket事件就绪时,可以不立刻处理,或只处理一部分

  • 只要缓冲区中还有数据未处理,下次epoll_wait仍会立刻返回并通知就绪

  • 支持阻塞读写和非阻塞读写

(2)边缘触发(ET): 需在注册时设置EPOLLET标志。

  • 事件就绪后必须立刻处理,只有一次处理机会

  • 即使缓冲区还有数据未读完,下次epoll_wait也不会再返回

  • 性能比LT更高,Nginx默认采用ET模式

  • 只支持非阻塞读写

select和poll实际上也工作在LT模式下,而epoll是唯一支持ET模式的方案。

3.7 问题

**ET模式为何必须非阻塞?**理解这个问题需要看一个具体场景。

假设服务器接收一个10k的请求,读完后向客户端返回应答,客户端收不到应答就不会发送下一个请求。

正常的请求-应答时序

如果服务端使用阻塞式read,且一次只读了1k数据:

此时由于epoll处于ET模式,不会再认为该fd读就绪,epoll_wait不会再次返回,剩下的9k数据一直留在缓冲区中。

死锁场景:

  • 服务器只读到1k,需读完10k才返回响应

  • 客户端要收到响应才发下一个请求

  • 客户端发下一个请求,epoll_wait才会返回,才能读剩余9k

三者形成循环等待,解决方案是使用非阻塞轮询方式读取,确保一次把缓冲区数据读完。

而LT模式没有这个问题------只要缓冲区数据没读完,epoll_wait就会持续返回读就绪。

3.8 代码示例

cpp 复制代码
#pragma once
#include <vector>
#include <functional>
#include <sys/epoll.h>
#include "tcp_socket.hpp"

typedef std::function<void (const std::string&, std::string* resp)> Handler;

class Epoll {
public:
    Epoll() {
        epoll_fd_ = epoll_create(10);
    }
    ~Epoll() {
        close(epoll_fd_);
    }

    bool Add(const TcpSocket& sock) const {
        int fd = sock.GetFd();
        epoll_event ev;
        ev.data.fd = fd;
        ev.events = EPOLLIN;
        int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev);
        if (ret < 0) {
            perror("epoll_ctl ADD");
            return false;
        }
        return true;
    }

    bool Del(const TcpSocket& sock) const {
        int fd = sock.GetFd();
        int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, fd, NULL);
        if (ret < 0) {
            perror("epoll_ctl DEL");
            return false;
        }
        return true;
    }

    bool Wait(std::vector<TcpSocket>* output) const {
        output->clear();
        epoll_event events[1000];
        int nfds = epoll_wait(epoll_fd_, events,
                               sizeof(events) / sizeof(events[0]), -1);
        if (nfds < 0) {
            perror("epoll_wait");
            return false;
        }
        // 注意:循环到nfds即可,不需要遍历全部
        for (int i = 0; i < nfds; ++i) {
            TcpSocket sock(events[i].data.fd);
            output->push_back(sock);
        }
        return true;
    }

private:
    int epoll_fd_;
};

服务器主循环:

cpp 复制代码
class TcpEpollServer {
public:
    bool Start(Handler handler) {
        TcpSocket listen_sock;
        listen_sock.Socket();
        listen_sock.Bind(ip_, port_);
        listen_sock.Listen(5);

        Epoll epoll;
        epoll.Add(listen_sock);

        for (;;) {
            std::vector<TcpSocket> output;
            if (!epoll.Wait(&output)) continue;

            for (size_t i = 0; i < output.size(); ++i) {
                if (output[i].GetFd() == listen_sock.GetFd()) {
                    TcpSocket new_sock;
                    listen_sock.Accept(&new_sock);
                    epoll.Add(new_sock);
                } else {
                    std::string req, resp;
                    bool ret = output[i].Recv(&req);
                    if (!ret) {
                        epoll.Del(output[i]);
                        output[i].Close();
                        continue;
                    }
                    handler(req, &resp);
                    output[i].Send(resp);
                }
            }
        }
        return true;
    }
private:
    std::string ip_;
    uint16_t port_;
};

四、三者对比

对比维度 select poll epoll
数据结构 fd_set位图 pollfd结构体数组 红黑树+就绪链表
最大描述符数 通常1024 无硬性限制 无限制
每次调用是否需重置
用户态/内核态拷贝 每次调用都拷贝整个fd_set 每次调用都拷贝整个pollfd数组 仅在epoll_ctl时拷贝,epoll_wait不拷贝
就绪检测方式 内核遍历所有fd 内核遍历所有fd 回调机制,O(1)获取就绪
工作模式 仅LT 仅LT LT + ET
时间复杂度 O(n) O(n) O(1)
适用场景 连接数少且都较活跃 连接数中等 大量连接、活跃度低

五、使用场景

epoll的高性能是有前提的,并非所有场景都适用。

适合使用epoll:

  • 多连接,且其中只有一部分连接比较活跃

  • 典型的互联网APP入口服务器,需处理上万个客户端连接

  • 对并发性能要求较高的网络服务

不适合使用epoll:

  • 系统内部服务器之间通信,只有少数几个连接

  • 连接数少且全部持续活跃的场景,select/poll的遍历开销可以忽略,epoll的复杂结构反而带来额外开销

具体选择哪种I/O模型,应根据实际需求和场景特点来决定,而非盲目追求"最新最好"。


结语

从select到poll再到epoll,Linux I/O多路转接的演进脉络清晰可见:每一代方案都在解决上一代的核心痛点。

select用位图实现了最基本的多路复用,poll用结构体数组优化了接口,epoll则通过红黑树+回调机制从根本上解决了性能瓶颈。

理解这三者的底层原理和适用场景,不仅是网络编程的基本功,也是面试中的高频考点,希望本文能帮你建立起完整的知识框架。

相关推荐
学长毕业设计40 分钟前
基于springboot的云南中草药知识普及管理网站的设计与开发(源码+文档+讲解视频)
java·spring boot·后端
大模型码小白42 分钟前
AI 提示词专栏:使用系统指令(System Prompt)实现全局约束
java·大数据·运维·开发语言·人工智能·python·prompt
土星云SaturnCloud43 分钟前
超轻量 OCR 实战:PP-OCR 边缘部署实践
服务器·人工智能·ai·ocr·边缘计算
cxoptics1 小时前
蓝宝石的轴向在窗片和偏振片中起的作用
java
AI备忘录1 小时前
(十六)GRE/IPSec 隧道配置命令五厂商对照:华为 华三 锐捷 迈普 思科
运维·服务器·网络·网络协议·网络安全·华为
HugoStudio_SWAN1 小时前
C++ CMD 互动动画:按键触发爆炸效果
开发语言·c++·学习·程序人生
wuminyu1 小时前
Java FFM处理网络读写事件源码剖析
java·linux·c语言·jvm·c++
互联网中的一颗神经元1 小时前
10. Large 对象分配:直接路径
开发语言·golang
实心儿儿1 小时前
Linux —— 数据链路层
linux·网络·智能路由器