第一层:为什么需要 epoll?

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

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

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

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

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

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

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

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 实例

int epoll_create(int size);
// 或者更推荐:
int epoll_create1(int flags); // flags 一般传 0

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

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

int epfd = epoll_create1(0);
if (epfd == -1) {
    perror("epoll_create1");
    exit(1);
}

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

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 结构体:

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

events 的常见取值:

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

data 是一个联合体:

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

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

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 第三步:等待事件发生

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

参数说明:

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

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

#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 服务器(客户端发什么,服务器回什么):

#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)

ev.events = EPOLLIN | EPOLLET; // 加上 EPOLLET

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

例子:

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

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

// 设置非阻塞
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 详解

设置方式:

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 的事件

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

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐