概述

  • 参考文档:

  • 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 让线程睡在内核 wait 队列,多路复用把多次睡眠合并成一次

select、poll 与 epoll

  • select:fd 集合用位图fd_set表示
    • 上限FD_SETSIZE(默认1024),每次调用全量拷贝进内核、返回后线性扫描,复杂度O(n)O(n)
  • 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):连接上的读、编解码与业务逻辑
  • 三种线程模型,并发能力与复杂度递增:

    • 单 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,对应主从 Reactor
    • NioEventLoop包装 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

  • Buffer 与 Channel 基础见Java IO,套接字编程见网络编程基础

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 或事件循环:
    go
    go 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 泄漏、并发安全)转到语言层,见并发编程
  • 服务端与客户端用法见网络编程基础