本节目标:把
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 半包、粘包与缓冲区增长 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。