1. 为什么 IO 是性能瓶颈
1.1 IO 等待主导延迟
绝大多数服务的延迟来自等 IO:网络收发、磁盘读写、数据库往返。CPU 很快,IO 很慢——一个网络请求的耗时中,真正 CPU 计算的占比可能不足 10%。
# 一次网络请求的时间构成(示意)
# 建连 + 读写: 数百微秒~毫秒级
# 应用处理: 微秒级
# → 优化 IO 模型,比优化业务代码更能提吞吐
理解 IO 模型的核心变量:线程如何等待 IO 完成。是阻塞等、轮询查、还是被通知?不同模型在「高并发 + IO 密集」下的吞吐天差地别。
2. 阻塞与非阻塞 IO
2.1 阻塞 IO
线程发起读,数据未就绪时挂起等待,就绪后返回。简单直观,但一个线程同时只能服务一个连接——高并发下线程爆炸。
# 阻塞读(伪代码)
# data = read(fd) // 数据未到,线程阻塞在这里
# handle(data) // 只有数据到了才继续
# 成本: 每个连接要一个线程;大量线程 = 上下文切换风暴
2.2 非阻塞 IO
读不到数据时立即返回错误(EAGAIN),线程不挂起。但它需要轮询:反复调用 read 直到成功,CPU 空转严重。
# 非阻塞读(伪代码)
# while True:
# data = read_nonblock(fd) // 无数据立即返回 EAGAIN
# if data: handle(data); break
# // 否则继续轮询 —— CPU 忙等,浪费
非阻塞本身不解决问题,它需要配合多路复用——让内核一次通知「哪些 fd 可读可写」,避免逐 fd 轮询。
3. 同步 IO 与异步 IO
3.1 同步 vs 异步的本质区别
- 同步 IO:IO 完成后,调用线程自己处理数据。阻塞与非阻塞都是同步(数据由发起方读走)。
- 异步 IO(AIO):内核把数据准备好后回调处理函数,发起方完全不用等。
# 同步: 线程 read → 内核拷贝数据到线程 buffer → 线程处理
# 异步: 线程提交 aio_read → 内核完成后调回调 → 线程做别的事
# 关键: "是否等待数据就绪" 是同步异步的分界;"是否阻塞线程"是阻塞非阻塞的分界
3.2 四象限总结
| 阻塞 | 非阻塞 | |
|---|---|---|
| 同步 | 阻塞 IO(线程挂起等) | 非阻塞 IO(轮询,配合多路复用) |
| 异步 | — | 异步 IO(内核回调,如 Windows IOCP、Linux io_uring) |
工程现实:Linux 高并发服务主流是「同步非阻塞 + 多路复用」(Nginx/Redis/Netty);真异步 AIO 的 io_uring 正在普及但生态仍在成熟。
4. IO 多路复用:select / poll / epoll
4.1 思想
多路复用让一个线程同时监视成千上万个 fd,内核通知「哪些 fd 就绪」,应用只处理就绪的 fd。核心收益:用 1 个线程服务 N 个连接。
4.2 三代的演进
| 机制 | 就绪通知方式 | 复杂度 | 规模上限 |
|---|---|---|---|
| select | 每次调用把 fd 集合拷入内核,遍历检查 | O(n) | 1024(FD_SETSIZE) |
| poll | fd 数组传入,同样每次遍历 | O(n) | 无上限但仍是 O(n) |
| epoll | 内核维护就绪链表,只返回就绪 fd | O(就绪数) | 数十万级 |
# epoll 三步
# epoll_create() 建立实例
# epoll_ctl(ADD) 注册要监视的 fd + 事件
# epoll_wait() 等待就绪,返回就绪 fd 列表(只遍历就绪的)
epoll 的优势:O(1) 复杂度(相对就绪数)、事件驱动、可水平触发(LT)与边缘触发(ET)。ET 更高效但要求一次性读完,处理不当易丢事件——Nginx/Redis 默认 LT 或谨慎用 ET。
5. 事件驱动模型:Reactor / Proactor
5.1 Reactor:事件分发
**Reactor(反应器)**把「等待事件 + 分发 + 处理」解耦:主线程阻塞在 epoll_wait,有就绪事件就分发给 handler 处理。
# Reactor 结构
# Main Reactor(1): epoll_wait 监听 accept 与读写事件
# Sub Reactor(N): 处理具体连接的读写
# 业务 handler: 处理数据
# 代表: Nginx master+worker、Netty Boss/NioEventLoop、Redis 单线程
Redis 单线程为何能抗高并发?因为它是「单 Reactor + 事件驱动 + 内存操作」:无锁、无上下文切换,事件循环足够快。
5.2 Proactor:真异步
Proactor 提交异步操作,内核完成后回调——理论上吞吐更高(不占线程等 IO),但实现复杂、平台依赖(Windows IOCP 成熟,Linux 靠 io_uring 推进)。工程上Reactor 生态最成熟,是首选;Proactor 只在特殊场景(大量大文件 IO)才值得。
6. 零拷贝:绕过应用内存
6.1 sendfile / mmap
零拷贝让数据从磁盘到网卡不经应用内存拷贝,是文件/网络传输提速的关键:
# 传统读+写: 磁盘 → 页缓存 → 应用 buffer → socket buffer → 网卡(多次拷贝)
# sendfile: 磁盘 → 页缓存 →(DMA 直达)网卡(内核一次拷贝或零拷贝)
# mmap: 把文件映射进应用地址空间,读写直接操作页缓存
Kafka 消费、Nginx 静态文件、数据库日志传输,都靠 sendfile/mmap 把吞吐推到磁盘/网络带宽上限。注意零拷贝要求数据在页缓存(热数据),冷数据回读会退化。
7. 高性能框架的 IO 架构
7.1 三个代表
- Nginx:多进程 + 每进程单 Reactor,事件驱动处理连接与静态文件(sendfile)。
- Netty:Boss/NioEventLoop 多 Reactor + pipeline 处理器链,是 Java 高并发网络服务的基石(Dubbo/gRPC 底层)。
- Redis:单线程 Reactor + 内存数据结构,事件循环内只做快操作,慢命令会阻塞整站。
# Netty 的架构心智
# BossGroup: 处理 accept(连接建立)
# WorkerGroup: 处理读写事件(NioEventLoop 循环)
# 每个 NioEventLoop 绑定一个 epoll,串行处理该连接的事件
# → 单连接内无并发,无需加锁
共同规律:「单线程事件循环」串行处理同一连接的事件,天然免锁;把慢操作(DB、外部 IO)异步化或交给额外线程池,避免阻塞事件循环。
8. 选型决策清单
# IO 模型选型
# 1) 连接少、并发低: 阻塞 IO + 线程池,简单够用
# 2) 连接多、IO 密集: Reactor + epoll(主流,首选)
# 3) 大文件/高带宽传输: 零拷贝(sendfile/mmap)叠加
# 4) 极致异步、Windows 生态: IOCP/Proactor
# 5) Linux 真异步前沿: io_uring(生态渐进)
建议:不要为了「先进」选模型。业务连接量 < 万级,传统阻塞 + 连接池完全够;只有「C10K/C100K 级、IO 密集」才值得上 Reactor。高并发框架(Netty/Nginx)已经封装好,直接用比手写更稳。
9. 常见陷阱
- 非阻塞但没配多路复用:纯轮询空转 CPU,比阻塞还差。
- ET 模式没读完:边缘触发只通知一次,读一半就丢事件——要么读尽,要么回退 LT。
- 阻塞事件循环:在 Reactor 的 handler 里做同步 IO/耗时计算,堵住整个循环。
- 零拷贝的前提:数据不在页缓存时零拷贝失效,评估数据热度再用。
参考文章
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。