详细讲解一下epoll

第一层:为什么需要 epoll?

1.1 场景:一个餐厅服务员的故事

假设你是一个餐厅服务员,要同时服务 100 桌客人。有三种工作方式:

方式一:逐个检查(轮询)

cpp 复制代码
while (true) {
    for (桌号 = 1; 桌号 <= 100; 桌号++) {
        if (这桌有需求) 处理();
    }
}

你不停地从 1 号桌走到 100 号桌,再走回来。大部分时间白走了,累死。

**方式二:一桌一服务员(多线程)**每来一桌客人,就派一个服务员专门盯着。100 桌就要 100 个服务员,老板破产了。

方式三:客人按铃,你去服务(epoll)

cpp 复制代码
while (true) {
    在吧台等着();
    哪个响了,就去服务哪桌();
}

客人不叫你,你就歇着;有客人按铃,你立刻知道是谁、去干什么。

epoll 就是 Linux 提供给服务器的 "按铃系统"。


1.2 技术术语翻译

餐厅场景 网络编程场景
餐厅 网络编程
客人 客户端连接(socket)
客人有需求 socket 有数据到达(可读)/ 可以发送数据(可写)
按铃 操作系统通知你 "某个 socket 有事件"
服务员 你的程序(一个线程就能管理成千上万个连接)
epoll Linux 提供的事件通知机制

第二层:epoll 到底是什么?

epoll 是 Linux 内核提供的一种 I/O 多路复用机制。拆解这个词:

  • I/O:输入输出,网络数据的收发
  • 多路:同时监控多个 socket
  • 复用:一个线程处理所有 socket

它是 Linux 特有的,Windows 有类似的 IOCP,macOS 有 kqueue。


第三层:epoll 的三个核心函数

epoll 的使用就是三步走:

3.1 第一步:创建 epoll 实例

cpp 复制代码
int epoll_create(int size);
// 或者更推荐:
int epoll_create1(int flags); // flags 一般传 0

做什么:在内核中创建一个 "事件监听器",返回一个文件描述符(epfd)。

类比:你买了一个 "叫号器" 放在吧台,以后所有客人的按铃都接在这个叫号器上。

cpp 复制代码
int epfd = epoll_create1(0);
if (epfd == -1) {
    perror("epoll_create1");
    exit(1);
}

3.2 第二步:告诉 epoll 你要监控谁

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

参数说明:

  • epfd:第一步创建的 epoll 实例
  • op:操作类型,有三个值:
    • EPOLL_CTL_ADD:添加一个要监控的 socket
    • EPOLL_CTL_MOD:修改已监控 socket 的监控类型
    • EPOLL_CTL_DEL:删除一个监控
  • fd:你要监控的 socket 的文件描述符
  • event:告诉内核 "你要监控这个 socket 的什么事件",以及 "事件发生后给我什么信息"

struct epoll_event 结构体:

cpp 复制代码
struct epoll_event {
    uint32_t events;    // 监控什么事件(读?写?错误?)
    epoll_data_t data;  // 用户数据,事件触发时原样返回
};

events 的常见取值:

含义
EPOLLIN 监控可读事件(有数据到达)
EPOLLOUT 监控可写事件(可以发送数据)
EPOLLERR 错误事件(自动监控,不用显式设置)
EPOLLET 边缘触发模式(后面细讲)
EPOLLONESHOT 触发一次后自动移除监控

data 是一个联合体:

cpp 复制代码
typedef union epoll_data {
    void *ptr;      // 可以存任意指针
    int32_t fd;     // 可以存文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

完整示例:添加一个 socket 到 epoll

cpp 复制代码
struct epoll_event ev;
ev.events = EPOLLIN;            // 监控可读事件
ev.data.fd = client_fd;         // 把 socket fd 存进去,回头知道是谁触发了

if (epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev) == -1) {
    perror("epoll_ctl: add");
}

3.3 第三步:等待事件发生

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

参数说明:

  • epfd:epoll 实例
  • events:输出参数,内核把触发的事件列表写到这里
  • maxevents:最多返回多少个事件(events 数组的大小)
  • timeout:超时时间(毫秒):
    • -1:一直等待,直到有事件
    • 0:立即返回,不等待
    • >0:等待指定毫秒数

返回值:触发的事件数量。返回 0 表示超时,-1 表示出错。

cpp 复制代码
#define MAX_EVENTS 10
struct epoll_event events[MAX_EVENTS];

int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
// n 就是有多少个 socket 有事情
for (int i = 0; i < n; i++) {
    // events[i].data 是具体触发事件的 socket
    if (events[i].events & EPOLLIN) {
        int fd = events[i].data.fd;
        // 读取数据...
    }
}

第四层:完整的最小示例

一个 echo 服务器(客户端发什么,服务器回什么):

cpp 复制代码
#include <iostream>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
using namespace std;

#define MAX_EVENTS 10
#define PORT 8080

int main() {
    // 1. 创建监听 socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 设置地址重用(避免 "Address already in use")
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定地址
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
    addr.sin_port = htons(PORT);
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));

    // 开始监听
    listen(listen_fd, 5);

    // 2. 创建 epoll 实例
    int epfd = epoll_create1(0);

    // 3. 把监听 socket 加入 epoll
    struct epoll_event ev;
    ev.events = EPOLLIN;          // 监听可读(新连接到来)
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    // 4. 事件循环
    struct epoll_event events[MAX_EVENTS];
    while (true) {
        // 等待事件
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;

            if (fd == listen_fd) {
                // 监听 socket 可读 = 有新连接
                int client_fd = accept(listen_fd, NULL, NULL);

                // 把客户端也加入 epoll
                ev.events = EPOLLIN;
                ev.data.fd = client_fd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

                cout << "新客户端连接: fd=" << client_fd << endl;
            } else {
                // 客户端 socket 可读 = 有数据到达
                char buf[1024];
                int len = read(fd, buf, sizeof(buf));

                if (len <= 0) {
                    // 客户端断开连接
                    cout << "客户端断开: fd=" << fd << endl;
                    close(fd);
                    // epoll 会自动移除已关闭的 fd,也可以手动删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                } else {
                    // 回写数据
                    write(fd, buf, len);
                }
            }
        }
    }
    return 0;
}

第五层:两种触发模式(重要!)

5.1 水平触发(Level Triggered, LT)------ 默认模式

特点:只要 socket 还有数据没读完,epoll_wait 就会不断返回这个事件。

例子:

  • 数据到达(2KB)
  • epoll_wait 返回 fd 可读
  • 你读了 1KB,还剩 1KB
  • 下次 epoll_wait 还会返回这个 fd(因为还有数据)
  • 你读了剩下的 1KB
  • 下次 epoll_wait 不再返回(数据读完了)

优点:实现简单,略低效率缺点:可能重复通知,降低效率


5.2 边缘触发(Edge Triggered, ET)

cpp 复制代码
ev.events = EPOLLIN | EPOLLET; // 加上 EPOLLET

特点:只在状态变化时通知一次。

例子:

  • 数据到达(2KB)
  • epoll_wait 返回 fd 可读(仅这一次)
  • 你必须循环 read 直到返回 EAGAIN,把数据全部读完
  • 新数据到达
  • epoll_wait 再次返回(新的状态变化)

要求:socket 必须设为非阻塞模式,循环读直到返回 -1 且 errnoEAGAIN

cpp 复制代码
// 设置非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// ET 模式下读数据
while (true) {
    char buf[1024];
    int len = read(fd, buf, sizeof(buf));
    if (len == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break; // 数据读完了
        } else {
            // 其他错误
            break;
        }
    }
    if (len == 0) {
        // 对方关闭连接
        break;
    }
    // 处理这 1024 字节数据
}

LT vs ET 对比

特性 水平触发(LT) 边缘触发(ET)
通知次数 有数据就一直通知 状态变化时只通知一次
编程难度 简单 较高
数据丢失风险 高(没读完不会再通知)
效率
socket 要求 可阻塞 / 非阻塞 必须非阻塞

第六层:EPOLLONESHOT 详解

设置方式:

cpp 复制代码
ev.events = EPOLLIN | EPOLLONESHOT;

作用:触发一次后,epoll 自动移除对这个 fd 的监控,需要手动再加回来。

解决什么问题?多线程环境下,防止同一个 fd 被多个线程同时处理。

例子:

  1. 没有 EPOLLONESHOT
    • 线程 A 在 read(fd),数据被读完了,乱套了
    • 线程 B 也在 read(fd)
  2. EPOLLONESHOT
    • 事件触发,epoll 移除 fd
    • 线程 A 处理完这个 fd,epoll_ctl(MOD) 重新注册 fd
    • 线程 B 不会收到这个 fd 的事件

处理完一次事件后,重新激活监控:

cpp 复制代码
ev.events = EPOLLIN | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);

第七层:epoll 为什么高效?

对比传统的 select /poll:

特性 select poll epoll
监控方式 每次调用都传入整个 fd 集合 同 select 一次注册,内核维护
内核实现 遍历所有 fd 检查 遍历所有 fd 检查 事件驱动,回调
返回 同时监控的所有 fd,找出谁触发了 同 select 直接返回触发的事件列表
1000 个连接,1 个有事件 遍历 1000 次 遍历 1000 次 只处理 1 个
fd 数量限制 有(默认 1024)
时间复杂度 O(n) O(n) O(1)

epoll 内部使用红黑树存储注册的 fd,用链表保存触发的事件,所以添加删除是 O (log n),获取事件是 O (1)。


总结一句话: epoll 是 Linux 下高性能网络编程的基石,核心就是 "注册你要监控的,然后等着操作系统通知你有事情"。epoll 使用三步走:epoll_createepoll_ctlepoll_wait

相关推荐
A小辣椒2 天前
TShark:Wireshark CLI 功能
linux
A小辣椒2 天前
TShark:基础知识
linux
AlfredZhao2 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao3 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334663 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪3 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠4 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush44 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5204 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩4 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言