《Java 网络编程基础》7.1 非阻塞 accept、read 与 write

非阻塞通道上的 accept、read、write 不再阻塞,返回值变成 null、0 和 -1 三种信号。本节用 linkd 的 NonBlockingEchoServer 实测这三种返回值,讲清读循环、写队列与 OP_WRITE 开关,以及漏摘 OP_WRITE 导致 CPU 空转的排查方法。

本节目标:把 configureBlocking(false) 之后的三种返回值读对,知道什么时候该继续读、什么时候该等事件,以及写队列和 OP_WRITE 怎么配合。
适用版本:JDK 25(Temurin 25.0.5+8);SocketChannel、ServerSocketChannel、Selector。

承接的问题

6.3 把 accept 和读写拆到了不同的 Reactor 线程,但事件循环里真正干活的仍然是 read 和 write。这两个调用在阻塞模式下的直觉是「要么成功,要么抛异常」;切到非阻塞之后,多出了第三种结果:什么也没发生。

linkd 的 Echo 服务在压测时很容易遇到两类现象:客户端一次性发 32 KB,服务端回显同样的 32 KB,某些连接的回显少了一截,日志里没有任何异常;另一种情况则反过来,事件循环 CPU 跑满,read 却一直是 0。前者的常见原因是 write 返回了 0,代码把它当成了「写完了」;后者通常是 OP_WRITE 注册后忘了摘掉。

这两个问题都不是协议问题,而是没有把非阻塞返回值当成状态信号。本节先把 accept、read、write 的返回值逐个跑出来,再看 NonBlockingEchoServer 怎么把读循环、写队列和兴趣位接起来。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。macOS 的 Selector 底层是 kqueue,本节不做 epoll 实测。

核心机制一:阻塞变成返回值

SocketChannel 和 ServerSocketChannel 默认是阻塞的。configureBlocking(false) 之后,内核不会让调用线程睡眠,而是立刻返回当前能完成的部分。三种调用的返回值语义如下:

调用阻塞模式非阻塞模式
accept()等到有新连接没有待处理连接时返回 null
read(buf)等到至少 1 字节或 EOF没有数据返回 0;对端 FIN 返回 -1
write(buf)至少写出 1 字节或抛异常发送缓冲区满返回 0;否则返回写入字节数

关键区别是 read 的 0 和 -1:0 表示「现在没有」,-1 表示「永远没有」。把 0 当 EOF 会提前关闭空闲连接;把 -1 当「暂时没有」会让连接永远不释放。write 的 0 则表示内核发送缓冲区已满,剩余字节必须留在用户态,等下一次可写事件。

用一个最小探针把三种返回值跑出来。服务端和客户端都走 loopback,服务端非阻塞,发送缓冲区故意设成 1 KB:

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

public class NonBlockingProbe {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel server = ServerSocketChannel.open();
        server.configureBlocking(false);
        server.bind(new InetSocketAddress("127.0.0.1", 0), 128);
        int port = ((InetSocketAddress) server.getLocalAddress()).getPort();

        System.out.println("accept no client = " + server.accept());

        SocketChannel client = SocketChannel.open();
        client.connect(new InetSocketAddress("127.0.0.1", port));
        client.setOption(StandardSocketOptions.SO_RCVBUF, 1024);

        SocketChannel accepted = null;
        for (int i = 0; i < 100 && accepted == null; i++) {
            accepted = server.accept();
            if (accepted == null) Thread.sleep(5);
        }
        accepted.configureBlocking(false);
        accepted.setOption(StandardSocketOptions.SO_SNDBUF, 1024);

        ByteBuffer small = ByteBuffer.allocate(64);
        System.out.println("server read, no data yet = " + accepted.read(small));

        client.write(ByteBuffer.wrap("hello".getBytes()));
        Thread.sleep(50);
        small.clear();
        int first = accepted.read(small);
        small.flip();
        byte[] got = new byte[small.remaining()];
        small.get(got);
        System.out.println("server first read = " + first + " bytes, payload=" + new String(got));
        System.out.println("server read, drained = " + accepted.read(small.clear()));

        ByteBuffer big = ByteBuffer.allocate(1 << 20);
        int total = 0;
        int n;
        while ((n = accepted.write(big)) > 0) {
            total += n;
        }
        System.out.println("write returned " + n + ", written=" + total + ", remaining=" + big.remaining());

        client.shutdownOutput();
        Thread.sleep(100);
        small.clear();
        System.out.println("server read after peer FIN = " + accepted.read(small));
        client.close();
        accepted.close();
        server.close();
    }
}

本机输出(单次运行,未做统计):

accept no client = null
server read, no data yet = 0
server first read = 5 bytes, payload=hello
server read, drained = 0
write returned 0, written=1024, remaining=1047552
server read after peer FIN = -1

逐行对应六个事实:

  • 没有客户端时 accept() 直接返回 null,线程没有被挂起。
  • 对端还没发数据时 read 返回 0,连接是活的。
  • 数据到达后第一次 read 返回 5,flip() 之后才能从缓冲区取字节。
  • 数据读空后再次 read 又返回 0,这就是「读到 EAGAIN」。
  • 1 MB 的 write 只写出了 1024 字节就返回 0,剩余 1047552 字节还在用户态缓冲区里。
  • 对端 shutdownOutput() 发 FIN 后,read 返回 -1。

发送缓冲区设为 1 KB 是为了让 write 尽早返回 0;实际环境中缓冲区大小由内核和 SO_SNDBUF 共同决定,不能拿 1024 当固定值。能复现的是「write 可能返回 0」这个事实,不是具体数字。

核心机制二:一次就绪要把数据读到 EAGAIN

Selector 告诉你有数据可读,只是说「至少有一个字节」。如果你只读一次就回去 select(),内核里如果还有数据,下一次 select() 会立刻再返回同一个 key。这不会丢数据,但会让事件循环反复空转,而且每次只搬一小块,吞吐上不去。

正确的读循环是读到 read 返回 0 或 -1 为止:

int n;
while ((n = c.channel().read(buf)) > 0) {
    // buf 写满后 flip 交给下游,再 clear 继续读
}
if (n == -1) {
    c.close();
}

这里有两个边界:

  • 缓冲区写满时循环会停不下来。 如果 buf 没有剩余空间,read 返回 0,循环退出,但数据其实还在内核里。要么在循环里 flip 消费,要么换一块新的缓冲区。7.2 的 AccumulatingReader 会在容量不足时扩容,解决的正是这一点。
  • 不能让一个连接独占事件循环。 如果对端持续高速发送,读到 EAGAIN 可能要搬几十 MB,其他连接会被饿死。生产实现通常给每次事件设一个预算(例如 64 KB),用完就重新注册兴趣位,把执行权还给 select()。

read 返回 -1 之后不要继续读。通道已经到流末尾,再读仍然返回 -1;此时应该走关闭流程,而不是把它当成「暂时没有数据」。

核心机制三:写队列与 OP_WRITE 的开关

非阻塞 write 返回 0 时,剩下的字节不能丢,也不能在原地自旋重试。做法是把 ByteBuffer 放进连接自己的写队列,等 OP_WRITE 就绪再继续。

linkd 的约定是:Handler 只调用 Connection.enqueue(ByteBuffer out),不直接碰 SocketChannel.write。enqueue 的语义是「接管这个缓冲区,尽量写,写不完排队」,实现体就是 7.2 的 WriteQueue。事件循环在每次可写事件里排空队列,队列空了就摘掉 OP_WRITE:

// EventLoop 内部:写队列非空才关注 OP_WRITE
key.interestOps(queue.isEmpty()
        ? SelectionKey.OP_READ
        : SelectionKey.OP_READ | SelectionKey.OP_WRITE);

这两行是整个非阻塞写路径的核心。OP_WRITE 和 OP_READ 不一样:只要发送缓冲区还有空间,OP_WRITE 就一直就绪。如果队列排空后不摘掉它,select() 会立刻返回,事件循环变成忙等,CPU 单核跑满,而连接上什么也没发生。这类问题在 top 里表现为 java 进程 100%,线程栈却停在 KQueue.poll 附近,看起来像「卡在 select」,实际是 select 根本等不住。

反过来,队列非空却不注册 OP_WRITE,数据就会一直躺在用户态,直到下一次可读事件碰巧触发写操作。低流量连接上表现为「响应偶尔延迟几秒」,高流量连接上则可能永远不发。

写队列还要有上限。对端不读、本端一直 enqueue,队列会无限增长,最后 OOM。7.2 的 WriteQueue 用 highWaterMark 拒绝超额入队,14.1 会把水位信号暴露给业务层做背压。

在 linkd 里落地:NonBlockingEchoServer

linkd-app 里的 NonBlockingEchoServer 承担两件事:用独立的 accept 线程接收连接,作为 Handler 处理每个连接的读事件。accept 线程和各工作 EventLoop 通过 EventLoop.register(SocketChannel, Handler) 交接,和 6.3 的主从 Reactor 对应。

package com.plume.linkd.app;

import com.plume.linkd.core.BufferPool;
import com.plume.linkd.core.Connection;
import com.plume.linkd.core.EventLoop;
import com.plume.linkd.core.Handler;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

public final class NonBlockingEchoServer implements Handler {

    private static final int READ_CHUNK = 4096;
    private static final int MAX_READ_PER_EVENT = 64 * 1024;

    private final EventLoop loop;
    private final BufferPool pool;
    private final ServerSocketChannel listener;
    private volatile boolean running = true;

    public NonBlockingEchoServer(EventLoop loop, BufferPool pool, int port) throws IOException {
        this.loop = loop;
        this.pool = pool;
        this.listener = ServerSocketChannel.open();
        this.listener.configureBlocking(false);
        this.listener.bind(new InetSocketAddress("127.0.0.1", port), 256);
    }

    public int localPort() throws IOException {
        return ((InetSocketAddress) listener.getLocalAddress()).getPort();
    }

    public void start() throws IOException {
        try (Selector selector = Selector.open()) {
            listener.register(selector, SelectionKey.OP_ACCEPT);
            while (running) {
                selector.select();
                Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
                while (keys.hasNext()) {
                    SelectionKey key = keys.next();
                    keys.remove();
                    if (key.isAcceptable()) {
                        acceptReady();
                    }
                }
            }
        }
    }

    private void acceptReady() throws IOException {
        SocketChannel ch;
        while ((ch = listener.accept()) != null) {
            ch.configureBlocking(false);
            ch.setOption(StandardSocketOptions.TCP_NODELAY, true);
            loop.register(ch, this);
        }
    }

    @Override
    public void onReadable(Connection c) {
        ByteBuffer chunk = pool.acquire(READ_CHUNK);
        try {
            int n = 0;
            int budget = MAX_READ_PER_EVENT;
            while (budget > 0 && (n = c.channel().read(chunk)) > 0) {
                budget -= n;
                chunk.flip();
                c.enqueue(chunk);
                chunk = pool.acquire(READ_CHUNK);
            }
            if (n == -1) {
                c.close();
            }
        } catch (IOException e) {
            c.close();
        } finally {
            pool.release(chunk);
        }
    }

    @Override
    public void onWritable(Connection c) {
        // 写队列由 Connection 持有;队列排空后事件循环摘掉 OP_WRITE,这里无事可做。
    }

    @Override
    public void onClosed(Connection c) {
        // 本类没有按连接分配的缓冲区,直接返回。
    }

    public void stop() {
        running = false;
        try {
            listener.close();
        } catch (IOException ignored) {
            // 已经关闭
        }
    }
}

几个设计点值得单独说明:

  • accept 要循环到 null。 一次 select() 可能对应多个已完成三次握手的连接,只 accept() 一次会让其余连接留在 backlog 里,表现为客户端连上了但迟迟没有响应。
  • onReadable 每读满一块就 enqueue。 Echo 不需要消息边界,读到多少回多少;flip() 把缓冲区从写模式切到读模式,enqueue 之后所有权归 Connection,本地变量立刻换一块新缓冲区,避免下一轮 read 覆盖尚未写出的数据。
  • 单连接单次事件最多读 64 KB。 MAX_READ_PER_EVENT 用完就返回 select(),让其他连接有机会执行;剩余数据还在内核里,水平触发的 Selector 会再次报告可读。
  • finally 只释放最后一块没交出去的缓冲区。 已经 enqueue 的那块由写队列在写完后调用 BufferPool.release,这里再释放会双重归还。
  • onWritable 和 onClosed 是空实现。 它们的存在是为了满足 Handler 契约;写队列和关闭逻辑都在 Connection 内部。空实现不等于可以忽略,12.1 会把它们接到具体事件循环里。
  • stop() 只关闭监听套接字。 已有连接怎么排空是 14.3 的主题;这里先保证 accept 线程能退出。

装配时,accept 线程和事件循环是两条独立的执行线:

EventLoop loop = new EventLoop();
NonBlockingEchoServer server = new NonBlockingEchoServer(loop, loop.buffers(), 9090);
Thread.ofPlatform().name("linkd-accept").start(() -> {
    try {
        server.start();
    } catch (IOException e) {
        System.getLogger("linkd").log(System.Logger.Level.ERROR, "accept loop stopped", e);
    }
});
loop.run();

EventLoop 是 6.2 的实现,本节不重复。loop.register 必须把通道登记到目标事件循环自己的 Selector 上,不能跨线程直接操作另一个线程的 Selector;register 内部通常会把任务投递到目标循环,这也是 EventLoop.execute(Runnable) 存在的原因。

常见错误与边界

错误一:把 read 返回 0 当成 EOF。

// 反例:空闲连接会被误杀
int n = c.channel().read(buf);
if (n <= 0) {
    c.close();
}

非阻塞 read 在没有数据时返回 0,这是最常见的正常状态。只有 -1 才是 EOF。按这个写法,任何一次事件循环轮询到空闲连接都会把它关掉,日志里看不到异常,客户端只看到连接被重置。

错误二:OP_WRITE 注册后不摘。

// 反例:队列空了仍然关注可写事件
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);

发送缓冲区几乎总是有空位,select() 会立即返回,循环空转。判断依据是写队列是否为空,不是「这次写完了没有」。正确做法是在 WriteQueue.writeTo 返回 true(队列排空)时把兴趣位改回 OP_READ。

错误三:write 返回 0 就丢弃剩余数据。

// 反例:只写一次,剩下的字节丢失
ByteBuffer out = ByteBuffer.wrap(response);
c.channel().write(out);

write 返回的字节数可能小于 remaining(),返回 0 时一个字节都没写出去。剩余部分必须留在队列里,等 OP_WRITE。这个错误在回显小包时很难发现,只有响应超过发送缓冲区时才暴露。

错误四:在事件循环里做阻塞调用。 一次 JDBC 查询、一次 Thread.sleep、一次同步 HTTP 调用,都会让这个循环上的所有连接停住。非阻塞只解决了 socket 等待,没有解决业务阻塞;后者要放到 12.2 的业务线程池。

边界:Java 的 Selector 是水平触发。 只要缓冲区里还有数据,下一次 select() 仍会报告可读。这意味着「只读一次」不会丢事件,但会反复唤醒;也意味着不能把水平触发当成边沿触发来写代码。macOS 上是 kqueue,Linux 上是 epoll,Java 层屏蔽了差异,但「就绪代表还有数据」这个语义在两边一致。

边界:write 返回正数也不代表对端收到。 它只代表字节进入了本机内核发送缓冲区。对端是否读到、是否处理,需要应用层确认;这也是 14.2 重试与幂等要解决的问题。

小结

  • configureBlocking(false) 后,accept 无连接返回 null,read 无数据返回 0、EOF 返回 -1,write 缓冲区满返回 0。
  • 本机 1 MB 写入在 1 KB 发送缓冲区下只写出 1024 字节就返回 0,剩余字节必须排队。
  • 一次可读事件要读到 0 或 -1,但单连接要有预算,避免饿死其他连接。
  • 写路径统一走 Connection.enqueue,写队列非空才注册 OP_WRITE,排空立即摘掉,否则事件循环空转。
  • accept 必须循环到 null,否则突发连接会积压在 backlog。
  • 非阻塞不消除业务阻塞;阻塞调用要移出事件循环。

返回值读对了,下一个问题就浮出来:一次 read 拿到的可能只是半个帧,一次 write 也可能只写出一半。下一节用 AccumulatingReader 和 WriteQueue 把这两个「一半」补成完整。

阅读导航:上一节:6.3 多线程 Reactor 与主从模型 · 下一节:7.2 半包、粘包与缓冲区增长 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发