Post

从零理解 IO 多路复用和 Epoll——结合 Redis 实战讲解

从零理解 IO 多路复用和 Epoll——结合 Redis 实战讲解

这篇文章讲什么

用一篇文章把三个东西串起来讲清楚:IO 多路复用是什么、Epoll 为什么比 select/poll 强、Redis 怎么用这些机制做到单线程扛住几万并发连接。

读完你能回答:为什么 Redis 单线程还这么快?Epoll 到底快在哪?”IO 多路复用”里的”复用”到底复用了什么?


先搞清楚问题:一个服务端怎么同时跟多个客户端聊天?

写个最简单的 Echo 服务——客户端发一句话,服务端原样返回。一个客户端的时候,代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func handleConn(conn net.Conn) {
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)   // 阻塞等客户端发数据
        if err != nil { break }
        conn.Write(buf[:n])         // 原样返回
    }
}

func main() {
    ln, _ := net.Listen("tcp", ":6379")
    for {
        conn, _ := ln.Accept()     // 阻塞等下一个客户端连接
        go handleConn(conn)         // 每个客户端开一个 goroutine
    }
}

能跑,问题在哪?来一万个客户端,你就得开一万个 goroutine。 每个 goroutine 在 conn.Read() 上阻塞,大部分时间其实啥都没干,就干等着客户端发数据——占着栈内存、增加调度开销,纯浪费。

这个类比的边界:Go 的 goroutine 比系统线程轻很多,几万个 goroutine 不会直接 OOM,但调度开销和栈内存累积仍然不可忽略。C10K 问题在 C 语言时代用线程模型会直接爆炸——每个线程 8MB 栈,一万个就是 80GB。


IO 多路复用:一个线程盯多个连接

换个思路:与其每个连接派一个线程去傻等,不如一个人同时盯着所有连接,哪个有数据到了就去处理哪个。

这就是 IO 多路复用的核心思想——一个线程监听多个 IO 事件,谁就绪就处理谁。

原文此处有飞书嵌入内容,暂无法在博客中展示:查看原文。

一连接一线程 IO 多路复用
一万个连接 = 一万个线程 一万个连接 = 一个线程

“复用”到底是复用了什么?复用了线程。一个线程复用来处理所有连接的 IO 事件,而不是一对一绑定。


3. 怎么”同时盯着”?select、poll、epoll 三代进化

3.1 第一代:select——挨个点名

select 的思路简单粗暴:你告诉内核”帮我盯这 1000 个 fd,哪个可读了告诉我”。内核怎么做呢?

每次你调用 select,内核把你这 1000 个 fd 遍历一遍,逐个检查”你有数据吗?你没数据。你有数据吗?你没数据……”——O(N) 的遍历。

1
2
3
4
5
6
7
fd_set read_fds;
FD_ZERO(&read_fds);
for (int i = 0; i < 1000; i++) {
    FD_SET(fds[i], &read_fds);   // 把 1000 个 fd 塞进集合
}
select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 返回后,再遍历 1000 个 fd 看谁就绪了

三个硬伤:

问题 后果
fd 数量上限 1024 一台服务器最多只能同时盯 1024 个连接
每次调用都要把 fd 集合从用户态拷到内核态 连接越多,拷贝开销越大
内核 O(N) 遍历所有 fd 检查状态 一万个连接里只有 3 个有数据,也得全扫一遍

3.2 第二代:poll——不限数量,但还是笨

poll 基本等于 select,只是把 fd 集合从定长数组换成了动态数组,突破了 1024 的限制。但内核全量扫描的本质没变——你有 10000 个连接,内核还是得 10000 个遍历一遍。

3.3 第三代:epoll——内核主动通知

epoll 换了一个完全不同的思路。它不搞”你每次问我,我全量扫一遍”,而是在事件发生时由内核主动记录,你直接来取就行。

1
2
3
4
5
6
7
8
9
10
11
12
select/poll 模式(轮询检查):
你:这些 fd 有数据吗?
内核:(全扫一遍) 第 3 个和第 872 个有数据。
你:好的,我处理。下次再问……
内核:(又全扫一遍) ……

epoll 模式(事件驱动):
你:帮我盯着这些 fd,有数据了记下来。
内核:(事件发生) fd=3 可读,写进就绪列表。
      (事件发生) fd=872 可读,写进就绪列表。
你:给我就绪的 fd 列表。
内核:给你 [3, 872] —— 不用扫!

这个类比的边界:epoll 不是真的”主动推”事件。它也需要你调用 epoll_wait 去取,但它保证返回的列表里只有就绪的 fd,不需要你自己遍历全部连接来找谁就绪了。


Epoll 三个核心操作

1
2
3
int epoll_create(int size);                           // ① 创建一个 epoll 实例
int epoll_ctl(int epfd, int op, int fd, ...);        // ② 往实例里添加/删除要监听的 fd
int epoll_wait(int epfd, events, maxevents, timeout); // ③ 等事件,只返回就绪的 fd

4.1 epoll_create——在内核建一个”事件登记处”

调用后在内核分配一个 eventpoll 结构体,里面有两样东西:

  • 一棵红黑树: 存所有你注册的 fd(增删改查 O(logN))
  • 一个就绪链表: 存有事件发生的 fd(epoll_wait 直接取这个链表)

4.2 epoll_ctl——注册/修改/删除要盯的 fd

把 socket fd 注册到那棵红黑树里,同时告诉内核”当这个 fd 收到数据时,请回调我预留的处理函数”。这个回调函数做的事就一件:把该 fd 加入就绪链表。

4.3 epoll_wait——只取就绪的,不扫休眠的

1
2
3
4
5
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);  // 阻塞等事件
for (int i = 0; i < n; i++) {
    handle(events[i].data.fd);  // 只处理就绪的!n 通常只有几个
}

核心优势——O(1) 获取就绪事件。就绪链表里有多少取多少,不和总数挂钩。你盯着一万个连接,就 3 个有数据,epoll_wait 只返回 3 个。select 得扫一万个。

4.4 一张表说清三代差异

  select poll epoll
最大 fd 数 1024(硬限制) 无限制 无限制
获取就绪事件 O(N) 遍历全部 O(N) 遍历全部 O(1) 取就绪链表
每次调用的数据拷贝 拷全部 fd 集合到内核 拷全部 fd 集合到内核 仅在 epoll_ctl 时拷一次
内核实现 遍历检查 → 需要 O(N) 时间 遍历检查 → 需要 O(N) 时间 事件回调 + 就绪链表 → O(1)
适合场景 几十个连接 几百个连接 几万几十万个连接

结合 Redis 看 Epoll 的实际使用

Redis 是 IO 多路复用最经典的案例。它用单线程处理所有客户端命令,却能扛住几万个并发连接,核心原因就是 epoll。

5.1 Redis 的线程模型

很多人说”Redis 是单线程的”,这句话不够准确。准确说法:Redis 处理命令始终在单线程里,但从 6.0 开始,网络 IO 可以交给多个 IO 线程并行处理。

原文此处有飞书嵌入内容,暂无法在博客中展示:查看原文。

主线程用 epoll 事件循环驱动这一切,命令执行始终单线程。

5.2 主线程事件循环(核心骨架)

简化后的 Redis 主循环本质上是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void aeMain(aeEventLoop *eventLoop) {
    while (!stop) {
        // ① 在"处理时间事件"之前,先算一下 epoll_wait 应该等多久
        long wait_ms = aeSearchNearestTimer(eventLoop);

        // ② epoll_wait —— 阻塞等文件事件(客户端连接、读写等),最多等 wait_ms
        int n = aeApiPoll(eventLoop, wait_ms);
        for (int i = 0; i < n; i++) {
            // ③ 只处理就绪的那几个 fd
            handleReadyFileEvent(eventLoop->fired[i]);
        }

        // ④ 处理到期的定时任务(过期键删除、rehash、AOF 刷盘等)
        processTimeEvents(eventLoop);
    }
}

这就是 epoll 驱动的事件循环(Event Loop)。关键点在 aeApiPoll——底层就是 epoll_wait。

Redis 是真的只处理了”就绪的 fd”。 就算有两万个客户端连着,每轮循环 epoll_wait 通常只返回几十个有数据到达的连接,主线程扫一眼就处理完了。这解释了为什么单线程能扛高并发——它不是在 20000 个连接中找谁可读,而是 epoll 直接告诉它哪些可读。

5.3 主线程和 IO 线程怎么协作

以一次 SET key value 命令为例,拆成四步:

原文的四步流程是飞书嵌入内容,暂无法在博客中展示:查看原文。

为什么命令执行必须在单线程? 因为 Redis 的所有数据结构(string、hash、list、set、zset)都是内存里的,如果多线程并发修改,就得上锁——锁竞争会吃掉多线程带来的性能收益,而且 Redis 的命令执行本身是纯内存操作、极快(微秒级),并行化的收益不大。真正的瓶颈在网络 IO——读数据、写数据、解析协议——这才是 6.0 引入 IO 线程的原因。

🎓 面试追问: “Redis 6.0 不是变成多线程了吗,为什么还说它是单线程?” 标准答法——命令执行仍然是单线程的,多线程只用在网络 IO 上。 这样可以同时拿到两个好处:命令执行不用加锁(保证原子性、简化设计),网络 IO 却可以利用多核并行(解决单线程读写的瓶颈)。


Epoll 两种触发模式:LT vs ET

这是 Epoll 面试必考题。

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

只要缓冲区还有数据,每次 epoll_wait 都会通知你。Redis 用的就是这个。

1
2
3
客户端发了 "SET key value\r\n"(17 字节)
你第一次 read 只读了 5 个字节 "SET k" → 还剩 12 个字节
下次 epoll_wait → 又通知你 fd 可读 → 你再读 → "ey value\r\n"

优点:不容易丢数据,编程简单。缺点:如果一直不读,epoll_wait 会一直通知,可能造成空转。

边缘触发(Edge Triggered, ET)——高性能模式

只在状态变化时通知一次。缓冲区从空变非空时通知你一次,之后不管你有没有读完,不会再通知。

1
2
3
客户端发了 "SET key value\r\n"
第一次 epoll_wait → 通知你 fd 可读
你必须用 while 循环一次性把数据全读完,否则剩下的没人再通知你了

优点:减少不必要的通知,性能更高。缺点:必须用非阻塞 IO + 循环读完,漏了就丢数据。

  LT(水平触发) ET(边缘触发)
通知时机 缓冲区有数据就通知 缓冲区状态变化时通知一次
没读完怎么办 下次还通知 不会再通知,可能丢数据
编程难度 简单 复杂(必须非阻塞+循环读)
Redis 的选择 ✅ 默认用 LT 不用 ET
适合场景 对可靠性要求高的场景 高并发、追求极致性能

🎓 为什么 Redis 选 LT 不选 ET? Redis 追求的是可靠性 > 极致性能。LT 模式下少读几个字节下次还能接着读,不会丢数据。而且 Redis 主线程处理速度极快,LT 多通知几次的代价可以忽略。

补充材料《epoll-ET模式问题.md》见飞书原文。


常见误区

误区一:”epoll 是异步 IO”

不是。 Epoll 是 IO 多路复用,本质上是同步 IO。read/write 调用仍然会阻塞当前线程,只是你不会 read 一个没有数据的 fd——因为 epoll 已经告诉你哪些可读了。真正的异步 IO(AIO)是你发起 read 请求后直接返回,数据到了内核自动回调你的处理函数,中间完全不用阻塞。Linux AIO 一直不太成熟,直到 io_uring 才真正好用。

误区二:”epoll 比 select 快,是因为用了红黑树”

红黑树是原因之一,但不是最主要的原因。 最主要的原因是 epoll 不用每次全量扫描。select 不管有没有数据都要 O(N) 扫一遍;epoll 用回调 + 就绪链表,有数据才通知,没数据的 fd 完全不参与计算。

误区三:”Redis 单线程处理所有 IO”

到了 6.0 就已经不是了。网络读写交给了 IO 线程并行处理,主线程只负责事件循环的调度和命令的真实执行。


面试标准答法

Q: 什么是 IO 多路复用?

IO 多路复用就是一个线程通过系统调用(select/poll/epoll)同时监听多个 socket,哪个有数据来了就处理哪个,不用给每个连接各开一个线程。”复用”指的是复用一个线程来处理多个 IO 事件。

Q: epoll 为什么比 select 快?

Select 每次调用都要把全部 fd 集合从用户态拷到内核态,然后内核 O(N) 遍历检查谁就绪。连接多了之后拷贝和遍历开销线性增长。Epoll 用 epoll_ctl 把 fd 注册到内核的红黑树里(只注册一次),事件发生时通过回调把 fd 加入就绪链表,epoll_wait 直接从链表取就绪事件,O(1) 获取,不需要全量扫描。

Q: Redis 为什么单线程还这么快?

三点:(1) 纯内存操作,执行一条命令通常只需微秒级;(2) 用 epoll 做 IO 多路复用,一个线程高效处理几万个连接的读写事件;(3) 单线程避免了锁竞争和上下文切换,数据结构无需考虑并发安全。Redis 6.0 后网络 IO 可以用多线程,但命令执行始终单线程。

Q: Redis 6.0 的 IO 多线程怎么工作的?

主线程通过 epoll 拿到就绪的客户端连接列表,把”读数据+解析协议”的工作分发给 IO 线程并行处理。IO 线程完成后,主线程拿到解析好的命令,单线程逐条执行。执行完后,主线程再把”写回结果”的工作分给 IO 线程。命令执行始终单线程,所以不用加锁;网络读写利用多核并行,解决了单线程的 IO 瓶颈。

Q: Epoll 的 LT 和 ET 有什么区别?

LT(水平触发)只要缓冲区有数据就每次通知,不容易丢数据,Redis 用的就是这个。ET(边缘触发)只在缓冲区从空变非空时通知一次,必须用非阻塞 IO 一次性读完,否则剩余数据不会被再次通知。ET 性能更高但编程更复杂。


一句话总结

IO 多路复用解决的是”一个线程怎么同时管很多个连接”的问题。Epoll 是 Linux 上最高效的实现——用回调 + 就绪链表取代了全量扫描。Redis 是这个机制的教科书级用例:单主线程 + epoll 事件循环 + 可选的 IO 线程做并行读写,兼顾了简单性和高性能。

This post is licensed under CC BY 4.0 by the author.