概述
参考文档:
IO 模型回答一个问题:线程如何得知 fd 就绪、由谁完成数据搬运,Reactor 与 Proactor 则是多路复用与异步 IO 之上的服务器组织模式
一次网络读分两个阶段,同步与异步的分界线就在第二阶段:
flowchart LR A["网络读"] --> B["阶段一: 等数据到达内核缓冲区"] B --> C["阶段二: 数据从内核拷贝到用户空间"] C --> D{"拷贝由谁完成"} D -->|"用户线程"| E["同步: 阻塞、非阻塞、多路复用、信号驱动"] D -->|"内核"| F["异步: POSIX AIO、IOCP、io_uring"]本文脉络:五种 IO 模型、
select/poll/epoll的演进、Reactor 与 Proactor 模式,最后对照 Spring WebFlux(底层 Netty)与 Go netpoller 两个工业实现
五种 IO 模型
- 出自 Stevens《UNIX Network Programming》的经典划分,前四种都是同步 IO:
- 阻塞 IO:
recvfrom从等待到拷贝全程阻塞,最直观也最昂贵- 一连接一线程(thread per connection):万级连接就要万级线程,内存与上下文切换都撑不住,这就是 1999 年提出的 C10K 问题
- 非阻塞 IO:fd 设
O_NONBLOCK,没数据立即返回EWOULDBLOCK- 靠轮询兜底,空转烧 CPU,实践中只作为多路复用的配套手段
- IO 多路复用:一个线程用
select/poll/epoll同时盯一大批 fd,哪个就绪处理哪个- 等待被集中到一次系统调用上,单线程可撑起数万连接,是高性能网络服务的基石
- 信号驱动 IO:内核就绪时发
SIGIO,信号处理器里再发起读- TCP 的事件语义太模糊,实际多用于 UDP
- 异步 IO:
aio_read/io_uring/IOCP 把两个阶段全部交给内核,完成后才通知用户线程- 前四种就绪后仍要用户线程自己调
recvfrom完成拷贝,所以是同步;只有这种是真正的异步
- 前四种就绪后仍要用户线程自己调
- 阻塞 IO:
- 与进程管理里的进程状态联动:阻塞 IO 让线程睡在内核 wait 队列,多路复用把多次睡眠合并成一次
select、poll 与 epoll
select:fd 集合用位图fd_set表示- 上限
FD_SETSIZE(默认1024),每次调用全量拷贝进内核、返回后线性扫描,复杂度
- 上限
poll:换成pollfd数组,去掉 1024 上限,但每次仍要全量拷贝与线性扫epoll(Linux):把注册与等待拆开epoll_create建实例,fd 用红黑树管理,epoll_ctl增删改只做一次注册- 就绪的 fd 由内核回调挂入就绪链表,
epoll_wait只取就绪事件,开销与总 fd 数无关 - LT(水平触发)是默认,只要缓冲区还有数据就反复通知;ET(边缘触发)只在状态跃变时通知一次,必须循环读到
EAGAIN并配合非阻塞 fd,省掉重复唤醒,Nginx 与 Netty 的 native 传输都用 ET
- 平台对应:BSD/macOS 是
kqueue,Windows 是 IOCP,Linux 5.1 起的io_uring用共享提交/完成队列把 IO 做成真正的异步完成模型
Reactor 模式
Reactor 在一个或少数事件循环线程上阻塞等待多路复用事件,把就绪事件分发给对应的 handler,核心是把"等待"集中、把"处理"解耦
三个角色:
- 事件多路分发器(demultiplexer):即
epoll_wait这类调用 - 分发器(dispatcher):事件循环本身,把 fd 事件映射到回调
- 处理器(handler):连接上的读、编解码与业务逻辑
- 事件多路分发器(demultiplexer):即
三种线程模型,并发能力与复杂度递增:
- 单 Reactor 单线程:一个线程包揽 accept、读写与业务,Redis 6.0 之前即此形态,任何慢操作都会卡住全部连接
- 单 Reactor 多线程:事件循环只管事件,业务交给线程池
- 主从 Reactor:main reactor 只做
accept,把新连接分派给若干 sub reactor,各自维护自己的 epoll,Nginx 的多 worker 进程与 Netty 的 boss/worker 是同一形态:
flowchart LR L["listener fd"] --> B["main Reactor(boss): 只做 accept"] B -->|"新 conn 分派"| W1["sub Reactor(worker) #1"] B --> W2["sub Reactor(worker) #2"] W1 --> P1["pipeline: 解码 → 业务 → 编码"] W2 --> P2["pipeline: 解码 → 业务 → 编码"]
Proactor 模式
- 与 Reactor 的分界在通知的内容:
- Reactor 是就绪通知:“可以读了”,拷贝仍由用户线程发起
- Proactor 是完成通知:“已经读好放进缓冲区了”,内核完成两个阶段后才回调
- Proactor 依赖真正的异步 IO,Windows IOCP 是最完整的实现
- Linux 网络长期缺少可用的 kernel AIO,所以 Linux 高性能服务器主流是 Reactor 系;
io_uring补上了 completion 模型,新框架开始向 Proactor 靠拢 - Reactor + 非阻塞 IO 也能伪装出异步观感:回调里直接读反正不会阻塞,这就是 Netty 与 Node.js 的实际路线
Netty 与 Spring WebFlux
Netty 是 Java 生态的事实标准网络框架,也是 Spring WebFlux 默认的底层服务器:
EventLoopGroup分 boss 与 worker,对应主从 ReactorNioEventLoop包装 JDK Selector,EpollEventLoop直接走 native epoll,ET 模式- 读写事件流经
ChannelPipeline,由一组ChannelHandler责任链完成编解码与业务
Spring 的两条栈:
- Spring MVC:Servlet 容器(Tomcat)加大线程池,线程 per request,写同步代码最自然
- Spring WebFlux:默认跑在 Reactor Netty 上,只有与核数相当的少量 event loop 线程,控制器返回
Mono/Flux:
java@GetMapping("/users/{id}") public Mono<User> user(@PathVariable Long id) { return repository.findById(id); // 订阅由框架完成,handler 线程不被占用 }命名陷阱:Project Reactor(库)与 Reactor(模式)同名不同物,前者是 Reactive Streams 规范的实现,提供声明式流水线与背压(backpressure)
event loop 上任何一拍阻塞都会拖住整条 loop 的所有连接:JDBC、锁等待必须挪到专用线程池,Reactor 侧是
Schedulers.boundedElastic
Go 的 netpoller
Go 把 Reactor 藏进了运行时:网络轮询器(netpoller)内嵌在 runtime,底层同样是 epoll/kqueue/IOCP,调度侧见Go 调度器(GMP)
关键差异在 API 形态,Go 呈现阻塞式 API、非阻塞式成本:
conn.Read看起来在阻塞,实际发起非阻塞读,未就绪就把 G 挂起为_Gwaiting,不占用任何线程- 就绪后 netpoller 把 G 重新入队,M 继续执行后续代码,全程对程序员透明
一连接一 goroutine:G 起步栈约
2KB,十万连接十万 G 也可承受- 业务代码是普通的顺序读循环,不需要回调、pipeline 或事件循环:
gogo func() { buf := make([]byte, 4096) for { n, err := conn.Read(buf) // 未就绪时 G 挂起,M 去跑别的 G if err != nil { return } handle(buf[:n]) } }()与 Netty 对照:
- Netty 用回调与线程边界换显式控制,event loop 模型清晰但心智负担在框架层
- Go 用运行时调度换顺序心智模型,阻塞写法的隐患(G 泄漏、并发安全)转到语言层,见并发编程
服务端与客户端用法见网络编程基础