一篇讲透 epoll:从「服务员怎么接单」理解 Linux 高并发核心
学后端绕不开 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 请求 → 执行业务
两个关键点:
-
epoll 不存业务数据。它只是个通知机制,告诉你"哪个 socket 有货了"。真正的数据在 socket 缓冲区里,靠
read搬出来。 -
内核只认字节。什么
GET key、什么 HTTP 请求,内核不管。它只把一堆字节收好,你的程序自己按协议解析。
四、两种 socket:迎宾和服务
一个服务器里通常有两类 socket:
-
监听 socket(listenfd):像餐厅门口的迎宾。有新客人来(新连接),迎宾把客人领进来,分配一个桌号。
-
连接 socket(connfd):像每桌的服务员通道。客人在这条通道上点单(发命令)、收菜(收响应)。
简单记:listenfd 管进门,connfd 管干活。
连接建立的过程:
-
客户端和服务器完成 TCP 三次握手。
-
服务器调
accept(listenfd)→ 得到一个新的connfd。 -
此时客户端可能还没发任何数据——连接已经建好了,但 socket 缓冲区是空的。
-
等客户端发了数据,
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 一群人分工——但叫号系统是同一套。
更多推荐



所有评论(0)