《Java 并发编程·性能诊断与事故》7.1 Reactor 的线程模型回顾

用一个 60 行以内的 JDK Selector 事件循环讲清 Reactor 的两个数据结构、非阻塞前提与背压本质,并用本机实测展示一次阻塞调用如何把 3 个并发请求的往返延迟从 5 ms 拉到约 50 ms,以及停止读取后 TCP 窗口如何在 2.9 MB 处关死。

承接的问题

构造场景:libraryd-conc 的借阅接口被接到一个事件循环网关上。压测时 200 QPS 一切正常,P99 是 18 ms;某天有人把一个「查库存」的实现从缓存换成了同步 JDBC 查询,平均耗时 8 ms。上线后 P99 跳到 2.1 s,吞吐掉到 90 QPS,CPU 只用了 1.2 个核——机器闲着,请求却全在排队。

这段代码放到 200 个平台线程的池子里毫无问题:8 ms 的阻塞只影响那一个线程。放到事件循环里就成了事故,因为循环线程只有一个,它停了,所有连接一起停。这不是 Reactor 的 bug,是线程模型的定价方式不同。

本节不写任何 Reactor 依赖代码,用 JDK 原生 Selector 搭一个最小事件循环,把三件事讲透:循环里到底有什么、为什么非阻塞是前提而不是建议、背压为什么最终是一个协议动作。最后把它与虚拟线程的「每请求一线程」放在一起对照。

事件循环只有两个数据结构

剥掉所有封装,一个事件循环就是一个线程 + 一个 Selector + 一个任务队列:

  • Selector 是内核就绪队列的用户态视图。select() 返回时,你只知道「这些 channel 现在可读/可写/可接受」,不知道数据有多少。
  • 任务队列放的是「已经就绪、但还不想在 IO 回调里做的事」。比如解码完成后的业务处理、定时任务、别的线程投递过来的关闭请求。

一次循环固定做四件事:select() 等事件、清空任务队列、处理就绪的 IO、回到 select()。下面这个类就是全部,60 行,去掉注释与空行不到 50 行:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;

/** 单线程事件循环:select 轮询 + 任务队列。仅用于对照,不是生产实现。 */
public final class MiniReactor implements AutoCloseable {
    private final Selector selector;
    private final ServerSocketChannel server;
    private final Queue<Runnable> tasks = new ConcurrentLinkedQueue<>();
    private final ByteBuffer buf = ByteBuffer.allocate(256);
    volatile boolean running = true;

    MiniReactor() throws IOException {
        selector = Selector.open();
        server = ServerSocketChannel.open();
        server.bind(new InetSocketAddress("127.0.0.1", 0));
        server.configureBlocking(false);
        server.register(selector, SelectionKey.OP_ACCEPT);
    }

    int port() throws IOException { return ((InetSocketAddress) server.getLocalAddress()).getPort(); }

    void execute(Runnable task) { tasks.add(task); selector.wakeup(); }

    /** 循环本体:一次 select,处理 IO,再清空任务队列。 */
    void loopOnce() throws IOException {
        selector.select();                       // 没有就绪事件时,线程停在这里
        for (Runnable task : tasks) task.run();  // 先跑队列任务
        tasks.clear();
        for (SelectionKey key : selector.selectedKeys()) {
            if (!key.isValid()) continue;
            if (key.isAcceptable()) accept();
            else if (key.isReadable()) read(key);
        }
        selector.selectedKeys().clear();
    }
    private void accept() throws IOException {
        SocketChannel ch = server.accept();
        ch.configureBlocking(false);
        ch.register(selector, SelectionKey.OP_READ);
    }

    private void read(SelectionKey key) throws IOException {
        SocketChannel ch = (SocketChannel) key.channel();
        buf.clear();
        int n = ch.read(buf);
        if (n < 0) { key.cancel(); ch.close(); return; }
        String msg = new String(buf.array(), 0, n).trim();
        if (msg.equals("SLOW")) sleepQuietly(50);   // 故意违反非阻塞要求
        ch.write(ByteBuffer.wrap(("ok:" + msg + "\n").getBytes()));
    }

    private static void sleepQuietly(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
    @Override public void close() throws IOException { running = false; selector.close(); server.close(); }
}

两点值得注意。第一,selector.select() 是阻塞的,但阻塞的是「等事件」,不是「等某一个连接的数据」——一个连接没数据不会拖住别人,这是它和「每连接一线程」最本质的区别。第二,execute() 里的 wakeup() 不是可选项:循环线程正卡在 select() 里时,别的线程往队列里放任务是叫不醒它的,wakeup() 才让 select() 立刻返回。

loopOnce() 内部没有检查停止标志,这是简化;真实实现要在每轮开头检查,否则 close() 之后线程还会再进一次 select() 并抛 ClosedSelectorException。我第一次跑这个示例时就吃了这个异常——外层循环只捕获 IOException 时,它会把堆栈打到控制台。

非阻塞不是一个建议,是循环的前提

把上面的 read() 原样用起来,写一个四客户端的对照实验:第 0 个客户端发 SLOW(服务端处理时 sleep(50)),另外三个延迟 5 ms 发送 FAST,每个客户端记录从写入到读到响应的墙钟。

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.concurrent.CountDownLatch;

public final class HolDemo {
    public static void main(String[] args) throws Exception {
        try (MiniReactor r = new MiniReactor()) {
            Thread loop = Thread.ofPlatform().name("event-loop").start(() -> {
                try { while (r.running) r.loopOnce(); } catch (Exception ignored) { }
            });
            String[] msgs = {"SLOW", "FAST-1", "FAST-2", "FAST-3"};
            long[] done = new long[msgs.length];
            CountDownLatch started = new CountDownLatch(1);
            for (int i = 0; i < msgs.length; i++) {
                final int idx = i;
                Thread.ofVirtual().start(() -> {
                    try (SocketChannel c = SocketChannel.open(new InetSocketAddress("127.0.0.1", r.port()))) {
                        started.await();
                        if (idx > 0) Thread.sleep(5);      // 让 SLOW 先进入处理
                        long t0 = System.nanoTime();
                        c.write(ByteBuffer.wrap(msgs[idx].getBytes()));
                        c.read(ByteBuffer.allocate(64));
                        done[idx] = (System.nanoTime() - t0) / 1_000_000;
                    } catch (Exception e) { done[idx] = -1; }
                });
            }
            started.countDown();
            Thread.sleep(400);
            for (int i = 0; i < msgs.length; i++) {
                System.out.printf("%-6s roundTrip=%dms%n", msgs[i], done[i]);
            }
            r.close();
            loop.join(1000);
        }
    }
}

本机一次运行的输出:

SLOW   roundTrip=61ms
FAST-1 roundTrip=54ms
FAST-2 roundTrip=53ms
FAST-3 roundTrip=54ms

三个 FAST 请求本该在 5 ms 内往返,实际都等了 53–54 ms——因为循环线程正卡在 SLOW 的 sleep(50) 里,select() 根本没有被调用,内核里那三个可读事件没人取。一次 50 ms 的阻塞,代价不是 50 ms,而是「这段时间内所有连接的处理都推迟 50 ms」。同一程序另一次运行是 SLOW=55 ms、三个 FAST 均为 47 ms;单次运行、随调度波动,不作基准,量级关系是确定的。

这条结论在线程转储里看得更直接。同一个程序,空闲时循环线程是这样:

"event-loop" #25 [27139] prio=5 os_prio=31 cpu=0.35ms elapsed=2.35s runnable
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.KQueue.poll(java.base@25.0.5/Native Method)
	at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@25.0.5/KQueueSelectorImpl.java:121)
	at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@25.0.5/SelectorImpl.java:130)
	at sun.nio.ch.SelectorImpl.select(java.base@25.0.5/SelectorImpl.java:147)
	at BlockedLoop.lambda$main$0(BlockedLoop.java:16)

一旦循环线程进了阻塞的业务调用,栈顶换成了业务方法,select 整段消失:

"event-loop" #25 [26627] prio=5 os_prio=31 cpu=0.48ms elapsed=2.25s waiting on condition
   java.lang.Thread.State: TIMED_WAITING (sleeping)
	at java.lang.Thread.sleepNanos0(java.base@25.0.5/Native Method)
	at java.lang.Thread.sleepNanos(java.base@25.0.5/Thread.java:509)
	at java.lang.Thread.sleep(java.base@25.0.5/Thread.java:540)
	at BlockedLoop.handleReservation(BlockedLoop.java:30)
	at BlockedLoop.lambda$main$0(BlockedLoop.java:19)

判据很简单:事件循环线程的栈里没有 select/poll 帧、栈顶是业务方法,就是有人在循环里做了阻塞调用。本机是 macOS,Selector 的实现类是 sun.nio.ch.KQueueSelectorImpl(Selector.open().getClass() 实测输出),所以原生帧是 KQueue.poll。Linux 上实现类是 EPollSelectorImpl,转储里出现的是 epoll 相关帧而不是 KQueue——这一点按平台实现推导,本机未验证。照抄「看到 epollWait 就是空闲、看到业务帧就是阻塞」这条经验时,先确认自己在哪个平台。

背压是协议问题,不是实现问题

很多人对背压的理解是「给队列设个上限」。上限有用,但它解决的是「内存别炸」,不解决「上游该慢下来」。真正的背压必须让发送方感知到接收方处理不过来,而在 TCP 上,唯一能让发送方感知的信号是:接收方不读,窗口关小,最后关死。

这一点可以单独测量:接收端 accept 之后故意 1.2 秒不读,发送端用非阻塞 channel 尽可能写,每 500 ms 打印一次被内核接受的字节数。

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public final class BackpressureProbe {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel server = ServerSocketChannel.open();
        server.bind(new InetSocketAddress("127.0.0.1", 0));
        int port = ((InetSocketAddress) server.getLocalAddress()).getPort();
        Thread consumer = Thread.ofPlatform().name("consumer").start(() -> {
            try (SocketChannel ch = server.accept()) {
                Thread.sleep(1200);                       // 故意 1.2 秒不读
                ByteBuffer in = ByteBuffer.allocate(256 * 1024);
                long total = 0;
                while (ch.read(in) > 0) { total += in.position(); in.clear(); }
                System.out.println("consumer 共读 " + total + " 字节");
            } catch (Exception ignored) { }
        });

        SocketChannel client = SocketChannel.open(new InetSocketAddress("127.0.0.1", port));
        client.configureBlocking(false);
        ByteBuffer chunk = ByteBuffer.allocate(64 * 1024);
        long accepted = 0, target = 64L * 1024 * 1024, t0 = System.nanoTime(), nextReport = 0;
        while (accepted < target && System.nanoTime() - t0 < 6_000_000_000L) {
            chunk.clear();
            int n = client.write(chunk);
            if (n > 0) accepted += n;
            else if (n == 0) Thread.sleep(1);              // 窗口满,写不进去
            long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
            if (elapsedMs >= nextReport) {
                System.out.printf("t=%4dms accepted=%9d%n", elapsedMs, accepted);
                nextReport += 500;
            }
        }
        consumer.join(15_000);
        client.close();
        server.close();
    }
}

本机一次运行:

t=   0ms accepted=    65536
t= 500ms accepted=  2864132
t=1000ms accepted=  2864132
consumer 共读 67112300 字节

发送端在 2,864,132 字节处彻底停住,整整 500 ms 一个字节都没写进去;接收端 1.2 秒后开始读,剩余 64 MB 随即全部流过去。同一程序另一次运行在 2,920,228 字节处停住——这个数是 macOS 的发送缓冲加接收窗口自动调整的结果,换机器、换内核参数、换一次运行都会变,不要当成常量,但「不读 → 窗口关死 → write 返回 0」的机制是 TCP 规范决定的。

所以背压的正确位置在协议里:接收方处理不过来时,停止注册 OP_READ,让窗口自然关小;等自己的出站队列降到水位线以下,再重新注册。做这件事的前提是协议允许「暂时不收」——HTTP/1.1 一个连接一个请求,不收就是不响应,上游会超时;HTTP/2 有流控窗口,可以只停某条流;自定义协议则必须自己定义「暂停/恢复」。这就是为什么说背压是协议问题:实现层只能决定「读多快」,协议层才能决定「让对方等多久、等的时候语义是什么」。

与虚拟线程「每请求一线程」的对照

虚拟线程把「阻塞」的价格改写了:一个虚拟线程在 socket 读上阻塞时,载体线程会被卸下来去跑别的虚拟线程,阻塞不再独占一个平台线程。事件循环则根本不允许阻塞,它把「等待」折叠进 select()。

维度事件循环(Reactor)虚拟线程每请求一线程
调度单位就绪事件一个请求一个虚拟线程
阻塞的代价整个循环停摆只停自己,载体线程被回收
线程数固定,约等于核数请求数级,但载体仍约等于核数
栈回调之间不保留调用栈保留完整调用栈,可读
上下文传递显式参数或 context 对象原线程内直接用;跨线程仍要显式传递
背压必须显式设计(停读、流控)阻塞线程天然限住并发,但仍需准入控制
调试栈是框架帧,业务帧被拆散栈是连续的业务调用链

最后一行值得展开。事件循环为了不阻塞,会把「读一半」「处理一半」的状态放进对象里,回调返回后栈就没了;出问题时你看到的是一个「状态机卡住」,不是一条调用链。虚拟线程没有这个问题,代价是每个请求一份栈——JDK 25 的虚拟线程栈按需增长,空栈很便宜,但不是零。

两者也不是互斥的。网关层用事件循环扛连接数,业务层把阻塞调用丢进虚拟线程或线程池,是常见的混合形态;7.3 专门讲这种栈的边界与调试。

还要纠正一个常见误解:虚拟线程不等于无限线程。载体线程默认并行度等于 availableProcessors(本机 10),而且按需创建——本机只跑两个忙碌虚拟线程时,转储里只有 ForkJoinPool-1-worker-1 和 -2 两个载体。10 万虚拟线程同时做 CPU 计算,和在 10 个平台线程上做没有区别。

在 libraryd-conc 里落地

判断一个服务该不该上事件循环,看三个条件,缺一个就别上:

  1. 全链路非阻塞。从 socket 到数据库、到下游 HTTP 客户端,任何一处是同步阻塞的,循环就会被它拖住。只要有一个 JDBC 驱动,就不满足。
  2. 团队能维护背压。要有人能回答「队列满了之后协议上怎么办」,而不是加个 Integer.MAX_VALUE 了事。
  3. 单机连接数确实是瓶颈。几千连接、每连接低速率,事件循环有明显优势;几十个连接、每个都在算,它没有优势。

三条都满足时,事件循环的收益是「用 10 个线程扛 10 万连接」;有一条不满足,收益就会被「一次阻塞拖垮全部」的风险抵消。虚拟线程让第 1 条不再必要:连接数的问题它也能解,且保留同步写法和连续栈。

常见错误与边界

错误后果正确做法
在循环线程里调 JDBC / 同步 HTTP所有连接一起等丢到独立线程池,回调里再切回循环
只给队列设上限,不做停读上限一到就丢请求或 OOM队列到水位就注销 OP_READ,靠窗口背压
用 execute() 却不 wakeup()任务要等下一次 IO 事件才执行入队后调用 selector.wakeup()
把 CPU 密集计算放在循环线程事件处理被拉长,P99 抖动计算移出循环,或换成虚拟线程
以为 select() 返回就有完整报文半包解析错乱按协议长度字段攒包,状态挂在连接上
在 macOS 上照抄 epollWait 判据找不到该帧,误判循环空闲先确认 Selector 实现类与原生帧名

什么时候不要用事件循环:团队里没人能画出「队列水位 → 停读 → 恢复」的状态机;调用链上有任何一处无法改成异步的阻塞 API;请求的处理时间远大于等待时间(CPU 密集)。这三种情况下,虚拟线程或传统线程池的维护成本更低,尾延迟也更可控。

小结

事件循环的全部内容是两个数据结构:内核的 Selector 就绪队列和用户态的任务队列。它的前提是非阻塞——本机实测,一次 50 ms 的阻塞让三个本该 5 ms 完成的请求都等到 47–54 ms,因为 select() 在那 50 ms 里没有被调用。转储上的判据是栈里有没有 select/poll 帧;本机 macOS 的实现是 KQueueSelectorImpl,空闲帧是 KQueue.poll,不是 epollWait。

背压不是「队列设上限」,而是「接收方停止读取,让 TCP 窗口关死」:本机实测发送端在 2.86–2.92 MB 处停住 500 ms,接收端一开始读就全部放行。实现层能决定读多快,只有协议层能定义「让对方等」的语义。

与虚拟线程相比,两者不是快慢之争,而是把阻塞定价在不同位置:事件循环不许阻塞,虚拟线程让阻塞变便宜。下一节把七个维度摊开,做一张能直接用于选型的矩阵。

阅读导航:上一节 6.3 连接池、ThreadLocal 与 MDC | 下一节 7.2 两种模型的取舍矩阵

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收