学后端绕不开 epoll,这篇文章先用一个「餐厅服务员」的故事把逻辑讲通,再一一对应到技术概念。保证你看完能说清楚:epoll 解决什么问题、数据怎么流转、和你写的 Java/Redis 代码是什么关系。


一、一个餐厅的故事:为什么需要 epoll

想象你开了一家餐厅,只有一个服务员,同时有 100 桌客人。

笨办法 1:傻等

服务员走到 1 号桌前站着,等客人想好点什么。 客人在看菜单,服务员就干等。 这时 2 号桌已经举手要点单了——但服务员被 1 号桌「卡住」了,看不见。

这就是「单线程 + 阻塞 I/O」的问题。

笨办法 2:请 100 个服务员

每桌配一个服务员,谁举手谁的服务员就去。 能用,但工资(线程开销)太高,桌子再多就发不起了。

这就是「每个连接一个线程」的问题。

笨办法 3:来回跑

服务员不停地从 1 号桌跑到 100 号桌,挨个问"你要点单吗?" 大部分桌都没准备好,白跑一趟又一趟。

这就是「非阻塞 + 轮询」的问题,CPU 空转。

聪明办法:装一个叫号系统

每桌有个按钮,客人准备好了就按一下。 服务员只看叫号屏幕:屏幕上显示哪几桌亮了,就只去那几桌。 没人按的时候,服务员可以休息,不用瞎跑。

这个叫号系统,就是 epoll。


二、把餐厅故事翻译成技术语言

餐厅里技术上
一桌客人一条 TCP 连接(一个 socket)
桌号(1 号、2 号…)文件描述符 fd(一个整数编号)
客人按按钮socket 上有数据到达(事件发生)
叫号屏幕epoll 的就绪列表
服务员看屏幕程序调用 epoll_wait
服务员去那桌接单程序对就绪的 fd 调用 read
唯一的服务员Redis 的单线程
多个服务员分工Tomcat/Netty 的线程池

三、数据到底怎么流的?——从客户端到你的代码

很多人以为客户端一发请求,你的 Java/Redis 程序就直接收到了。不是的,中间还有个「内核」在替你收快递。

完整路径(记住这条线就行):

客户端发数据

网卡收到

Linux 内核接收,放进「socket 接收缓冲区」(内核管的仓库)

epoll 发现:这个 socket 的仓库有货了→ 通知你的程序

你的程序调 read(),把数据从内核仓库搬到自己的内存里

解析成 Redis 命令 / HTTP 请求 → 执行业务

两个关键点:

  1. epoll 不存业务数据。它只是个通知机制,告诉你"哪个 socket 有货了"。真正的数据在 socket 缓冲区里,靠 read 搬出来。

  2. 内核只认字节。什么 GET key、什么 HTTP 请求,内核不管。它只把一堆字节收好,你的程序自己按协议解析。


四、两种 socket:迎宾和服务

一个服务器里通常有两类 socket:

  • 监听 socket(listenfd):像餐厅门口的迎宾。有新客人来(新连接),迎宾把客人领进来,分配一个桌号。

  • 连接 socket(connfd):像每桌的服务员通道。客人在这条通道上点单(发命令)、收菜(收响应)。

简单记:listenfd 管进门,connfd 管干活。

连接建立的过程:

  1. 客户端和服务器完成 TCP 三次握手。

  2. 服务器调 accept(listenfd) → 得到一个新的 connfd

  3. 此时客户端可能还没发任何数据——连接已经建好了,但 socket 缓冲区是空的。

  4. 等客户端发了数据,connfd 才变成「读就绪」。

所以:先有连接,后有数据。不是有数据才建连接。


五、epoll 怎么用?三步走

回到叫号系统的类比:

第一步:装系统

epoll_create() // 创建一个 epoll 实例(装好叫号系统)

第二步:登记桌号

epoll_ctl(ADD, fd, 读事件) // 把某个 socket 的 fd 注册进去

// 相当于:给这桌装上按钮,盯着「有人按」这个事

第三步:看屏幕

events = epoll_wait(...) // 阻塞等待,直到有 fd 就绪

// 返回:哪些桌亮了(哪些 fd 有事件)

然后对每个「亮了的桌」:

  • 如果是 listenfd 亮了:有新客人 → accept 建新连接 → 新 connfd 也登记到 epoll。

  • 如果是 connfd 亮了:有数据来了 → read 读数据 → 解析命令 → 执行。

epoll 循环伪代码:

// 1. 创建叫号系统
int epfd = epoll_create(1);

// 2. 把迎宾(监听socket)登记进去
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

// 3. 主循环:看屏幕
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_fd) {
            // 新客人来了 → accept
            int conn_fd = accept(listen_fd, ...);
            epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
        } else {
            // 老客人按了按钮 → read
            char buf[1024];
            read(events[i].data.fd, buf, sizeof(buf));
            // 处理命令...
        }
    }
}

六、红黑树和就绪链表

面试最爱问的「红黑树 + 就绪链表」,翻译成人话:

内核结构是什么类比
红黑树存「所有被监控的 fd」花名册:记着你要盯哪些桌
就绪链表存「已经有事件发生的 fd」叫号屏幕:哪些桌刚按了按钮

要点:

  • 在花名册上 ≠ 这桌有事。花名册只是说"我要盯着这桌"。

  • 叫号屏幕上的才是「有事的桌」。epoll_wait 就是去看这个屏幕。

  • 屏幕上只有 fd 和事件类型,不会把客人说的话(业务数据)显示上去。数据还在 socket 缓冲区里,得你自己去 read


七、「就绪」到底是什么意思?

很多人以为「就绪 = 空闲」,恰恰相反:

  • 就绪 = 这个 socket 上有事情要处理了

    • 读就绪(EPOLLIN):接收缓冲区有数据,你调用 read() 能立刻读到。

    • 写就绪(EPOLLOUT):发送缓冲区有空间,你调用 write() 不会卡住。

  • 未就绪 = 现在没事,去读可能会卡住(阻塞模式下)。

就绪是「有活干」,不是「闲着」。


八、单线程 Redis vs 多线程 Tomcat

两者底层都可以用 epoll,区别在于:epoll 通知之后,谁来干活?

Redis(单线程干活)

epoll_wait 返回一批就绪的 fd

主线程依次处理:读 fd1 → 执行命令 → 读 fd2 → 执行命令 → ...

  • 优点:不用加锁,没有并发问题,逻辑简单。

  • 适合:命令执行快(纯内存操作),不会卡在某个命令上。

Tomcat / Netty(多线程干活)

epoll_wait 返回一批就绪的 fd

丢给线程池:线程 A 处理 fd1,线程 B 同时处理 fd2

  • 优点:一个慢请求(比如查数据库)不会堵住其他请求。

  • 适合:请求处理可能有阻塞点(IO、RPC)的场景

两种模式的伪代码对比:

// 单线程:一个一个处理,没有锁
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        char buf[1024];
        read(fd, buf, sizeof(buf));
        process_command(buf);   // 执行命令,串行
    }
}

// 初始化时创建 N 个 worker 线程
// 主线程只把 fd 放入队列

while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        thread_pool_add_task(fd);   // 扔进线程池的任务队列
    }
}

// worker 线程:
void* worker(void* arg) {
    while (1) {
        int fd = thread_pool_get_task();  // 从队列取一个 fd
        char buf[1024];
        read(fd, buf, sizeof(buf));
        process_request(buf);
    }
}

一句话:epoll 只管「通知谁有事」,单线程还是多线程决定「通知之后怎么干活」。


九、和 select/poll 比,epoll 好在哪?

不用记复杂度,记一个直觉:

  • select/poll:每次都要把「我关心哪些桌」这张单子完整交给内核,内核挨个检查一遍再告诉你哪些亮了。桌子越多,检查越慢。

  • epoll:花名册常驻内核,增删改是增量的。有事的桌主动亮灯,不用挨个查。

连接少时差别不大;连接成千上万时,epoll 的优势才明显。


十、Java 里看不见 epoll?

Java 的 Selector 在 Linux 上底层就是 epoll:

Java API底层调用
Selector.open()epoll_create
channel.register()epoll_ctl
selector.select()epoll_wait

封装好了还要学,是因为:

  • 线上连接打满、延迟飙高时,你需要知道问题出在内核队列、fd 数量、还是线程模型。

  • 只会调 API 的人只能重启;懂原理的人能定位根因。

Java NIO 底层调用对照(简洁版):

// Java 代码
Selector selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_READ);
selector.select();

// 底层 Linux 调用
// epoll_create() → epoll_ctl(ADD) → epoll_wait()

十一、常见误区速查

你可能以为实际上
红黑树里存的是 Redis 命令存的是「监控哪些 fd + 关注什么事件」
就绪 = socket 空闲就绪 = 有数据/有事件(有活干)
有数据才能建连接先三次握手建连接,之后才可能有数据
没 epoll 就没法并发多线程阻塞也能扛,只是成本高
多线程不需要 epoll高性能服务器常是 epoll + 线程池组合
一次 epoll_wait = 一个请求一次可能返回一批就绪 fd

十二、一句话带走

epoll 就是 Linux 内核里的「叫号系统」:你把要盯的 socket 登记进去,有事它通知你,没事你可以休息。数据不在 epoll 里,在 socket 缓冲区里。Redis 一个人接单,Tomcat 一群人分工——但叫号系统是同一套。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐