主题
第 7 章 · 实战应用:Redis / Nginx / Netty 是怎么用 epoll 的
学完本章你将知道:业界最有代表性的三个高并发系统——Redis、Nginx、Netty——是怎么把 epoll 这个原始 syscall 包装成自己事件循环的;它们各自踩过什么坑、做过什么权衡;面试时谈到这些是绝对加分项。
1. 一句话开场
没有任何一个生产系统会让你"裸用"epoll 三剑客——它们都会在 epoll 之上构建事件循环(Event Loop)和Reactor 模式,把"哪个 fd 就绪 → 调用对应回调"这件事抽象起来。
看完这章你会发现:Redis、Nginx、Netty 三家的 epoll 用法各有不同——但底层套路都是一样的。
2. Reactor 模式:所有事件循环的共同祖宗
2.1 什么是 Reactor
Reactor 模式:一个事件分派器(Reactor)阻塞等待事件 → 事件来了根据类型分派给对应处理器。
核心思想:Reactor = epoll_wait 循环 + 事件 → 回调的映射表。
2.2 三种 Reactor 部署形态
text
① Single Reactor + Single Thread (单线程)
┌────────────────────────────────┐
│ Reactor (一个 epoll_wait 循环) │
│ ↓ ↓ ↓ │
│ Acceptor / Reader / Writer │
│ (全部在主线程同步执行) │
└────────────────────────────────┘
优点: 简单, 无锁
缺点: 一个慢请求拖死全部
代表: Redis (除了 6.0+ 的 IO threads)
② Single Reactor + Worker Thread Pool
┌────────────────────┐ ┌──────────────┐
│ Reactor 主线程 │ → │ Worker Pool │
│ (只 accept + read) │ │ (业务处理) │
└────────────────────┘ └──────────────┘
优点: 业务逻辑可以耗时
缺点: Reactor 仍是单点
③ Multi-Reactor (主从 / 多 Reactor)
┌──────────┐
│ Main │ ← 只负责 accept, 派给 sub
│ Reactor │
└────┬─────┘
↓ 分发新 fd
┌─────────┬─────────┬─────────┐
│ Sub │ Sub │ Sub │ ← 各自有自己的 epoll
│ Reactor │ Reactor │ Reactor │
└─────────┴─────────┴─────────┘
优点: 多核充分利用
代表: Nginx (master + workers), Netty3. Redis 的事件循环 (ae.c) ⭐
3.1 为什么 Redis 单线程也能扛 10 万 QPS?
关键:Redis 的"单线程"是指命令执行单线程——网络 I/O 是 epoll 多路复用的。
text
Redis 事件循环 (ae.c)
│
└─→ aeMain() {
while (!stop) {
aeProcessEvents(); // 一次 epoll_wait
}
}
aeProcessEvents() {
// 1. epoll_wait 等事件
numevents = epoll_wait(...);
// 2. 派发到对应回调
for each event in events:
if (mask & READABLE) fe->rfileProc(...) // readQueryFromClient
if (mask & WRITABLE) fe->wfileProc(...) // sendReplyToClient
}3.2 Redis 的 epoll 封装(ae_epoll.c)
Redis 把 epoll 封装成跨平台的 4 个函数:
c
aeApiCreate → epoll_create
aeApiAddEvent → epoll_ctl(ADD/MOD)
aeApiDelEvent → epoll_ctl(DEL/MOD)
aeApiPoll → epoll_wait设计巧妙之处:在 macOS 编译时这些函数会被替换成 kqueue 的对应实现,在 Solaris 上是
evport——业务代码不需要改一行。
3.3 为什么 Redis 用 LT 而不是 ET?
| 维度 | Redis 选择 LT 的理由 |
|---|---|
| 单线程 | 一次没读完下次再读,没有竞争问题 |
| 命令简单 | RESP 协议小包,一次 read 通常就够了 |
| 代码可读性 | LT 写法直观,不容易出错 |
| 性能够用 | Redis 瓶颈在 CPU 单线程,不在 epoll |
3.4 Redis 6.0+ 的 IO 线程
Redis 6.0 引入了 多线程 I/O——但仍只有一个事件循环,I/O 线程只做"读 socket / 写 socket"的网络拷贝,命令执行还是单线程。
text
[主线程] [I/O Threads]
epoll_wait
│
├─ 把 read 任务派给 IO 线程 ──→ 并行 read
│ │
│ ←─ 收齐所有 read 结果 ←─────────┘
│
├─ 主线程串行执行命令 (无锁,简单)
│
├─ 把 write 任务派给 IO 线程 ──→ 并行 write
└─ 收齐写完成设计哲学:把"瓶颈中的瓶颈"(网络拷贝)多线程化,但不动数据结构访问,避免引入复杂的锁。
4. Nginx 的多 worker + epoll ⭐
4.1 整体架构
text
Master Process
(管理, 不处理请求)
│
┌─────────┬──────┴──────┬─────────┐
↓ ↓ ↓ ↓
Worker 1 Worker 2 Worker 3 Worker 4
(epoll) (epoll) (epoll) (epoll)
↑ ↑ ↑ ↑
└─────────┴──── 接收客户端 ─┴─────────┘4.2 Nginx 用 ET 还是 LT?
Nginx 默认用 ET 模式 + 非阻塞 socket——它的核心代码 ngx_epoll_module.c 这样注册:
c
ee.events = EPOLLIN | EPOLLRDHUP | EPOLLET;为什么敢用 ET?因为 Nginx 的代码极度严谨——每个 read/write 都有循环到 EAGAIN 的处理,没有任何遗漏。
4.3 Nginx 怎么解决惊群?
方案 A:accept_mutex(默认)
多个 worker 都监听同一个 listen_fd——但 Nginx 给它套了一个进程间互斥锁:
text
worker1: lock(mutex)? → 拿到 → epoll_ctl(ADD listen_fd) → epoll_wait
worker2: lock(mutex)? → 拿不到 → 不监听 listen_fd, 只处理已有连接
worker3: lock(mutex)? → 拿不到 → 同上
→ 同一时刻只有 1 个 worker 在等新连接,避免惊群方案 B:SO_REUSEPORT(Linux 3.9+,推荐)
每个 worker 都 bind 同一个端口——内核做负载均衡:
c
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof(yes));text
内核维护 N 个独立 listen 队列,新连接哈希分到其中一个
→ 每个 worker 只处理自己的队列,零竞争业界数据:SO_REUSEPORT 比 accept_mutex 在多核机器上 QPS 高 30%+。
4.4 Nginx 的事件分发器抽象
c
ngx_event_actions_t ngx_event_actions = {
ngx_epoll_add_event, // 增加 fd
ngx_epoll_del_event, // 删除 fd
ngx_epoll_add_connection,
ngx_epoll_del_connection,
NULL, // notify (不用)
ngx_epoll_process_events, // epoll_wait 循环
ngx_epoll_init,
ngx_epoll_done
};同 Redis 一样,Nginx 也通过这层抽象实现跨平台——FreeBSD 上自动换成 kqueue 实现。
5. Netty 的 EventLoop(Java NIO) ⭐
5.1 Java 默认有什么?
Java NIO 提供了 Selector(它是 select/poll/epoll 的跨平台封装)——但默认实现性能不够好,Netty 自己写了一套基于 epoll 的 JNI 直调。
5.2 Netty 的两种 EventLoop 实现
| 类 | 底层 | 适用场景 |
|---|---|---|
NioEventLoop | Java Selector(跨平台) | 默认,跨 OS 通用 |
EpollEventLoop | 直接 JNI 调 epoll(仅 Linux) | 性能极致,Linux 推荐 |
5.3 Netty 的主从 Reactor
java
EventLoopGroup boss = new EpollEventLoopGroup(1); // 主 Reactor,只 accept
EventLoopGroup worker = new EpollEventLoopGroup(8); // 从 Reactor,处理 IO
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
.channel(EpollServerSocketChannel.class)
.childHandler(new MyHandler());text
EpollServerSocketChannel (listen fd)
│
Boss EventLoop (1)
(epoll_wait, accept)
│
↓ 派发新连接
┌───────────────┼───────────────┐
↓ ↓ ↓
Worker EL 1 Worker EL 2 ... Worker EL N
(epoll_wait) (epoll_wait) (epoll_wait)
(有自己的 epfd) (有自己的 epfd)5.4 Netty 的 ET 模式
Netty 的 EpollEventLoop 默认也用 ET + 非阻塞——通过 Java JNI 直接调 epoll,并实现了完整的"循环读到 EAGAIN"逻辑。
6. 实战架构对比表
| 维度 | Redis | Nginx | Netty |
|---|---|---|---|
| 事件循环数 | 1 主 + 多 IO 线程 (6.0+) | N worker,每个独立 | 1 boss + N worker |
| 触发模式 | LT | ET | ET |
| 惊群方案 | 不存在(单实例) | accept_mutex / SO_REUSEPORT | SO_REUSEPORT |
| 平台抽象 | ae.c (epoll/kqueue/evport) | ngx_event_actions (epoll/kqueue) | EventLoopGroup (epoll/NIO/kqueue) |
| 典型用途 | 内存数据库 | Web 反向代理 | 通信框架 |
7. 自己怎么写一个 Reactor?
7.1 最小骨架
c
typedef void (*event_handler_t)(int fd, void *ctx);
typedef struct {
int epfd;
event_handler_t handlers[MAX_FDS];
void *contexts[MAX_FDS];
} reactor_t;
void reactor_register(reactor_t *r, int fd, event_handler_t cb, void *ctx) {
r->handlers[fd] = cb;
r->contexts[fd] = ctx;
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = fd};
epoll_ctl(r->epfd, EPOLL_CTL_ADD, fd, &ev);
}
void reactor_run(reactor_t *r) {
struct epoll_event events[64];
while (1) {
int n = epoll_wait(r->epfd, events, 64, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
r->handlers[fd](fd, r->contexts[fd]);
}
}
}7.2 用法
c
reactor_t r;
r.epfd = epoll_create1(0);
reactor_register(&r, listen_fd, on_accept, NULL);
reactor_run(&r);
// on_accept 里:
void on_accept(int fd, void *ctx) {
int conn = accept(fd, NULL, NULL);
set_nonblock(conn);
reactor_register(&r, conn, on_read, NULL);
}
void on_read(int fd, void *ctx) {
char buf[1024];
while (read(fd, buf, sizeof(buf)) > 0) { /* echo */ }
}短短几十行,就有了 Redis / Nginx 同款的事件分派架构。
8. 业界最佳实践清单
| 实践 | 说明 |
|---|---|
| 优先用现成的事件库 | libuv (Node.js)、libevent、boost.asio——别自己造轮子 |
| Linux 上用 epoll,跨平台用 selector 抽象 | 不要写"if Linux else select" |
| 生产环境用 SO_REUSEPORT | 比 mutex 锁优雅、性能好 |
| ET 模式必须循环到 EAGAIN | 漏一次就丢事件 |
| 永远监听 EPOLLRDHUP | 否则可能一直 epoll 出来同一个已关闭的 fd |
| maxevents 设 64-1024 | 太小饥饿,太大延迟 |
| 配 ulimit -n 65535 | 否则 fd 用尽 |
| 多 worker 时关注 NUMA 亲和 | taskset 把 worker 绑核 |
9. 本章小结
text
工业级 epoll 用法精要
─────────────────────────────────
✅ 都用 Reactor 模式 (epoll_wait + 回调表)
✅ Redis: 1 主 + IO 线程, LT 模式
✅ Nginx: master + N worker, ET 模式 + SO_REUSEPORT
✅ Netty: 主从 Reactor, EpollEventLoop, ET 模式
✅ 跨平台: ae.c / ngx_event / NioEventLoop 抽象层
✅ 性能极限: 单机百万连接, 几十万 QPS10. 真实面试题(系统设计常考)
Q1:Redis 单线程为什么能这么快?
A:① 数据全在内存;② 数据结构 O(1) 设计(hash / skiplist / ziplist);③ 基于 epoll 的 I/O 多路复用——一个线程靠 epoll_wait 同时管理上万连接;④ 避免线程切换 + 锁竞争。6.0+ 引入 I/O 线程优化网络 IO,但命令执行仍是单线程。
Q2:Nginx 为什么用多进程而不是多线程?
A:① 进程隔离——一个 worker 崩了不影响其他;② 多核利用——一个 worker 独占一个核,配合 SO_REUSEPORT 内核负载均衡;③ 避免锁——每个 worker 内部全异步无锁,相比多线程少一层心智负担;④ cgroup / 资源控制更友好。
Q3:什么是 Reactor 模式?什么是 Proactor?
A:Reactor:事件分派器等到事件后通知用户线程"该读了"——用户自己 read(同步 I/O)。多路复用类(epoll)是 Reactor。 Proactor:内核自己完成 I/O,结果好了通知用户——用户拿到的就是数据。Windows IOCP / Linux io_uring 是 Proactor。
Q4:Netty 的 boss 和 worker 各做什么?
A:boss(通常 1 个)只负责 accept 新连接,把 SocketChannel 注册到某个 worker 的 EventLoop;worker(CPU 核数 × 2)跑各自的 epoll_wait,处理已建立连接的 read / write。这是经典的主从 Reactor 模式。
Q5:什么是 SO_REUSEPORT?解决了什么问题?
A:Linux 3.9+ 引入的 socket 选项——允许多个进程/线程 bind 同一个端口,内核为每个进程维护独立的 accept 队列,按 5 元组哈希做负载均衡。解决了惊群问题,且比应用层加锁性能好 30%+。Nginx、HAProxy、Envoy 都默认开。
Q6:业务低并发场景该用 epoll 吗?
A:不一定。如果连接数 < 100、业务逻辑简单:① 一连接一线程的阻塞 I/O 写起来更直观;② 异步代码的"颜色传染"反而拖累开发效率。epoll 是为高并发服务的,不是越多越好。
下一站 → 第 8 章:面试题集锦,把全系列高频面试题集中起来——含追问、陷阱、加分项。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗