Android-epoll原理详解

从一个 200 行的 Demo 看懂 epoll ------ 原理与海量连接下的行为

本文基于一个可运行在 Android 上的最小 epoll 回显服务器(epoll_server.c / epoll_client.c), 从代码入手,逐层拆解 epoll 的内部原理,并深入分析当客户端数量达到几十万、上百万时到底会发生什么


目录

  1. [为什么需要 epoll ------ I/O 模型的演进](#为什么需要 epoll —— I/O 模型的演进 "#1-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-epoll--io-%E6%A8%A1%E5%9E%8B%E7%9A%84%E6%BC%94%E8%BF%9B")
  2. [Demo 代码回顾](#Demo 代码回顾 "#2-demo-%E4%BB%A3%E7%A0%81%E5%9B%9E%E9%A1%BE")
  3. [epoll 三兄弟 API 详解](#epoll 三兄弟 API 详解 "#3-epoll-%E4%B8%89%E5%85%84%E5%BC%9F-api-%E8%AF%A6%E8%A7%A3")
  4. [内核原理:红黑树 + 就绪链表 + 回调机制](#内核原理:红黑树 + 就绪链表 + 回调机制 "#4-%E5%86%85%E6%A0%B8%E5%8E%9F%E7%90%86%E7%BA%A2%E9%BB%91%E6%A0%91--%E5%B0%B1%E7%BB%AA%E9%93%BE%E8%A1%A8--%E5%9B%9E%E8%B0%83%E6%9C%BA%E5%88%B6")
  5. 一次完整的请求在内核里经历了什么
  6. [LT 与 ET 模式](#LT 与 ET 模式 "#6-lt--et-%E6%A8%A1%E5%BC%8F")
  7. 海量客户端时会发生什么
  8. [Demo 在高并发下的缺陷与工业级写法](#Demo 在高并发下的缺陷与工业级写法 "#8-demo-%E5%9C%A8%E9%AB%98%E5%B9%B6%E5%8F%91%E4%B8%8B%E7%9A%84%E7%BC%BA%E9%99%B7%E4%B8%8E%E5%B7%A5%E4%B8%9A%E7%BA%A7%E5%86%99%E6%B3%95")
  9. 常见坑清单
  10. 总结

1. 为什么需要 epoll ------ I/O 模型的演进

要理解 epoll 的价值,得先看它替代了什么。

1.1 阻塞 I/O + 每连接一个线程

最朴素的写法:

arduino 复制代码
while (1) {
    int conn_fd = accept(listen_fd, NULL, NULL);   // 阻塞
    pthread_create(&tid, NULL, handle_client, conn_fd); // 每个连接一个线程
}

问题:

  • 每个线程占 8MB 左右的虚拟内存栈(默认)+ 内核调度实体;
  • 1 万连接 = 1 万线程,上下文切换开销指数级恶化,CPU 大部分时间在"切换"而不是"干活";
  • 线程创建/销毁本身就有成本。

核心矛盾:一个线程同一时刻只能阻塞在一个 read() 上,而 99% 的连接在 99% 的时间里是没有数据到来的。 我们需要的其实是一种"批量等待"的机制。

1.2 select / poll:轮询式多路复用

scss 复制代码
FD_ZERO(&read_set);
FD_SET(listen_fd, &read_set);
for each conn: FD_SET(conn, &read_set);
select(max_fd + 1, &read_set, NULL, NULL, NULL);   // 阻塞在这一个调用上
for (fd = 0; fd <= max_fd; fd++)                   // 返回后线性扫描
    if (FD_ISSET(fd, &read_set)) { ... }

进步:一个线程可以等 N 个 fd 了。但每次调用都有两个致命的 O(n) 开销:

开销 select poll epoll
每次调用要把 整个 fd 集合从用户态拷贝到内核态 是(bitmask,且有 1024 个 fd 的硬上限 FD_SETSIZE 是(pollfd 数组,无上限) (注册一次,常驻内核)
内核要 线性遍历所有 fd 检查就绪状态 是,O(n) 是,O(n) (事件驱动,只返回就绪的)
返回后用户还要 再遍历一次 找出哪些就绪 否(events 数组里直接就是答案)

假设 10 万连接中只有 100 个活跃:select 每次都要传 10 万个 fd 进内核、内核逐个检查 10 万个、用户再逐个查 10 万个 ------ 97% 以上的工作是无用功 。而 epoll 的哲学是:你只告诉我谁就绪了就行

1.3 epoll 的核心思想

把"注册监听"和"等待事件"两个动作分离。 fd 只需注册一次;之后每次 epoll_wait 只返回"当前真正有事件"的 fd 列表。 通知的来源不是"遍历检查",而是内核在数据到达的路径上主动回调

这就是 epoll 在高并发、低活跃度场景下接近 O(1) 的根本原因。


2. Demo 代码回顾

server

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
​
#define PORT 8888
#define MAX_EVENTS 10
​
int main(void) {
    /* 1. 创建监听 socket */
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }
​
    int opt = 1; /* 端口复用,方便重启 */
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
​
    /* 2. 绑定 0.0.0.0:8888 */
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); return 1;
    }
​
    /* 3. 开始监听 */
    if (listen(listen_fd, 5) < 0) { perror("listen"); return 1; }
​
    /* 4. 创建 epoll 实例 */
    int epfd = epoll_create1(0);
    if (epfd < 0) { perror("epoll_create1"); return 1; }
​
    /* 5. 把监听 socket 加入 epoll,关注"可读"事件 */
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
​
    printf("server listening on port %d\n", PORT);
​
    struct epoll_event events[MAX_EVENTS];
    while (1) {
        /* 6. 阻塞等待事件发生 */
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("epoll_wait"); break;
        }
​
        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == listen_fd) {
                /* 监听 fd 可读 => 有新客户端连接 */
                int conn_fd = accept(listen_fd, NULL, NULL);
                if (conn_fd < 0) { perror("accept"); continue; }
                printf("client connected, fd=%d\n", conn_fd);
​
                /* 新连接的 fd 也加入 epoll */
                ev.events = EPOLLIN;
                ev.data.fd = conn_fd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
            } else {
                /* 客户端 fd 可读 => 收到数据,或对端关闭 */
                int fd = events[i].data.fd;
                char buf[1024];
                ssize_t cnt = read(fd, buf, sizeof(buf));
                if (cnt <= 0) {
                    /* read 返回 0 表示客户端断开连接 */
                    printf("client disconnected, fd=%d\n", fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else {
                    /* 简单回显:收到什么就发回什么 */
                    write(fd, buf, cnt);
                    printf("echo %zd bytes to fd=%d\n", cnt, fd);
                }
            }
        }
    }
​
    close(listen_fd);
    close(epfd);
    return 0;
}

client

c 复制代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
​
#define PORT 8888
​
int main(void) {
    /* 1. 创建 socket */
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("socket"); return 1; }
​
    /* 2. 连接 127.0.0.1:8888 */
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
​
    if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("connect"); return 1;
    }
    printf("connected to server\n");
​
    /* 3. 发送一条消息 */
    const char *msg = "hello epoll!";
    if (write(fd, msg, strlen(msg)) < 0) {
        perror("write"); return 1;
    }
    printf("sent: %s\n", msg);
​
    /* 4. 读取服务器回显 */
    char buf[1024] = {0};
    ssize_t cnt = read(fd, buf, sizeof(buf) - 1);
    if (cnt > 0) {
        printf("recv: %s\n", buf);
    } else {
        printf("server closed\n");
    }
​
    /* 5. 关闭连接 */
    close(fd);
    return 0;
}

bp

css 复制代码
cc_binary {
    name: "epoll_server",
    srcs: ["epoll_server.c"],
    compile_multilib: "first",
}
​
cc_binary {
    name: "epoll_client",
    srcs: ["epoll_client.c"],
    compile_multilib: "first",
}

输出:

ini 复制代码
# epoll_server                                                       
server listening on port 8888
client connected, fd=5
echo 12 bytes to fd=5
client disconnected, fd=5
​
​
# epoll_client
connected to server
sent: hello epoll!
recv: hello epoll!

整个程序只有一个线程,却可以同时服务任意多个客户端。下面逐层拆开。


3. epoll 三兄弟 API 详解

3.1 epoll_create1(0) ------ 创建 epoll 实例

ini 复制代码
int epfd = epoll_create1(0);

返回一个 fd,它代表内核中的一个 struct eventpoll 对象。注意两点:

  • 它本身也是一个 fd,所以不用了要 close(epfd)(本 demo 是学习代码,死循环里没退出路径,真实程序要处理);
  • 自 Linux 2.6.9 起推荐用 epoll_create1 替代旧的 epoll_create(size)size 参数早已被忽略(曾经是哈希表的初始大小提示,现在内核改用红黑树,不再需要"预估数量")。

3.2 epoll_ctl ------ 增删改监听项

scss 复制代码
epoll_ctl(epfd, EPOLL_CTL_ADD / MOD / DEL, fd, &ev);
操作 作用 demo 中的位置
ADD 把 fd 和关心的事件注册进 epoll 实例 listen_fd 注册、每个新 accept 的 conn_fd 注册
MOD 修改已注册 fd 的事件掩码(如加 EPOLLOUT、切 EPOLLET demo 未用到,生产代码常用
DEL 移除监听(不 close fd) 客户端断开时移除

struct epoll_event 的两个字段:

arduino 复制代码
struct epoll_event {
    uint32_t events;   /* EPOLLIN / EPOLLOUT / EPOLLERR / EPOLLHUP / EPOLLET ... */
    epoll_data_t data; /* 联合体:可放 fd / 指针 / u32 / u64 */
};

data 是纯粹的"搭车数据" :内核不解释它,事件发生时原样还给用户。所以你可以塞一个指向"连接上下文结构体"的指针,这是高性能服务器把事件和业务对象关联起来的标准手法:

ini 复制代码
struct conn {
    int    fd;
    buffer in, out;
    ...
};
struct epoll_event ev = { .events = EPOLLIN, .data.ptr = conn };
// 事件回来后:struct conn *c = events[i].data.ptr;  ------ O(1) 拿到上下文

本 demo 用 data.fd 是为了简单,代价是每次事件还要自己判断"是 listen_fd 还是 conn_fd"。

3.3 epoll_wait ------ 收割就绪事件

ini 复制代码
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
  • events内核只把就绪的事件填进来n 是本次就绪数量;
  • MAX_EVENTS:单次最多返回的条数(10),不是最大连接数!连接数上限是 fd 上限;
  • -1:无限阻塞;0:非阻塞轮询;正数:毫秒级超时;
  • 返回后,events[0..n-1] 里的每一个都保证有对应事件发生(LT 语义下,详见第 6 节)。

EINTR 处理也是标准姿势------被信号打断时重试即可,demo 里直接 continue


4. 内核原理:红黑树 + 就绪链表 + 回调机制

这是 epoll 真正的魔法所在。epfd 背后的核心结构(fs/eventpoll.c):

arduino 复制代码
struct eventpoll {
    struct rb_root_cached rbr;     /* ① 红黑树:保存所有被监听的 epitem */
    struct list_head      rdllist; /* ② 就绪链表:当前有事件的 epitem */
    wait_queue_head_t     wq;      /* ③ epoll_wait 在此睡眠/唤醒 */
    ...
};
​
struct epitem {
    struct rb_node        rbn;      /* 挂在红黑树上 */
    struct list_head      rdllink;  /* 挂在就绪链表上 */
    struct epoll_event    event;    /* 用户注册的事件 + data */
    struct file          *file;     /* 监听的目标 fd 对应的 file */
    ...
};

4.1 三个关键设计

① 注册表 = 红黑树(解决"存 N 个 fd"的问题)

每次 EPOLL_CTL_ADD,内核分配一个 epitem,插入 rbr 红黑树。查找/插入/删除都是 O(log n) 。 为什么不用哈希表?哈希表需要随规模扩容、内存局部性差;红黑树天然有序、内存紧凑,且 epoll 的增删频率(建连/断连)远低于数据到达频率,O(log n) 完全够用。10 万 fd 的树高只有约 17 层。

② 结果集 = 就绪链表(解决"返回就绪 fd"的问题)

rdllist 是一条普通双向链表,只挂当前有事件的 epitemepoll_wait 做的事极其简单:

复制代码
若 rdllist 非空 → 把前 MAX_EVENTS 个摘下来拷给用户,返回个数;
若 rdollist 为空 → 把自己挂在 wq 上睡眠,等待被唤醒。

注意这里没有任何"遍历所有 fd 检查状态"的动作------这是与 select/poll 的本质区别。

③ 通知机制 = 回调(解决"谁往链表里放东西"的问题)

这是 epoll 灵魂。每个 socket 在内核里都有一个等待队列sk->sk_wq)。注册 fd 时(ep_insert)会执行:

perl 复制代码
ep_item_poll(epi, ...) → 调用该文件描述符的 ->poll 方法(对 socket 即 tcp_poll)
                        → 把一个带回调函数 ep_poll_callback 的等待项挂到 socket 的等待队列上

从此,数据到达的路径上,内核会顺带通知 epoll

perl 复制代码
网卡收到数据 → 硬中断/软中断 → TCP 处理报文,数据放入 socket 接收缓冲区
            → 检查该 socket 的等待队列不为空 → 逐个调用其中的回调
            → ep_poll_callback(epi) 执行:
                 1. 把这个 epitem 挂到 eventpoll->rdllist 尾部      ← 就绪链表有了内容
                 2. 检查 epoll_wait 是否正睡在 wq 上 → 是则唤醒它
            → epoll_wait 返回,用户拿到就绪 fd 列表

一句话总结:select/poll 是"每次都去问所有人好了没",epoll 是"每个 fd 好了会自己举手,内核把它记在小本本(就绪链表)上,你只管来收名单"。

代价当然也有:注册需要系统调用、每个被监听 fd 多一个 epitem(约 128 字节)的内存开销。所以连接数少、且几乎全员活跃 时,epoll 相比 poll 并无明显优势;它的主场是连接多、活跃比例低的场景------而这正是长连接服务器的真实画像。


5. 一次完整的请求在内核里经历了什么

以 demo 为例,追踪 epoll_client 发送 "hello epoll!" 后的完整链路:

perl 复制代码
【连接建立】
client: connect()
   └─ 三次握手完成 → 服务端 listen_fd 的全连接队列出现一个新连接
   └─ listen_fd 变为"可读" → 其等待队列上的 ep_poll_callback 被触发
   └─ listen_fd 的 epitem 进入就绪链表 → epoll_wait 被唤醒
   └─ 用户态: events[i].data.fd == listen_fd → 调用 accept()
        └─ accept 从全连接队列取出连接,返回新的 conn_fd
        └─ EPOLL_CTL_ADD 把 conn_fd 的 epitem 插入红黑树,
           并把 ep_poll_callback 挂到 conn_fd 的等待队列上
​
【数据到达】
client: write(fd, "hello epoll!", 12)
   └─ 数据经网络到达 → 内核协议栈处理 → 12 字节进入 conn_fd 的接收缓冲区
   └─ conn_fd 可读 → ep_poll_callback → conn_fd 的 epitem 进入就绪链表
   └─ epoll_wait 唤醒 → 用户态: read(fd, buf, 1024) 从内核缓冲区拷出 12 字节
​
【回显】
server: write(fd, buf, 12)
   └─ 若发送缓冲区有空间 → 立即拷入,TCP 异步发出
   └─ 若发送缓冲区满了 → write 返回 -1/EAGAIN(非阻塞)或阻塞(阻塞 fd)→ 见第 8 节
​
【断开】
client: close(fd)
   └─ 服务端收到 FIN → conn_fd 变为"可读"且 read() 返回 0
   └─ epoll_wait 报告 EPOLLIN(通常伴随 EPOLLRDHUP)
   └─ demo 判定 cnt == 0 → DEL + close

可以看到, "连接、数据、断开"在内核看来全是同一种东西:fd 状态变化 → 触发回调 → 进入就绪链表。这就是多路复用的统一抽象。


6. LT 与 ET 模式

6.1 LT(Level Triggered,水平触发,默认)

只要 fd 的缓冲区里仍有 数据可读(或仍有空间可写),每次 epoll_wait 都会报告它。

  • read 只读了一半,下次 epoll_wait 还会提醒你------绝不会丢事件
  • 允许阻塞 fd、允许一次读不完,容错性极高;
  • 缺点:一个"磨蹭"的 fd 会被反复提醒,理论上有活干的 fd 会拖累循环效率。

demo 用的就是 LT,所以代码才敢这么随意:阻塞式 read、一次只 accept 一个、buf 读不完也无所谓。

6.2 ET(Edge Triggered,边缘触发,EPOLLET

只在状态发生跳变时(无数据 → 有数据)报告一次。之后哪怕缓冲区还剩数据,也不会再提醒。

配套的强制纪律:

  1. fd 必须非阻塞fcntl(fd, F_SETFL, O_NONBLOCK));
  2. 读必须循环读到 EAGAIN
ini 复制代码
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) { 处理数据; continue; }
    if (n < 0 && errno == EAGAIN) break;   /* 读干净了,正常退出 */
    if (n < 0 && errno == EINTR) continue;
    出错或对端关闭; break;
}
  1. accept 也要循环EAGAIN(尤其在惊群/高连接速率场景,一次事件可能对应多个排队连接)。

ET 的意义:每个事件只通知一次,减少 epoll 就绪链表的操作次数和重复唤醒,配合非阻塞 fd 在极端吞吐场景下效率略高。nginx、Redis 内部用的就是 ET。

经典事故:ET 模式下 read 只读一次,缓冲区还剩 5 字节,对端不再发新数据 → 这 5 字节永远读不出来,连接"卡死"。这就是 ET 必须读穿的原因。


7. 海量客户端时会发生什么

假设把 demo 推到 10 万、100 万 TCP 长连接,逐项分析。

7.1 内存:epoll 自身的开销极小

  • 每个 fd 一个 epitem ≈ 128B(内核空间,不可换出);
  • 100 万连接 ≈ 128MB 内核内存用于 epoll 结构 ------ 完全可接受;
  • 用户侧 events 数组只需按"单批就绪数"分配(如 1024),与总连接数无关。

对比 select:bitmask 本身虽小(1024/8=128B),但每次调用都要整块拷进内核 ,且 1024 上限直接出局;poll 的 pollfd 8B×100万 = 8MB,每次调用拷一次,系统调用内存带宽先被打爆。

真正的内存大头在别处:每个 TCP 连接的内核 socket 缓冲区(收/发各默认可达几十上百 KB,按需扩张),加上用户态为每个连接保存的上下文。100 万空闲连接轻松占用数 GB 内存------这属于"连接本身"的成本,与用不用 epoll 无关。

7.2 CPU:活跃连接数才是决定性变量

epoll 的每次 epoll_wait 成本 ≈ O(本次就绪事件数) ,与总连接数无关:

  • 100 万连接、1 万活跃:每轮处理 1 万个事件,开销约等于只服务 1 万连接的服务器。这就是 C1000K 可行的原因;
  • 100 万连接、100 万同时活跃:epoll 退化为"高效的事件分发器",瓶颈转移到协议栈处理、业务逻辑、网卡中断(此时该上 多线程 Reactor + SO_REUSEPORT 多队列 RSS 等手段了)。

select/poll 则相反:成本永远 O(总连接数),100 万连接里挑 1 万个就绪,每轮白扫 99 万个 fd。

7.3 accept 风暴与"就绪链表积压"

海量客户端同时建连(比如 100 万客户端同一秒启动)时:

  1. 每个完成三次握手的连接都触发 listen_fd 的回调,但就绪链表上 listen_fd 的 epitem 只有一份 (挂过就不会重复挂,内核用 epi->pwqlist 上的标志去重)------所以 100 万次握手在 epoll 视角只是"listen_fd 就绪"这一个事实;
  2. epoll_wait 返回后,demo 只 accept 一次,队列里剩下的 999999 个连接要等下一轮循环;
  3. 若全连接队列(accept 队列)被填满,内核会丢弃 SYN 或拒连 (视 tcp_abort_on_overflow 而定),客户端表现为连接失败或超时重试;
  4. LT 模式下 listen_fd 会持续被报告,所以 demo 最终能消化完(每轮 1 个,配合 backlog=5 很勉强);ET 模式下必须循环 accept 到 EAGAIN,否则同样卡死。

demo 里 listen(listen_fd, 5) 的 backlog=5 在高并发下就是个笑话,生产至少 1024 起步(内核实际取 min(backlog, somaxconn),现代内核 somaxconn 默认 4096)。

7.4 惊群(Thundering Herd)

多个线程/进程各自 epoll_wait 同一个 epfd 时:一个事件到达,可能唤醒所有等待者,但只有一个能拿到事件,其余白白惊醒、空转一次。

  • 同一 epfd 被 fork 共享(如传统 prefork + epoll):经典惊群,内核已对 epoll_wait 本身做了 WQ_FLAG_EXCLUSIVE 缓解;
  • 不同进程各自建 epoll 实例、SO_REUSEPORT 绑定同一端口:内核按四元组哈希分流,无惊群,这是当前主流方案(nginx 1.9+、Linux 内核自身实践)。

demo 单线程单 epoll,不存在此问题。

7.5 fd 资源上限

100 万连接首先撞到的往往不是 epoll,而是这些墙:

bash 复制代码
ulimit -n                 # 单进程 fd 上限,默认 1024!生产要调到百万级
cat /proc/sys/fs/file-max # 系统级总 fd 上限
cat /proc/sys/net/ipv4/ip_local_port_range  # 客户端侧源端口耗尽(压测机)

客户端压测机做百万连接时,单台机器源端口约 2.8 万个/IP(约 28232 个可用端口),需要多个 IP 或多台压测机。

7.6 定时器与空转

海量长连接几乎必然带心跳/超时管理。若把超时做成"每秒唤醒全量扫描 100 万连接",O(n) 扫描会吃掉可观的 CPU。工业级做法:

  • 按超时时间分桶 / 时间轮(hashed timing wheel):每秒只处理到期的极小部分;
  • 或利用 epoll_wait 的 timeout 兼做调度节拍。

demo 没有任何超时逻辑------死连接会永远占着 fd,这在生产上是事故(fd 泄漏 + 内存泄漏)。


8. Demo 在高并发下的缺陷与工业级写法

以这个教学 demo 为基线,升级为能扛十万连接的样子:

scss 复制代码
/* ① listen_fd 设非阻塞(配合 ET 或批量 accept) */
fcntl(listen_fd, F_SETFL, O_NONBLOCK);
​
/* ② backlog 加大 */
listen(listen_fd, 1024);
​
/* ③ 循环 accept,直到 EAGAIN(ET 必需,LT 也建议) */
if (events[i].data.fd == listen_fd) {
    while (1) {
        int conn_fd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
        if (conn_fd < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* 收干净了 */
            if (errno == EINTR) continue;
            break;
        }
        register_conn(epfd, conn_fd);   /* ADD + 初始化 conn 上下文 */
    }
}
​
/* ④ 读:非阻塞 + 循环读穿(ET 模式强制) */
while ((n = read(fd, buf, sizeof(buf))) > 0) { append_to_conn_buffer(c, buf, n); }
if (n < 0 && errno != EAGAIN) { close_conn(c); }
​
/* ⑤ 写:处理发送缓冲区满(慢客户端背压) */
ssize_t m = write(fd, c->out.data, c->out.len);
if (m < 0 && errno == EAGAIN) {
    /* 内核发送缓冲区满:先把剩余数据存入 c->out,
       并给该 fd 追加 EPOLLOUT;等缓冲区腾出空间时 epoll 会报 EPOLLOUT,
       届时继续写,写完后 MOD 回 EPOLLIN-only。
       demo 的 write 是阻塞语义:慢客户端会卡死整个事件循环! */
    epoll_mod(epfd, fd, EPOLLIN | EPOLLOUT, c);
}

再加几件 demo 完全没有的事:

  • 每连接上下文data.ptr 指向 struct conn,携带输入/输出缓冲区(写不定长消息必须自己拼包);
  • EPOLLRDHUP:注册它可以在对端半关闭(发 FIN)时更准确地感知断连;
  • EPOLLONESHOT:多线程 Reactor 中防止两个线程同时操作同一 fd 的事件(处理完要 MOD 重新武装);
  • 多 Reactor(主从 Reactor / "one loop per core") :主线程 epoll 只管 accept,把 conn_fd 分发给 N 个 worker 线程各自的 epoll(参考 muduo/netty 模型),把百万连接摊到多核;
  • 优雅退出signal + eventfd/pipe 唤醒 epoll_wait,统一清理资源。

9. 常见坑清单

  1. MAX_EVENTS 误解:它是"单次最多取多少个就绪事件",设 1024 左右即可,不是连接数上限;
  2. ET 不读穿:残留数据永远读不到,连接假死(见 6.2);
  3. ET + 阻塞 fd:最后一次 read 阻塞在事件循环里,整个服务器卡住;
  4. 忘了 EPOLLOUT 背压:对慢客户端持续 write,阻塞模式下卡死循环,非阻塞下不断 EAGAIN 空转(demo 属于前者);
  5. fork 之后共享 epfd:父子进程各自 epoll_wait 同一实例会导致事件被"抢走",语义错乱;
  6. 重复 ADD 同一 fd :返回 EEXIST;fd 关闭后内核会自动从 epoll 移除(close 会触发),但dup 出来的 fd 例外
  7. close 一个仍在 epoll 里的 fd :没问题,内核自动清理;但先用 dup2 覆盖会踩坑;
  8. epoll_wait 返回后 fd 已被别的线程关掉:多线程共享 fd 表时的经典 use-after-close 竞态;
  9. 把 epoll 当事件队列缓存:epoll 只报告"调用时刻"的就绪状态,不累积历史(LT 的"重复提醒"不是历史记录);
  10. 海量连接忘了调 ulimit -naccept 返回 EMFILE,然后死循环空转------处理 EMFILE 时要先取一个空闲 fd 占位再 close,防止惊群式重试。

10. 总结

维度 select poll epoll
fd 上限 1024(编译期) 无硬限制 无硬限制(受 fd 配额约束)
fd 传递 每次全量拷入内核 每次全量拷入内核 注册一次,常驻内核
就绪判定 内核 O(n) 遍历 内核 O(n) 遍历 回调驱动,就绪链表 O(1) 入队
返回处理 用户再 O(n) 扫描 用户再 O(n) 扫描 直接给出就绪列表
海量连接表现 不可用 迅速劣化 成本只随活跃连接数增长

用一句话记住 epoll:

红黑树存"我关心谁",就绪链表存"谁举手了",回调机制保证数据一到达就有人把 fd 放进链表------epoll_wait 只是去收名单。

而海量客户端场景下,真正需要担心的从来不是 epoll 本身(百万元级连接也就百来 MB 内核内存),而是:fd 配额、per-conn 内存、accept 风暴、慢客户端背压、超时管理与多核扩展。把 demo 按 第 8 节 的路径逐条补齐,你就从"会用 epoll"走到了"能写 epoll 服务器"。

配套代码:epoll_server.c(LT 模式回显服务器)、epoll_client.c(单发单收客户端)、Android.bp(AOSP 编译脚本)。 推荐延伸阅读:内核源码 fs/eventpoll.c、nginx 的 epoll module、muduo《Linux 多线程服务端编程》。

相关推荐
fireworks991 小时前
接口鉴权(401与403)
java·后端
wear工程师1 小时前
线程池里的异常去哪了?execute 和 submit 不是一回事
java
孔明click331 小时前
别人绕过我的网关直接调用资源服务怎么办?使用 Sa-Token 解决:网关转发鉴权、RPC调用鉴权
java·sa-token·springboot·权限·权限认证
吴声子夜歌1 小时前
Java面试——设计模式(一)
java·设计模式·面试
半亩码田1 小时前
C#转Python第3.6篇:Python 的 @property 比 C# 的 get/set 更灵活
java·python·c#
老郑聊AI业财智造1 小时前
Spring AI 技术架构与源码分析
java·人工智能·后端·spring·架构·软件工程
省长2 小时前
别人绕过我的网关直接调用资源服务怎么办?使用 Sa-Token 解决:网关转发鉴权、RPC调用鉴权
java·后端·开源
MetaLite2 小时前
SpringBoot异常处理-到底该转换还是继续抛-入口层与调用层不能一刀切
java·spring boot·后端
赵广陆2 小时前
Spring AI的聊天模型
java·人工智能·spring