本节目标:能沿着一次 loopback 请求,指出内核缓冲区、Java Channel、Selector 和业务线程各自的位置。
适用版本:JDK 25(Temurin 25.0.5+8);curl 8.7.1;macOS 26.3 (25D125)。
承接的问题
linkd 的第一个可验证接口是健康检查:GET http://127.0.0.1:9090/healthz 返回 200 OK 和 ok。它足够小,小到可以把整条路径摊开看;又足够真实,真实到能暴露后面所有章节要处理的问题:连接从哪里来、字节什么时候可读、响应什么时候能写出去。
很多人的第一反应是「Socket.read() 就是一次系统调用」,这句话只对了最下面一层。一次请求在 Java 进程里至少经过五个位置:
- 内核的监听队列与已连接队列;
- 内核的接收缓冲区;
- Java 的
SocketChannel与ByteBuffer; Selector的就绪事件与事件循环线程;- 解析、路由和业务代码,最后通过发送缓冲区写回。
这一节用 loopback 把网卡和 DNS 排除掉,只留下软件栈。先看一次真实的 curl -v,再逐层解释每个缓冲区归谁所有,最后把 linkd 的 EventLoop、Handler 和 Connection 放到这条路径上。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
一次 loopback 请求的七步
先启动一个最小服务。它只做三件事:监听 127.0.0.1:9090、等待 OP_ACCEPT、读到请求后回固定的 ok。完整代码在 1.2 会拆开讲,这里先看行为。
curl -v --max-time 3 http://127.0.0.1:9090/healthz
本机输出(省略进度表):
* Trying 127.0.0.1:9090...
* Connected to 127.0.0.1 (127.0.0.1) port 9090
> GET /healthz HTTP/1.1
> Host: 127.0.0.1:9090
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 3
< Connection: close
<
ok
服务端日志同时打印了它看到的东西:
listening on 127.0.0.1:9090
accept /127.0.0.1:59644
read n=84 position=84
request-line: GET /healthz HTTP/1.1
把两边的时间线对齐,一次请求可以拆成七步:
curl解析 URL,发现主机是字面量127.0.0.1,跳过 DNS,直接向内核发起connect(127.0.0.1:9090)。- 内核走 loopback 路由,完成 TCP 三次握手。服务端的监听套接字把新连接放进 accept 队列,此时 Java 还没有调用
accept()。 Selector报告OP_ACCEPT,服务端调用accept(),得到一个已连接的SocketChannel,日志里的accept /127.0.0.1:59644就是这一步。curl把 84 字节的请求写进内核发送缓冲区,loopback 把它送到服务端接收缓冲区;Selector报告OP_READ。- Java 调用
channel.read(in),把 84 字节从内核缓冲区复制到ByteBuffer;日志里的read n=84 position=84是复制完成后的位置。 - 应用层解析请求行、匹配
/healthz、生成响应,再把响应字节写入SocketChannel。 - 服务端在响应头里写了
Connection: close,写完就关闭连接;curl读到ok后退出。
第 7 步会在 netstat 里留下痕迹:
netstat -anv -p tcp | grep -E 'Local Address|9090'
本机输出(表头已简化):
Proto Recv-Q Send-Q Local Address Foreign Address (state) rxbytes txbytes rhiwat shiwat process:pid
tcp4 0 0 127.0.0.1.9090 *.* LISTEN 0 0 131072 131072 java:6718
tcp4 0 0 127.0.0.1.9090 127.0.0.1.59644 TIME_WAIT 272 86 408256 146988 java:6718
LISTEN 行说明监听套接字还在;TIME_WAIT 行说明先关闭的一方是服务端,因为响应头写了 Connection: close。如果服务端保持 keep-alive,这一行会先显示 ESTABLISHED,等某一方关闭后才进入 TIME_WAIT。这不是「连接没关干净」,而是 TCP 的正常状态。
同一时刻用 lsof 看进程持有的套接字:
lsof -nP -iTCP:9090 -sTCP:LISTEN
本机输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 6718 bingrong.yan 5u IPv6 0x9077098634c1ed59 0t0 TCP 127.0.0.1:9090 (LISTEN)
FD 5u 是文件描述符 5,u 表示读写打开;(LISTEN) 是套接字状态。lsof 擅长回答「哪个进程占了端口」,不擅长回答「连接走到了哪一步」;后者要用 netstat 或抓包。macOS 没有 ss,这一章后面会反复用到这两个替代命令。
内核缓冲区与 Java 缓冲区
请求字节不是从 curl 直接「掉进」ByteBuffer 的。中间至少有两层缓冲区:内核的接收缓冲区,以及 Java 的 ByteBuffer。
内核接收缓冲区由 SO_RCVBUF 控制,发送缓冲区由 SO_SNDBUF 控制。netstat -anv 里的 rhiwat、shiwat 是这两个缓冲区曾经达到的高水位,不是当前占用量;当前占用量看 Recv-Q 和 Send-Q。不要把 131072 当成「还有 128 KB 没读」,它只是高水位记录。
Java 侧的读取路径大致是:
SocketChannel.read(ByteBuffer)
-> sun.nio.ch.IOUtil.read(...)
-> read(2) / recv(2)
-> 内核接收缓冲区 -> 用户缓冲区
这里有一个容易忽略的细节:如果传入的是堆上的 ByteBuffer.allocate(n),SocketChannelImpl 会借一个临时的直接缓冲区做系统调用,再把结果复制回堆;如果传入的是 ByteBuffer.allocateDirect(n),系统调用可以直接读写这块堆外内存,少一次复制。第 5 章会专门测量这个差别,本节只需要记住:「读进 ByteBuffer」不一定只有一次复制。
读方法的返回值有三个分支,必须分开处理:
ByteBuffer in = ByteBuffer.allocate(1024);
int n = channel.read(in);
if (n == -1) {
// 对端关闭,读到 EOF
channel.close();
} else if (n == 0) {
// 非阻塞模式下暂时没有数据;不是 EOF
return;
} else {
in.flip(); // limit = position, position = 0
// 只消费 in.remaining() 字节,不能假设一次读满
}
n == 0 只可能出现在非阻塞模式。阻塞模式下 read() 会一直等到有数据、EOF 或异常。n == -1 才是 EOF。把 0 当成 EOF,会在客户端短暂空闲时误关连接;把 -1 当成「再试一次」,会在对端关闭后空转。
flip() 也不是「反转缓冲区」,而是把 limit 设为当前 position,再把 position 归零,让后续读取从 0 开始、到刚才写到的位置为止。这个状态机在第 5 章会用图拆开;现在只要不在 flip() 之前调用 get() 就行。
Selector 就绪不等于数据到齐
Selector 解决的是「哪个连接现在可以读/写」,不是「这个连接的数据已经完整」。这两个问题经常被混为一谈。
Selector.select() 返回时,selectedKeys() 里是一批就绪的 key。OP_READ 的含义是「至少有一个字节可读,或者已经 EOF」,不是「一个完整的 HTTP 请求已经到了」。HTTP 请求行、头部和消息体可能分三个 TCP 段到达;第一次 OP_READ 时,你可能只读到 GET /hea。半包处理是第 7 章的主题,本节只需要知道:就绪事件是提示,不是消息边界。
一个最小的事件循环长这样:
Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
while (running) {
selector.select();
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须移除,否则下一轮重复处理
if (key.isAcceptable()) {
SocketChannel ch = server.accept();
ch.configureBlocking(false);
ch.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 读之前先确认 key 还有效
if (key.isValid()) {
readOnce((SocketChannel) key.channel());
}
}
}
}
三个细节决定了这个循环是否正确:
it.remove():selectedKeys()是复用集合,不移除就会在下一轮继续处理同一个 key。selector.selectedKeys().clear()也可以,但边遍历边清空容易漏掉新加入的 key,初学阶段用迭代器移除更稳。configureBlocking(false)必须在register之前:把阻塞通道注册到Selector会抛IllegalBlockingModeException。key.isValid():连接可能在上一个分支里被关闭,key随之失效;继续调用channel.read()会抛CancelledKeyException。
macOS 上的 Selector 由 kqueue 实现,不是 epoll。写代码时不需要关心底层是 kqueue 还是 epoll,但排障时要记住:Linux 上的 epoll 经验不能原样搬过来,macOS 也没有 ss。这是本卷反复强调的平台事实。
业务线程在哪里
健康检查很便宜:读 84 字节、匹配一个字符串、写 3 字节,放在事件循环线程里没有问题。但 linkd 还有 GET /books/{id},它要访问 libraryd 的 8080 端口。如果这个访问是阻塞的,放在事件循环里会卡住所有连接。
这就是「网络栈」和「业务线程」的边界:
事件循环线程:accept -> read -> 解析 -> 路由
业务线程池: 阻塞调用 libraryd -> 生成响应 -> 回投事件循环
事件循环线程:write -> 关闭/继续读
linkd 的 EventLoop.execute(Runnable) 就是回投入口。业务线程不能直接操作 SocketChannel,因为多个线程同时写会让响应字节的先后顺序不可控;SocketChannel 的单次写有内部锁,但业务消息的顺序仍要由发送队列保证。正确做法是把响应交给 Connection.enqueue(ByteBuffer),由事件循环在 OP_WRITE 就绪时统一写出。
Connection.enqueue 至少要做三件事:
- 把
ByteBuffer放进该连接自己的发送队列; - 如果队列从空变非空,把
interestOps加上OP_WRITE; - 如果当前线程不是事件循环线程,调用
selector.wakeup(),让阻塞在select()的线程立即返回。
这三步的顺序不能反。先加 OP_WRITE 再入队,事件循环可能在队列还空的时候就被唤醒,写出 0 字节;先 wakeup() 再入队,事件循环可能醒来后看到空队列,又睡回去,直到下一次事件才写出。
在 linkd 里落地
linkd 把这条路径拆成三个共享类型:EventLoop 负责等待就绪事件,Handler 负责处理单个连接的就绪回调,Connection 负责持有通道和发送队列。
一个最小的健康检查 Handler 可以写成这样(每个连接一个实例,不解析请求头,只演示路径):
package com.plume.linkd.app;
import com.plume.linkd.core.Connection;
import com.plume.linkd.core.Handler;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public final class HealthHandler implements Handler {
private static final byte[] HEALTH_OK = ("HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain\r\n"
+ "Content-Length: 3\r\n"
+ "Connection: close\r\n\r\nok\n")
.getBytes(StandardCharsets.US_ASCII);
private final ByteBuffer in = ByteBuffer.allocate(1024);
@Override
public void onReadable(Connection c) {
try {
in.clear();
int n = c.channel().read(in);
if (n == -1) {
c.close();
return;
}
if (n == 0) {
return;
}
c.enqueue(ByteBuffer.wrap(HEALTH_OK));
} catch (IOException e) {
c.close();
}
}
@Override
public void onWritable(Connection c) {
// 发送队列由 Connection 内部维护,这里不直接写通道
}
@Override
public void onClosed(Connection c) {
// 释放连接级资源;BufferPool 的归还也在这一层做
}
}
这段代码故意省略了 HTTP 解析和路由,因为第 8 章才会实现 HttpParser 和 HttpRouter。它保留了两个关键动作:c.channel().read(in) 从内核缓冲区取字节,c.enqueue(...) 把响应交给事件循环写回。把这两个动作放在不同的线程上,就是 Reactor 模型;放在同一个线程上,就是单线程事件循环。两者都能工作,区别在于阻塞调用会不会拖住别人。
第二个落点是路由注册。HttpRouter.add 把方法和路径映射到一个函数,健康检查不经过业务线程池:
HttpRouter router = new HttpRouter();
router.add("GET", "/healthz", req -> new HttpResponse(
200,
Map.of("Content-Type", "text/plain"),
"ok\n".getBytes(StandardCharsets.UTF_8)));
HttpResponse 是 linkd-http 的记录类型,status、headers、body 三个字段;响应编码、Content-Length 和 keep-alive 决策在 8.1 到 8.2 展开。本节只关心请求路径的终点:路由命中后生成响应,响应最终仍然要走 Connection.enqueue,而不是在路由函数里直接写 SocketChannel。
第三个落点是缓冲区。BufferPool.acquire(int minCapacity) 从池里拿一块 ByteBuffer,release(ByteBuffer) 归还。健康检查的 1024 字节缓冲区可以每次分配,但高并发下频繁 allocate 会把压力推给 GC;第 5 章和第 13 章会分别讨论堆内池和直接内存池。现在只需要知道:缓冲区有生命周期,读完之后要归还或复用,不能把 ByteBuffer 的引用泄漏到连接之外。
常见错误与边界
错误一:把 read() 返回 0 当成 EOF。 非阻塞模式下,对端还没发数据时 read() 返回 0;只有返回 -1 才是关闭。下面这种写法会在客户端刚连上、还没发请求时就把连接关掉:
int n = channel.read(in);
if (n <= 0) {
channel.close(); // 反例:n == 0 只是暂时没数据
}
正确写法是 n == -1 关闭,n == 0 直接返回,等待下一次 OP_READ。
错误二:在事件循环里调用阻塞下游。 GET /books/{id} 如果直接在 Handler.onReadable 里调用阻塞的 HTTP 客户端,事件循环会停在 read() 上,其他连接的 OP_READ、OP_ACCEPT 都得不到处理。表现是「并发一高,所有请求一起变慢」,而不是某一个请求慢。把阻塞调用放进业务线程池,再用 EventLoop.execute 回投,是第 12 章的主线。
错误三:只用 lsof 判断连接状态。 lsof -nP -iTCP:9090 默认会把监听套接字也列出来;看到 (LISTEN) 不代表没有已建立的连接。要只看已建立连接,加 -sTCP:ESTABLISHED:
lsof -nP -iTCP:9090 -sTCP:ESTABLISHED
如果这条命令没有输出,而 curl 又能成功,说明连接已经关闭(比如 Connection: close),不是「连接没建立」。
错误四:在 macOS 上找 ss。 ss 是 Linux 的 iproute2 工具,macOS 没有。连接诊断用 netstat -anv -p tcp、lsof -nP -iTCP:9090,抓包用 sudo tcpdump -i lo0 -nn port 9090。本卷不会给出 ss 的「本机输出」,因为本机根本执行不了。
边界:loopback 没有网卡。 本节刻意用 127.0.0.1,是为了把网卡、DNS、NAT 排除掉。真实公网请求还会经过网卡队列、中断、协议栈校验和卸载等环节;这些不影响 Java 侧的 SocketChannel/Selector 语义,但会影响延迟和吞吐。第 15 章会用 tcpdump 在 loopback 上验证握手和关闭,不会伪造网卡层的数字。
小结
- 一次 loopback 请求经过内核监听队列、接收缓冲区、
SocketChannel、Selector和业务代码,最后通过发送缓冲区写回。 read()返回 -1 是 EOF,返回 0 在非阻塞模式下只是暂时没数据;flip()设置的是limit和position,不是「反转」。OP_READ只表示「至少一个字节可读或 EOF」,不表示一个完整 HTTP 请求已到齐;半包要在第 7 章处理。- macOS 的
Selector基于 kqueue,不是 epoll;连接诊断用netstat、lsof、tcpdump,没有ss。 Connection.enqueue是业务线程回到事件循环的唯一入口;入队、加OP_WRITE、wakeup()的顺序不能反。- 健康检查可以留在事件循环,访问
libraryd的阻塞调用必须放进业务线程池,否则一个慢请求会拖住所有连接。
下一节把这条路径上的两种服务器写法并排跑起来:阻塞 Echo 每个连接一个线程,非阻塞 Echo 一个线程管多个连接,用真实日志对比 BIO、NIO、AIO 与 Reactor 的边界。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 BIO、NIO、AIO 与 Reactor 模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。