io - multiplexing - event - loop
01. IO 多路复用与事件循环
0. 本章先解决什么问题
Redis 单线程为什么快?
NGINX 为什么几个 worker 能扛几万连接?
Netty 的 EventLoop 是什么?
背到第三遍我才反应过来,这三个组件讲的是同一件事:
连接很多,但大部分连接大部分时间都在等数据。
让一个线程盯住所有连接,谁就绪处理谁。
这个机制在 Linux 上叫 epoll,架在它上面的编程模式叫事件循环。
IO 模型 那一章讲过阻塞、非阻塞、多路复用的概念框架。
本章是它的工程上的应用:
先把 select、poll、epoll 三个系统调用逐个拆开,
再把 Redis、NGINX、Java NIO、Netty、Tomcat、Kafka 六个组件的网络模型逐个讲透。
-
上半部分是机制本身:大量连接注册进 epoll,内核把就绪的挑出来,事件循环逐个分发。
-
下半部分是四个组件对同一机制的包装:差异只在于事件循环有几个、慢任务丢给谁。
1. 问题的起点:一个连接一个线程撑不住
最直观的服务器写法,每来一个连接就开一个线程去 read:
代码块收起展开
// 最朴素的 BIO 服务器
while (true) {
Socket socket = serverSocket.accept(); // 等新连接(阻塞)
new Thread(() -> {
InputStream in = socket.getInputStream();
in.read(buf); // 等数据(阻塞,线程睡在这)
handle(buf);
}).start();
}一万个连接就是一万个线程,开销如下:内存、线程
- 内存: 每个线程默认约 1 MB 栈,一万连接光栈就 10 GB
- 切换: 线程由内核调度,一次上下文切换微秒级,
线程越多,CPU 花在”换人”上的比例越高 - 浪费: 这些线程绝大多数时间睡在 read 上等数据,
占着资源不干活
把”等待”这件事交给了线程。
解决之策是反转职责:线程不再各自守着一个连接等,而是去问内核”连接里哪个就绪了”。
这个”问”的三代实现就是 select、poll、epoll。
2. select:第一代,位图加全量遍历
代码块收起展开
// 概念签名,重点看数据结构
int select(int nfds,
fd_set *readfds, //读事件集合
fd_set *writefds,//写事件集合
fd_set *exceptfds,
struct timeval *timeout);
// fd_set 是一个 1024 位的位图,第 n 位代表 fd=n
一次调用的完整流程:
- 用户态时将需要监控的fd标记置位, 拷贝整个位图到内核态
- 内核从0到nfds遍历, 检查是否有驱动就绪, 就绪的位图置位拷贝到用户态
- 用户态遍历一次位图找出就绪fd
- 下次调用, 重新构造位图并全量拷贝
三个硬伤全在数据结构上:
- 位图定长1024, 连接数太少了
- 两次全量拷贝+内核态和用户态切换 ==> 开销太大
3. poll:第二代,只解决了上限
代码块收起展开
struct pollfd {
int fd;
short events;
short revents;
};
int poll(struct pollfd *fds, //数组
nfds_t nfds,
int timeout);把位图换成数组,
注册的事件(events)和返回的事件(revents)分了字段,数组不用每次重建,1024 上限没了。
但每次调用全量拷贝数组、内核全量遍历这两条原样保留,还是 O(总连接数)。
连接几千之后,CPU 大头花在”检查谁就绪”而不是”处理数据”上。select 和 poll 的共同病根:内核不记事,每次都要从头告诉它我关心什么。
4. epoll:第三代,让内核记住关心列表
epoll 的改进思路一句话:与其每次全量问,让内核长期记住我关心什么,谁就绪谁主动登记。为此把一个调用拆成三个:
代码块收起展开
int epoll_create(int size); // 建实例:内核里的登记处
int epoll_ctl(int epfd, int op, // 增删改关心的 fd
int fd, struct epoll_event *event);
int epoll_wait(int epfd, // 收就绪列表
struct epoll_event *events,
int maxevents,
int timeout);
内核侧的结构是理解一切的钥匙:
-
每个 epoll 实例维护两个结构:
兴趣列表: 红黑树,存所有epoll_ctl注册进来的 fd
(红黑树是为了 ctl 的增删改查 O(log n),注册一次长期有效)
就绪列表: 双向链表,存已经就绪、等着被取走的 fd -
注册时: 内核在该 fd 的等待队列上挂一个回调
-
数据到达时: 网卡中断 -> 协议栈处理 -> 唤醒 fd 等待队列
-> 回调被触发,把这个 fd 挂进就绪链表 -
epoll_wait: 就绪链表非空就直接返回它;空则睡眠,
有 fd 进链表时被唤醒。只拷贝就绪的这几个
对比一下成本结构的变化:
- select/poll: 每次调用 = 全量拷贝 + 全量遍历,O(总连接数)
- epoll: 注册一次,之后每次 wait 只处理就绪的,O(活跃连接数)
十万连接挂着、一百个在说话,epoll_wait 每次只碰这一百个。这正好命中服务器的真实负载形态:连接多,活跃少。这也是为什么连接数少且都很活跃时 select 并不吃亏(反正都要全量处理),epoll 的优势专属于”海量空闲连接”场景。
顺带把 中断 那章接上了:数据到达是网卡中断驱动的,epoll 的就绪登记就挂在中断处理链上。硬件层的”事件通知”和应用层的”事件循环”是同一根线的两端。
4.1 LT 和 ET:两种通知语义
水平触发 LT(默认): 只要缓冲区还有数据没读完,每次 wait 都报告它
边缘触发 ET: 仅在状态从”无数据”变成”有数据”的那一刻报告一次
ET 少了重复通知,代价是一条铁的编程纪律:
收到可读事件后必须循环 read 直到返回 EAGAIN(读干净)
否则: 缓冲区剩下的数据不会再触发新通知,
这个连接看起来就”死”了,直到对端发新数据
(ET 还必须配非阻塞 fd,否则最后一次 read 会把线程挂住)
真实组件的选择和理由:
| 组件 | 模式 | 理由 |
|---|---|---|
| Redis | LT | 事件处理器逻辑简单,宁可多收几次通知,不背”必须读干净”的纪律 |
| NGINX | ET | 追求极致吞吐,减少重复通知,读干净的循环自己写 |
| Java NIO Selector | LT | 跨平台抽象要语义保守稳定 |
| Netty epoll 传输 | ET | 自己管理读循环和缓冲区,敢用激进语义 |
5. 事件循环:多路复用之上的编程模式
epoll 通知”谁就绪了”,
怎么组织代码去处理,就是事件循环(Reactor 模式):
代码块收起展开
while (running) {
timeout = 距离最近的定时任务还有多久
events = epoll_wait(epfd, ..., timeout) //阻塞到返回事件集
for (e : events) e.handler.process() // 遍历处理事件集
processExpiredTimers() // 顺路处理到期定时任务
}两个设计点:
-
定时任务不需要独立线程: 把”最近一个定时器还有多久”
算成 epoll_wait 的超时参数,醒来顺手执行到期的。一个线程同时伺候 IO 事件和时间事件 -
铁律: 循环体里不许有慢操作。
一个 handler 卡 100 ms,这个循环管的所有连接集体延迟 100 ms
所以每个真实组件都要回答同一道题:慢任务放哪里。下面六个组件,每家一节。
6. Redis:一个事件循环包办一切
6.1 ae 循环逐步拆解
Redis 的网络模型就是一个裸事件循环(源码 ae.c,逐行拆解见 ae事件循环):
aeMain 每一圈:
- beforeSleep: 睡前杂务
把上一圈产生的回复写回客户端(先尝试直接写,写不完才注册写事件)
处理集群、过期等杂项 - aeApiPoll: 封装 epoll_wait(跨平台,Linux 用 epoll,
macOS 用 kqueue),超时 = 最近的时间事件还差多久 - 处理就绪的文件事件:
可读 -> 读入查询缓冲区 -> 解析 RESP 协议 -> 执行命令
-> 结果写进该客户端的输出缓冲区
可写 -> 把输出缓冲区继续往 socket 送 - 处理时间事件 serverCron(默认每秒 10 次):
主动过期一批 key、渐进式 rehash 步进、
触发 BGSAVE/AOF 重写判断、统计与心跳
命令的解析、执行、回复全在这一个线程里。它敢这么做的完整理由:
- 业务是纯内存操作,单条命令微秒级,符合快进快出
- 单线程免掉所有锁和上下文切换
- 数据结构可以按单线程假设做到极简
(dict 的渐进式 rehash 就是明例: 敢把搬迁拆碎到每次操作里,
因为不存在并发访问两张表的竞态) - 瓶颈本来就在内存和网络,不在 CPU,多线程换不来吞吐
6.2 铁律的两次让步
单线程的软肋是”任何慢操作都是全局停顿”,Redis 历史上打的补丁全在把慢操作搬出主线程:
Redis 的线程模型经历了三次扩展:
- 后台线程
bio(很早就有):负责fsync、关闭大文件描述符。 - Redis 4.0 lazy free:
UNLINK/FLUSHALL ASYNC先摘引用,再把大对象的内存释放交给后台线程。DEL一个百万成员集合可能卡主线程几百毫秒,而UNLINK只做轻量摘链。 - Redis 6.0 I/O 线程:连接数极大时,socket 读写与协议解析本身成为瓶颈。
- 主线程攒一批就绪连接,派给 I/O 线程并行读取、解析。
- 主线程逐个执行命令;命令执行仍是单线程,因此无需为数据操作加锁。
- I/O 线程并行写回响应。
I/O 线程默认关闭(io-threads 1),只有大流量实例才值得打开。
所以”Redis 6.0 多线程”改的只有 IO 搬运,命令执行的单线程骨架没动。日常慢操作黑名单同理成立:keys *、大集合的 SMEMBERS、未加 COUNT 的大 SCAN、百万级 DEL,全都是在主线程上做长任务。
展开版在 yori - 内存 & 网络模型。
7. NGINX:每个 CPU 核一个事件循环
NGINX 是代理,自己几乎没有业务计算,纯搬运工,它的答案是把事件循环整个复制 N 份:
master 进程: 不接流量。读配置、管理 worker、平滑重启
(reload 时旧 worker 处理完存量连接才退,新 worker 接新连接)
worker 进程 x N(worker_processes,通常 = CPU 核数):
每个 worker 单线程,一个 epoll(ET)事件循环
各自 accept、各自读写、互不共享连接状态
理论容量 = worker_processes x worker_connections
worker 数为什么等于核数:worker 是纯 CPU + 事件驱动的,一个核跑一个刚好吃满,多了互相抢核反而引入切换。这和”IO 密集型线程池要开大”的直觉相反,原因是 NGINX 的 worker 从不阻塞等 IO,等待全部托付给了 epoll。
多个 worker 同时监听一个端口引出惊群问题:
惊群: 一个新连接到达,所有 worker 都被唤醒去抢 accept,
只有一个抢到,其余白醒一场,连接风暴时浪费可观
解一 accept_mutex: worker 之间抢一把锁,持锁者才把监听 fd
挂进自己的 epoll,轮流坐庄
解二 SO_REUSEPORT(现代默认方向): 每个 worker 各自 bind 同一端口,
内核按四元组哈希直接分流,从根上没有共享监听队列
对照配置笔记 NGINX:worker_processes、worker_connections、reuseport 这些指令,每一个都对应上面结构里的一个零件。
8. Java NIO:Selector 就是 epoll 的 Java 名字
Java 1.4 的 NIO 三件套,对着内核概念一一映射:
Channel ~ fd(FileChannel、SocketChannel、ServerSocketChannel)
Buffer ~ 读写用的用户态缓冲区(position/limit/capacity 三指针)
Selector ~ epoll 实例(Linux 实现类 EPollSelectorImpl,
windows 上是别的实现,接口不变)
标准使用骨架,和第 5 节的伪代码逐行对应:
代码块收起展开
Selector selector = Selector.open(); // epoll_create
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT); // epoll_ctl
while (true) {
selector.select(); // epoll_wait
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须手动移除,NIO 不自动清
if (key.isAcceptable()) { // 新连接就绪
SocketChannel c = server.accept();
c.configureBlocking(false);
c.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) { // 数据就绪
read(key); // 非阻塞读 + 处理
}
}
}直接用原生 NIO 写产品级服务器的坑不少(selectedKeys 手动清理、写事件的注册取消时机、半包粘包全要自己管),所以生产里几乎总是用 Netty。从 NIO 写起的完整入门代码在 Netty 01 - NIO。
9. Netty:主从 Reactor 加连接绑定
Netty 把第 5 节的事件循环产品化成 EventLoop,并做了两层分工:
bossGroup(主 Reactor): 一小撮 EventLoop,只管监听端口的 accept,
接到新连接后把它注册给 workerGroup 里的某个 EventLoop
workerGroup(从 Reactor): 默认 CPU 核数 x 2 个 EventLoop,
每个 EventLoop = 一个线程 + 一个 Selector + 一个任务队列
固定负责一批连接的读写、编解码、handler 链执行
两条设计规则值得单独记:
- 连接终身绑定一个 EventLoop:
一个 Channel 的所有事件永远在同一个线程里跑
-> 单连接内天然串行,pipeline 上的 handler 不需要加锁
-> 把 Redis”单线程无锁”的好处按连接粒度复制了出来 - EventLoop 的任务队列:
其他线程想操作某个 Channel(比如业务线程池算完了要写回),
不直接动,而是 channel.eventLoop().execute(task) 投递进队列,
由绑定线程自己执行。跨线程协作靠消息传递,不靠共享加锁
慢任务的去向同样是铁律的落实:编解码这类快操作留在 EventLoop;查库、调远程接口这类耗时业务,要么 pipeline 上指定业务线程池(addLast(businessGroup, handler)),要么 handler 里自己提交线程池,算完投回 EventLoop 写响应。
赖在 EventLoop 里同步查库,是 Netty 应用最经典的事故模式:表现为这个 EventLoop 名下所有连接一起超时。
10. Tomcat:多路复用只用一半
Tomcat 的业务形态和前面几家都不同:Servlet 里要查库、调接口,天然是阻塞逻辑,塞不进事件循环。所以 Tomcat NIO 连接器只用多路复用做”等待”,处理仍然交线程池:
一个请求进来的完整接力:
LimitLatch: 连接数闸门(maxConnections,默认 8192,超了不再 accept)
Acceptor 线程: 阻塞式 accept 新连接,包装后交给 Poller
Poller 线程: 持有 Selector,监听所有连接的读就绪
就绪的连接包成任务,丢进 Executor
Executor 线程池: 真正读请求、解析 HTTP、执行 Servlet/业务代码
(maxThreads 默认 200,排队上限 acceptCount)
对比收益看得很清楚:BIO 时代一个连接从建立到关闭独占一个线程,长连接空闲也占着;NIO 之后,空闲连接只在 Poller 的 Selector 里挂个号,线程只在”有请求正在处理”时被占用。
等待不占线程了,但处理仍是一请求一线程,所以 Tomcat 的并发上限还是 maxThreads 和业务耗时决定的吞吐,事件驱动只救了”海量空闲长连接”这一头。
源码走读见 请求处理链,线程池的定制细节(为什么 Tomcat 改了 JUC 线程池的排队策略)见 tomcat线程池。
11. Kafka broker:同一骨架的三段式
Kafka 服务端的网络层是标准的主从 Reactor 变体,配置项直接暴露结构:
Acceptor 线程: 每个监听端口一个,只管 accept,
轮询分发新连接给 Processor
Processor(num.network.threads,默认 3): 各持一个 Selector,
负责一批连接的读写和请求组装,组好的请求进全局请求队列
KafkaRequestHandler(num.io.threads,默认 8): 从请求队列取任务,
执行真正的读写日志、副本管理,结果回给 Processor 写出
和 Tomcat 的结构几乎同构(等待层 + 处理层),差异在处理层干的是磁盘顺序读写而不是业务代码。网络线程和 IO 线程分开配置,等待和处理可以按各自瓶颈独立扩容。
12. 一张对照表收拢
| 组件 | 事件循环数量 | 慢任务去向 | 为什么这样设计 |
|---|---|---|---|
| Redis | 1 个 | bio 后台线程、lazy free、6.0 IO 线程 | 业务是微秒级内存操作,单线程换无锁 |
| NGINX | 每核 1 个进程 | 基本没有慢任务 | 纯转发,复制循环即可横向扩展 |
| Java NIO | 自己写多少是多少 | 自己安排 | 原材料,不是成品 |
| Netty | boss 几个 + worker 每核约 2 个 | 业务线程池 | 连接绑定 EventLoop,单连接无锁 |
| Tomcat | Poller 少量 | Servlet 线程池(200) | 业务是阻塞式的,复用只管等待 |
| Kafka broker | network 线程组 | io 线程组 | 网络和磁盘按各自瓶颈独立扩容 |
记法:先记”全是 epoll + 事件循环”,再记每家对”慢任务放哪”的回答。回答由业务形态决定(内存操作、纯转发、阻塞业务、磁盘读写),机制本身从没变过。
13. 联系实际:事件循环系统的排查地图
-
现象:所有连接一起变慢,而不是某个连接慢
-
方向:事件循环被卡住了
Redis -> SLOWLOG 找慢命令(keys、大集合操作、大 key DEL)
Netty -> jstack 看 EventLoop 线程在执行什么(同步查库最常见)
NGINX -> 查同步磁盘 IO、超大配置 reload -
现象:CPU 不高,吞吐上不去,连接数很大
-
方向:单个事件循环到顶了
代码块收起展开
Redis -> IO 瓶颈开 io-threads;计算瓶颈只能拆实例或上集群
Netty -> 加大 workerGroup;查连接是否哈希不均挤在少数 EventLoopNGINX -> 核对 worker_processes 是否等于核数、是否开 reuseport
-
现象:Tomcat 连接建立快,响应慢
-
方向:瓶颈不在事件层在处理层
-> 看 Executor 活跃线程数是否顶到 maxThreads,
顶到了就是业务耗时问题,调大线程数只是给排队换个地方 -
现象:偶发一批连接同时超时,过后自愈
-
方向:某个循环被一次性长任务暂停
Redis 的 fork(BGSAVE 瞬间)、Netty 的 Full GC、
NGINX reload 的旧 worker 收尾,都长这个样子
判断问题在”循环”还是在”任务”,是这类系统排障的第一分叉;第二分叉是”哪一个循环”(连接分布不均时只有一部分连接遭殃)。