网络(四):IO 模型、多路复用与高并发网络
网络(四):IO 模型、多路复用与高并发网络
导语:这一篇是「高并发服务为什么这么设计」的答案。先把五种 IO 模型与同步/异步、阻塞/非阻塞的判定标准讲清(这是最容易被问混的一题),再拆 select/poll/epoll 的实现差异与 epoll 高效的真实原因(顺带纠正流传极广的"mmap 共享内存"误解),然后讲 LT/ET、空轮询 bug、Reactor 三种模型,最后落到零拷贝与生产调优清单,共 10 题。
一、IO 模型
1. 有哪几种 IO 模型?同步/异步、阻塞/非阻塞到底怎么区分?
答: UNIX 定义了五种 IO 模型,关键前提是:一次 IO 操作分两个阶段。
| 模型 | 阶段一(等数据) | 阶段二(复制数据) | 说明 |
|---|---|---|---|
| 阻塞 IO(BIO) | 阻塞 | 阻塞 | read() 一直等到数据到达并复制完才返回 |
| 非阻塞 IO(NIO) | 不阻塞(立即返回 EAGAIN) | 阻塞 | 需要轮询重试,CPU 空转浪费 |
| IO 多路复用 | 阻塞在 select/poll/epoll_wait | 阻塞 | 一个线程同时监听多个 fd,就绪后再读;Reactor 的基础 |
| 信号驱动 IO | 不阻塞(注册 SIGIO,就绪时信号通知) | 阻塞 | 用得少(信号处理复杂、易丢失) |
| 异步 IO(AIO) | 不阻塞 | 不阻塞 | 发起后立即返回,内核完成全部工作后通知(io_uring、Windows IOCP) |
最关键的判定标准(面试必问,务必区分两组概念):
| 概念 | 判定问题 | 结论 |
|---|---|---|
| 阻塞 / 非阻塞 | 发起 IO 调用时,线程会不会被挂起? | 阻塞、非阻塞、多路复用、信号驱动在阶段一的阻塞行为不同 |
| 同步 / 异步 | 数据的复制(阶段二)由谁完成? | 只有 AIO 是异步(复制也由内核完成);前四种都是同步(read 的复制阶段由用户线程参与/等待) |
结论(背下来):
「阻塞/非阻塞」看第一阶段(等数据时线程是否挂起);「同步/异步」看第二阶段(复制由谁完成)。
所以:BIO、NIO、IO 多路复用、信号驱动 都是"同步 IO",只有 AIO 是真正的异步 IO。这也是为什么 Java 的 NIO(New IO)虽然名字带"非阻塞",但它仍然是同步 IO——它的Selector是同步多路复用。
Java 侧的对应关系(面试高频延伸):
| 模型 | Java 实现 |
|---|---|
| 阻塞 IO | java.net.Socket(BIO,InputStream.read) |
| 非阻塞 IO | SocketChannel + configureBlocking(false) |
| IO 多路复用 | Selector(底层 epoll) → Netty 的基础 |
| 异步 IO | JDK 7+ 的 AsynchronousSocketChannel(AIO);JDK 21 虚拟线程让"阻塞式写法"重新变得高效 |
为什么 Netty 用 epoll 而不是 AIO?(经典追问)→ 因为 Linux 的 AIO 长期不成熟(
LinuxAIO只对文件 IO 好,网络 IO 在 5.1 之前体验差),而 epoll 足够高效且可靠;加上 AIO 很难做零拷贝与顺序保证,生态与调试工具也差。Windows 上 Netty 才提供了 IOCP 支持。
2. select、poll、epoll 有什么区别?epoll 为什么高效?
答:
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 数量上限 | 1024(FD_SETSIZE,编译期写死) | 无限制(链表) | 无限制(受系统最大 fd 数) |
| 数据结构 | 位图 fd_set | 数组 pollfd[] | 红黑树(注册)+ 就绪链表 |
| 把 fd 传给内核 | 每次调用都要全量传入 | 每次调用都要全量传入 | epoll_ctl 注册一次即可 |
| 内核检测方式 | O(n) 轮询所有 fd | O(n) 轮询 | 事件回调,就绪时主动加入就绪链表 |
| 返回结果 | 返回后需遍历所有 fd 判断哪个就绪 | 同 select | epoll_wait 直接返回就绪的 fd 列表 |
| 内核→用户态拷贝 | 每次全量拷贝 fd 集(进出各一次) | 每次全量拷贝 | 只拷贝就绪的事件 |
| 复杂度 | O(n) | O(n) | O(1)(与监听总数无关,只与就绪数有关) |
| 触发模式 | 仅 LT | 仅 LT | LT / ET / EPOLLONESHOT |
| 跨平台 | 好 | 好 | Linux 专有(BSD 用 kqueue) |
epoll 的三步 API 与高效原理:
内核侧的工作方式(关键):
⚠️ 必须纠正的一个流传极广的错误说法:
「epoll 用 mmap 把内核的就绪队列映射到用户态,从而省去拷贝」——这是错的。
- 翻查内核源码,
epoll_wait是通过__put_user把就绪事件拷贝到用户态缓冲区的,没有使用 mmap 共享内存;- 它之所以"省",是因为拷贝的只是"就绪的子集"(通常几十个),而 select/poll 每次都要把整个 fd 集合拷进拷出(上万 fd 时拷贝本身就是灾难);
- 真正的高效来源是三点:① 注册一次免去重复传参;② 事件回调免去 O(n) 轮询;③ 就绪链表免去全量返回。
这个误区在很多博客里被反复复制,面试时说清楚会非常加分。
什么时候 epoll 反而不占优势(加分点):
- 几乎所有 fd 都活跃时(如短连接压测、代理转发),就绪事件接近全量 → 回调维护与事件拷贝的开销反而更大,此时 select/poll 的简洁实现未必更差;
- fd 数量很少时,epoll 的红黑树与回调注册是"过度设计",select 足够了;
- 结论:epoll 的优势场景是"海量连接 + 少量活跃"(典型的 C10K/C10M 长连接服务)。
3. epoll 的 LT(水平触发)与 ET(边缘触发)有什么区别?
答:
| 维度 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
| 通知时机 | 只要有数据/可写每次都通知 | 仅状态变化的那一次通知 |
| 编程复杂度 | 低(可分批处理) | 高(必须循环读到 EAGAIN) |
| 是否需要非阻塞 fd | 不一定 | 必须 |
| 事件次数 | 多(可能有重复通知) | 少(更省 CPU) |
| 丢事件风险 | 低 | 高(漏读就会"卡住") |
| 使用方 | 简单服务、默认选择 | Nginx、Netty(EpollEdgeTriggered)、Redis 等高性能框架 |
ET 的正确写法(伪代码,面试常让手写):
// 必须:① fd 设为非阻塞;② 循环读直到 EAGAIN
while (1) {
n = read(fd, buf, sizeof(buf));
if (n > 0) {
process(buf, n);
continue; // 继续读,直到读完
}
if (n == -1 && errno == EAGAIN) // ★ 读干净了,正常退出
break;
if (n == -1 && errno == EINTR) // 被信号打断,重试
continue;
if (n == 0) { // 对端关闭
close(fd);
break;
}
/* 其它 errno:真正错误 */
handle_error();
break;
}两个实践要点:
- ET +
EPOLLONESHOT:ET 模式下若把事件交给线程池处理,可能出现"同一 fd 被多个线程同时处理"。用EPOLLONESHOT让事件只触发一次,处理完后再epoll_ctl(MOD)重新注册,保证同一时刻只有一个线程处理该 fd; - Netty 的选择:Netty 默认使用 ET(Linux 下
EpollEventLoop),并配合非阻塞循环 +AbstractNioByteChannel的读循环,从而"用更少的事件获得 LT 的效果"。
4. 什么是 epoll 空轮询 bug?Netty 是怎么解决的?
答:
现象:在 Linux 上,JDK NIO 的 Selector.select() 有时会在没有任何事件就绪的情况下立即返回 0,导致 while(true) { select(); } 变成紧密的空转循环 → CPU 直接被烧到 100%。
根因(需要说清,体现深度):
Netty 的解决方案(rebuildSelector):
// NioEventLoop.run() 里的核心逻辑(简化)
int selectCnt = 0;
for (;;) {
int selectedKeys = selector.select(timeoutMillis); // 阻塞等待
selectCnt++;
if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks()) {
selectCnt = 0; // 有正常事件或任务,计数清零
break;
}
// ★ 空轮询检测:连续空转超过阈值(默认 512 次)就判定触发了 JDK bug
if (selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
rebuildSelector(); // 重建 Selector
selectCnt = 0;
// 之后重新注册到新 Selector,继续处理
}
}rebuildSelector() 做了什么:
关键参数与补充:
| 项 | 值 / 说明 |
|---|---|
| 阈值常量 | SELECTOR_AUTO_REBUILD_THRESHOLD,默认 512 |
| 是否可关闭 | -Dio.netty.noKeySetOptimization=true 或 -Dio.netty.selectorAutoRebuildThreshold=0 可关闭重建(不推荐) |
| 代价 | 重建 Selector 需要重新注册所有 Channel(毫秒级抖动),所以只在必要时触发 |
| 相关优化 | Netty 还通过 SelectedSelectionKeySet(用数组替代 HashSet) 减少遍历开销,这也是它比原生 NIO 快的细节之一 |
面试加分:可以顺带说「这个 bug 的本质是 JDK 把内核 epoll 的返回值与自身状态判断混在一起了,Netty 的做法是‘检测到异常就换一个干净的对象’,这是一种典型的兜底式防御编程」。
二、高并发网络模型
5. 什么是 Reactor 模型?有哪几种变体?
答: Reactor = "IO 多路复用 + 事件驱动 + 回调分发" 的组合模式,是高性能网络框架的通用骨架。
核心角色(三个):
| 角色 | 职责 |
|---|---|
| Reactor | 用 epoll 监听事件,分发给对应的 Handler(不处理业务) |
| Acceptor | 专门处理 accept 新连接 |
| Handler | 处理具体连接的读写与业务逻辑(业务常交给线程池) |
四种变体(按"Reactor 线程数 × 业务线程数"划分):
Netty 的对应关系(高频):
Nginx 与 Redis 的模型对比(常拿来一起问):
| 系统 | 模型 | 特点 |
|---|---|---|
| Nginx | 多进程 + 每进程"主从 Reactor"(epoll) | 多 worker 进程 + SO_REUSEPORT/accept mutex 避免惊群;worker 数 = CPU 核数 |
| Redis 6.0 之前 | 单 Reactor 单线程 | 命令在内存执行、极快,单线程避免锁;瓶颈是 CPU 单核与大 value |
| Redis 6.0+ | Reactor 多线程(IO 线程,命令执行仍单线程) | 用多线程处理网络读写与协议解析,命令执行保持单线程(不改语义) |
| Netty | 主从 Reactor 多线程 | Boss 负责 accept、Worker 负责读写、业务可交线程池 |
面试答题模板:先讲「Reactor 是什么(多路复用 + 分发)」,再讲「四种变体的瓶颈在哪」,最后落到「为什么 Netty 选主从 Reactor、为什么 Redis 命令仍单线程」。
6. IO 多路复用、多线程、多进程、协程该怎么选?
答: 这是C10K 到 C10M 的演进史,面试常问「为什么不用"每连接一线程"」。
| 模型 | 并发能力 | 优点 | 缺点 | 代表 |
|---|---|---|---|---|
| 每连接一进程 | 百级 | 隔离性最好 | 进程创建/切换开销巨大,内存占用高 | 早期 CGI、Apache prefork |
| 每连接一线程 | 千级 | 编程模型简单(阻塞式写法) | 线程栈(1MB)吃内存、上下文切换开销大、C10K 就崩 | Tomcat BIO、早期服务 |
| IO 多路复用(Reactor) | 万~百万级 | 单线程管海量连接、内存省、无锁 | 编程复杂(回调/状态机)、业务逻辑不能阻塞 | Netty、Nginx、Redis |
| 协程 / 虚拟线程 | 百万级 | 同步写法 + 异步性能(看起来像"每连接一线程",实际极轻量) | 生态与调试工具成熟度、阻塞调用会 pin 住载体线程 | Go goroutine、Java 21 虚拟线程 |
C10K 问题的三个解法演进:
选型建议(面试可直接给):
| 场景 | 推荐 |
|---|---|
| 连接数少、逻辑简单、开发速度优先 | 多线程 + 连接池(简单可靠) |
| 海量长连接(IM、推送、网关、代理) | IO 多路复用(Netty) |
| 海量连接 + 业务逻辑复杂(含大量阻塞调用) | 虚拟线程/协程(JDK 21+)或 Netty + 业务线程池 |
| 需要极致性能且团队有积累 | Netty(主从 Reactor)+ 定制协议 |
JDK 21 虚拟线程的实战坑(加分点):
synchronized会 pin 住载体线程(JDK 21 中synchronized代码块里阻塞会让虚拟线程无法卸载)→ 应改用ReentrantLock;JDK 24 起已改善;- 线程池要换成"每任务一虚拟线程"(
Executors.newVirtualThreadPerTaskExecutor()),不要用固定大小的池去装虚拟线程(会失去意义); - CPU 密集型任务不适合虚拟线程(它只解决"等待"问题,不增加算力);
ThreadLocal要慎用(虚拟线程数量巨大,ThreadLocal会放大内存)→ 改用ScopedValue。
三、零拷贝与调优
7. 什么是零拷贝?mmap、sendfile、splice 分别是什么?
答: 零拷贝(Zero-Copy)的目标是减少数据在内核态与用户态之间的拷贝次数与上下文切换次数(注意:不可能完全"零"拷贝,DMA 拷贝无法避免)。
先看传统 read + write 的 4 次拷贝、4 次切换:
四种零拷贝方案对比:
| 方案 | 拷贝次数 | 上下文切换 | 原理 | 适用 |
|---|---|---|---|---|
| 传统 read+write | 4(2 DMA + 2 CPU) | 4 | 见上图 | 需要对数据做修改时 |
| mmap + write | 3(2 DMA + 1 CPU) | 4 | mmap 把页缓存映射到用户空间,省掉"内核→用户"那次拷贝;但 write 时仍需"用户空间→socket 缓冲"的拷贝 | 需要在用户态读写/修改数据(如 Kafka 索引文件) |
| sendfile | 3(2 DMA + 1 CPU) | 2 | 数据全程不出内核:页缓存 → socket 缓冲(CPU 拷贝)→ 网卡(DMA);不需要用户缓冲区 | 纯转发(静态文件、代理) |
| sendfile + SG-DMA | 2(全 DMA) | 2 | 网卡/内核支持散聚 DMA(scatter-gather),只把"数据位置与长度"描述符传给网卡,连 CPU 拷贝也省掉 | 大文件、高吞吐(真正的"零 CPU 拷贝") |
| splice | 2(全 DMA) | 2 | 借助内核 pipe 在两个 fd 之间搬运,无需用户缓冲区;可把 socket 作为一端(splice 不要求文件描述符是文件) | 内核 ≥ 2.6.17;适合"管道式"转发 |
实战应用(面试常问"哪些系统用了零拷贝"):
| 系统 | 用法 |
|---|---|
| Nginx | sendfile on;(静态文件) + tcp_nopush、tcp_nodelay |
| Kafka | FileChannel.transferTo()(底层 sendfile) 做 Broker 到 Consumer 的日志传输;索引文件用 mmap |
| RocketMQ | mmap 映射 CommitLog(写入用内存映射,避免用户态拷贝) |
| Netty | FileRegion(默认底层 sendfile,可用 Epoll 的 FileRegion 走 sendfile);CompositeByteBuf 做"逻辑零拷贝"(避免合并缓冲区时的数据拷贝) |
| Java NIO | FileChannel.transferTo()/transferFrom() → sendfile;MappedByteBuffer → mmap |
三个易错点(务必说清):
- 零拷贝 ≠ 完全不拷贝:DMA 拷贝始终存在(硬件搬运),省掉的是 CPU 参与的拷贝与上下文切换;
mmap不等于 sendfile:mmap 省了 1 次 CPU 拷贝但上下文切换仍是 4 次,且mmap有缺页中断与"文件被截断时 SIGBUS"的风险;- Kafka 的"零拷贝"只在"消费端拉取已有日志"路径上成立(Producer 写入需要校验/转换,仍会经过用户态),所以准确说法是「Kafka 消费路径使用零拷贝」。
8. 一次磁盘文件经网络发送的完整数据路径是怎样的?
答: 这是把「零拷贝 + DMA + 内核缓冲」串起来的一道综合题。
| 步骤 | 操作 | 谁参与 |
|---|---|---|
| ① | 磁盘 → 页缓存 | DMA |
| ② | 页缓存 → 用户缓冲区(read 返回) | CPU(切换 2 次) |
| ③ | 用户缓冲区 → socket 发送缓冲(write) | CPU(切换 2 次) |
| ④ | socket 缓冲 → 网卡 | DMA |
用 sendfile 之后:
由此得出的性能结论(面试要点):
- 吞吐提升:省掉 2 次 CPU 拷贝与 2 次上下文切换,CPU 占用大幅下降(大文件尤其明显);
- 延迟降低:少了一次"内核→用户→内核"的往返;
- 内存占用降低:不再需要用户态的大缓冲区(大文件场景省下几百 MB);
- 代价:数据不能被用户态修改(需要修改就只能用 mmap 或传统方式)。
面试延伸:「为什么 Kafka 用零拷贝吞吐能到百万级?」→ ① 消费路径用
sendfile避免数据进入用户态;② 批量传输(攒批减少系统调用);③ 顺序写磁盘 + 页缓存;④ 分区并行。四点合起来才是高吞吐的原因,不要只答"零拷贝"。
9. 什么是 epoll 惊群?怎么解决?
答:
惊群(Thundering Herd):多个进程/线程同时等待同一个事件,当事件发生时,内核唤醒所有等待者,但只有一个能成功处理,其余白白被唤醒又睡回去 → 大量无效的上下文切换与 CPU 浪费。
两种典型场景:
解决方案(按推荐度):
| 方案 | 做法 | 说明 |
|---|---|---|
① SO_REUSEPORT(推荐) | 多个进程各自创建独立的监听 socket,内核在收包阶段就按哈希(四元组)把连接分发给其中一个 socket | 从源头消除惊群(不是"唤醒后再抢",而是"根本只唤醒一个");Nginx 1.9.1+ 支持;还能天然做负载均衡 |
② EPOLLEXCLUSIVE(Linux 4.5+) | 注册时加此标志,内核只唤醒"一个"等待者 | 适合"多个进程共享同一 fd"的场景;需注意与 EPOLLONESHOT 的配合 |
| ③ 单进程 accept + 分发(Reactor 惯用) | 由一个 MainReactor 专门 accept,把连接分发给 SubReactor | Netty 的主从 Reactor 就是这个思路——根本不让多个线程同时等 accept |
| ④ accept mutex(Nginx 传统做法) | worker 之间用共享锁串行化 accept | 只在 SO_REUSEPORT 不可用时使用;有锁竞争开销 |
| ⑤ 只在必要时唤醒(应用层判断) | 被唤醒后先判断"是否真能拿到资源",拿不到就立刻回去(避免无效操作) | 治标手段 |
⑥ EPOLLONESHOT | 事件只通知一次,处理完再重新注册 | 解决"同一 fd 被多线程同时处理"的问题(严格说不是惊群,但常一起考) |
SO_REUSEPORT 的两个注意点:
- 必须所有进程设置且
uid相同,否则会EADDRINUSE; - 内核按四元组哈希分发,所以连接是均匀的,但"长连接的工作量"可能不均(新连接数均衡,已有连接的工作量不均衡)——需要观察并可能需要"连接迁移"机制。
10. 高并发网络服务的 Linux 参数调优清单?
答: 面试问「如何把一台机器调到能扛十万/百万连接」时的标准答案,按连接建立 → 连接保持 → 资源上限三块组织。
【一、连接建立:别让新连接被丢】
net.core.somaxconn = 32768 # 全连接队列上限(★ 应用 listen backlog 也要同步调大)
net.ipv4.tcp_max_syn_backlog = 16384 # 半连接队列(SYN 队列)上限
net.ipv4.tcp_syncookies = 1 # 抗 SYN Flood(无需增大队列)
net.ipv4.tcp_abort_on_overflow = 0 # 队列溢出时丢 ACK 让客户端重试(对突发流量友好)
net.ipv4.tcp_fastopen = 3 # TFO:允许 SYN 携带数据(客户端+服务端都开)
【二、连接保持:别让连接被无谓消耗】
net.ipv4.tcp_tw_reuse = 1 # 客户端复用 >1s 的 TIME_WAIT(需 tcp_timestamps=1)
net.ipv4.ip_local_port_range = 10000 65535 # 扩大本地端口范围(缓解端口耗尽)
net.ipv4.tcp_max_tw_buckets = 262144 # ★ 只作为硬上限,**不要调小**(见《网络(二)》)
net.ipv4.tcp_keepalive_time = 600 # 缩短 Keepalive 探测起点
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_fin_timeout = 15 # FIN_WAIT_2 超时(不是 TIME_WAIT 时长!)
【三、资源上限:别被 fd / 内存卡住】
fs.file-max = 2000000 # 系统级 fd 上限
# ulimit -n 1000000(进程级,写入 /etc/security/limits.conf 或用 systemd LimitNOFILE)
net.ipv4.tcp_mem = 786432 1048576 1572864 # TCP 内存页阈值(低/压力/高)
net.core.rmem_max / wmem_max = 16777216 # 单 socket 缓冲区上限
net.ipv4.tcp_rmem = 4096 87380 16777216 # min/default/max(★ 保留动态调整)
net.ipv4.tcp_wmem = 4096 65536 16777216
net.core.netdev_max_backlog = 16384 # 网卡收包队列(配合多队列网卡/RPS)
【四、稳定性:别让内存/IO 拖累】
vm.swappiness = 1 # 尽量少用 swap(延迟敏感服务)
vm.dirty_ratio / dirty_background_ratio # 脏页回写,避免突发刷盘抖动
net.ipv4.tcp_slow_start_after_idle = 0 # 空闲后不重置 cwnd(长连接友好)
net.ipv4.tcp_congestion_control = bbr # 公网/有丢包链路可考虑 BBR(见《网络(二)》)
net.core.busy_poll / busy_read # 低延迟场景轮询收包(谨慎,吃 CPU)⚠️ 三个必须强调的"反模式"(体现经验):
| 反模式 | 危害 |
|---|---|
在 socket 上硬设 SO_RCVBUF/SO_SNDBUF | 会关闭内核的自动缓冲区调整(autotuning),导致大带宽链路吞吐反而下降 |
调小 tcp_max_tw_buckets | 破坏 TIME_WAIT 的保护作用,可能引发 RST 与旧报文误收(见《网络(二)》) |
调大 tcp_fin_timeout | 它控制的是 FIN_WAIT_2(不是 TIME_WAIT),改它无法缓解 TIME_WAIT 堆积 |
调优的原则(面试收尾句):
网络系列小结:(一)分层与基础协议 →(二)TCP 连接管理与可靠性 →(三)HTTP/HTTPS 与 Web 协议 →(四)IO 模型与高并发网络。四篇的主线是同一条:「数据如何在不可靠的网络上可靠传输 → 应用层如何定义语义 → 服务端如何用最少的资源扛住最多的连接」。
