《Java 网络编程基础》3.2 连接、读、写超时

connect 超时、SO_TIMEOUT 与写阻塞是三件不同的事。本节用 linkd 访问 libraryd 的真实调用,实测 connect 1000ms 超时、read 超时后连接仍可继续读、发送缓冲区写满后线程卡在 SocketDispatcher.write0,并给出 Deadline 总预算与「超时不是重试」的边界。

本节目标:分清 connect 超时、读超时和写阻塞,知道每个 API 管到哪一步,不再用一个 setSoTimeout 包打天下。
适用版本:JDK 25(Temurin 25.0.5+8);java.net.Socket、SocketTimeoutException。

承接的问题

3.1 的 Echo 服务器有一个隐藏假设:客户端会正常发完、正常关闭。只要有一个客户端连上之后不发数据也不关连接,处理线程就会永远停在 read 上。连接数一多,线程栈先被耗尽,进程看起来还活着,但新连接已经没有线程处理。

linkd 访问 libraryd 时方向相反:linkd 是客户端,libraryd 在 8080 上。如果 libraryd 卡住、网络中断、或者对端根本不回响应,linkd 的请求线程同样会停在 read 上。用户看到的是「页面一直转圈」,日志里什么都没有。

这两种卡住看起来一样,解决方式却不同。TCP 建连、读数据、写数据是三个独立的阶段,JDK 只在其中两个阶段给了超时参数,第三个阶段根本没有对应的 setter。把这三个阶段混成一个「超时时间」,是网络代码里最常见的设计错误。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:connect 超时只在 connect 方法上

new Socket(host, port) 会立即发起连接,但没有超时参数。它使用操作系统的默认建连超时,可能长达几十秒。要控制建连时间,必须用无参构造加 connect(SocketAddress, int timeoutMillis):

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;

public class ConnectTimeoutDemo {
    public static void main(String[] args) throws IOException {
        long start = System.nanoTime();
        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress("192.0.2.1", 9090), 1000);
            System.out.println("connected");
        } catch (SocketTimeoutException e) {
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.println("SocketTimeoutException after " + ms + " ms: " + e.getMessage());
        }
    }
}

192.0.2.1 是 RFC 5737 保留的 TEST-NET-1 地址,正常网络里不会有人响应。本机输出:

SocketTimeoutException after 1025 ms: Connect timed out

connect 的第二个参数单位是毫秒,0 表示无限等待。超时抛出的是 SocketTimeoutException,消息是 Connect timed out;连接被拒绝则是 ConnectException,消息是 Connection refused (connect failed)。两者都是 IOException 的子类,但含义完全不同:前者是「没人应答」,后者是「有人明确拒绝」。

这里有一个容易被忽略的边界:connect 超时只覆盖 TCP 三次握手,不覆盖 DNS 解析。 下面这行代码在构造 InetSocketAddress 时就已经做了域名解析:

InetSocketAddress address = new InetSocketAddress("slow.example.com", 9090);
socket.connect(address, 1000);

如果 DNS 卡住,时间花在 new InetSocketAddress 里,connect 的 1000ms 根本还没开始计时。要绕开这一点,可以用 InetSocketAddress.createUnresolved(host, port) 延迟解析,但 connect 遇到未解析地址会抛 UnknownHostException,JDK 的 Socket 没有提供「带超时的 DNS 查询」。生产代码通常把域名解析放到带超时的解析器或服务发现层,而不是指望 connect 参数。

核心机制二:读超时是 SO_TIMEOUT,超时后连接还活着

读超时通过 Socket.setSoTimeout(int millis) 设置,语义上对应 Socket 选项 SO_TIMEOUT(接收超时)。JDK 的具体实现可能用 poll/select 等待,不一定直接设置 SO_RCVTIMEO,但对外行为一致:它只影响 getInputStream().read(...),超过指定时间没有读到任何字节,就抛 SocketTimeoutException,消息是 Read timed out。

try (Socket socket = new Socket()) {
    socket.connect(new InetSocketAddress("127.0.0.1", port), 1000);
    socket.setSoTimeout(1000);
    byte[] buffer = new byte[64];
    try {
        socket.getInputStream().read(buffer);
    } catch (SocketTimeoutException e) {
        System.out.println("timeout: " + e.getMessage());
    }
    socket.setSoTimeout(5000);
    int n = socket.getInputStream().read(buffer);
    System.out.println("second read n=" + n);
}

服务端接受连接后先睡 3 秒再写 late\n。本机输出:

timeout after 1002 ms: Read timed out
second read n=5 data=late

第二次读能拿到数据,说明读超时不会关闭连接,也不会破坏流的状态。它只是让这一次 read 提前返回异常。这一点和很多人以为的「超时即断线」相反:超时之后你可以选择继续读、换一个更大的超时,或者主动关闭。选择权在调用方。

setSoTimeout(0) 表示无限等待,这是默认值。这个「0 等于无限」的约定后面会咬人:如果用一个剩余预算算超时,预算不足 1 毫秒时整数除法得到 0,setSoTimeout(0) 反而变成永不超时。

读超时抛出的 SocketTimeoutException 继承自 InterruptedIOException,再往上才是 IOException。捕获时不要只写 catch (IOException) 然后当成连接断开处理;超时、拒绝、复位需要不同的恢复策略。

核心机制三:写没有超时 API,缓冲区满就阻塞

Socket 没有 setWriteTimeout,SO_SNDTIMEO 也没有通过标准 API 暴露。setSoTimeout 只作用于读,对写完全无效。

写的行为是:数据从用户态复制到内核发送缓冲区,复制成功就返回。缓冲区满时,write 阻塞,直到对端读走数据、窗口重新打开。TCP 有自己的重传和保活机制,但它们保证的是可靠性,不是应用层的截止时间;对端进程卡死但 TCP 连接还在时,write 可以阻塞很久。

用一个实验证明。服务端把发送缓冲区设为 32 KB,连续写 64 KB 的块;客户端连上后不读,只睡 12 秒。服务端还特意设置了 setSoTimeout(1000):

socket.setSoTimeout(1000);
socket.setSendBufferSize(32 * 1024);
OutputStream out = socket.getOutputStream();
byte[] block = new byte[64 * 1024];
for (int i = 0; i < 10_000; i++) {
    out.write(block);
}

跑 3 秒后抓线程栈,本机输出(节选):

"main" #3 [9731] prio=5 os_prio=31 cpu=42.75ms elapsed=4.27s tid=0x000000010521b630 nid=9731 runnable  [0x000000016b75a000]
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.SocketDispatcher.write0(java.base@25.0.5/Native Method)
	at sun.nio.ch.SocketDispatcher.write(java.base@25.0.5/SocketDispatcher.java:65)
	at sun.nio.ch.NioSocketImpl.tryWrite(java.base@25.0.5/NioSocketImpl.java:402)
	at sun.nio.ch.NioSocketImpl.implWrite(java.base@25.0.5/NioSocketImpl.java:418)
	at sun.nio.ch.NioSocketImpl.write(java.base@25.0.5/NioSocketImpl.java:448)
	at java.net.Socket$SocketOutputStream.implWrite(java.base@25.0.5/Socket.java:1086)
	at java.net.Socket$SocketOutputStream.write(java.base@25.0.5/Socket.java:1076)
	at BlockedWrite.main(BlockedWrite.java:16)

线程状态是 RUNNABLE,栈顶在 SocketDispatcher.write0,说明它卡在系统调用里等发送缓冲区腾出空间。setSoTimeout(1000) 没有任何作用,3 秒后它还在写。这个栈是排查「服务端不响应、线程却显示 RUNNABLE」的关键证据:不是死锁,是写阻塞。

阻塞模式下没有干净的「写超时」办法。可行的路径有三条:

  • 把套接字设为非阻塞,用 Selector 等可写事件,自己维护截止时间(第 6、7 章)。
  • 在另一个线程里计时,到点调用 socket.close(),让阻塞的写抛 SocketException: Socket closed。这是粗暴但有效的兜底。
  • 业务上保证对端会持续读,并用应用层确认判断进度;这不能替代超时,只能降低概率。

核心机制四:NIO 的超时是「截止时间」,不是异常

java.net.Socket 的超时模型是「每个操作一个计时器,到点抛异常」。NIO 非阻塞模型没有这个异常,因为读写根本不会阻塞:

  • SocketChannel.read / write 在非阻塞模式下立即返回,返回 0 表示「现在不能读写」,不是错误,也不抛超时;read 返回 -1 仍然表示 EOF。
  • 你用 Selector.select(timeoutMillis) 等待就绪。select 返回 0 表示这段时间内没有事件,不是异常;你需要自己检查截止时间,决定继续等还是关闭连接。
  • 超时从「异常」变成了「状态 + 调用方决策」。

把几种常见 API 放在一张表里:

API管哪个阶段到点行为连接是否关闭
Socket.connect(addr, ms)TCP 握手抛 SocketTimeoutException连接未建立
Socket.setSoTimeout(ms)每次 read抛 SocketTimeoutException否,可继续读
Socket 的 write无超时参数缓冲区满时阻塞否
SocketChannel 非阻塞 read/write不阻塞返回 0否
Selector.select(ms)等待就绪事件返回 0否,由调用方决定
HttpClient.connectTimeout建连抛 HttpConnectTimeoutException请求失败
HttpClient 请求超时整个请求抛 HttpTimeoutException请求失败,底层连接可能被丢弃

最后两行是 JDK 11 起自带的 java.net.http.HttpClient,它的超时是请求级的,和 Socket 的每读超时不是同一层。本卷手写 HTTP 客户端时仍然用 Socket,所以必须自己把连接、读、总预算分开管理。

在 linkd 里落地:一个总预算,而不是三个独立超时

linkd 调 libraryd 的 GET /books/{id},如果分别给 connect 1 秒、read 1 秒,最坏情况是 2 秒,而不是 1 秒。用户要的是「这个请求最多 1 秒」,所以需要一个贯穿始终的 Deadline,每次操作前用剩余时间作为本次超时。

package com.plume.linkd.app;

import java.time.Duration;

public record Deadline(long deadlineNanos) {

    public static Deadline after(Duration budget) {
        return new Deadline(System.nanoTime() + budget.toNanos());
    }

    public boolean expired() {
        return deadlineNanos - System.nanoTime() <= 0;
    }

    public int remainingMillis() {
        long remaining = deadlineNanos - System.nanoTime();
        if (remaining <= 0) {
            return 0;
        }
        return (int) Math.min(Integer.MAX_VALUE, remaining / 1_000_000);
    }
}

remainingMillis() 返回 0 有两种含义:预算已经用完,或者只剩不到 1 毫秒。调用方必须把 0 当成「不能再发起操作」,绝不能直接传给 connect 或 setSoTimeout,因为那个 0 会被解释成「无限等待」。

客户端类把预算切开:

package com.plume.linkd.app;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

public final class LibraryClient {

    private final String host;
    private final int port;

    public LibraryClient(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public String fetchBook(String id, Duration budget) throws IOException {
        Deadline deadline = Deadline.after(budget);
        try (Socket socket = new Socket()) {
            int connectMillis = deadline.remainingMillis();
            if (connectMillis == 0) {
                throw new SocketTimeoutException("deadline exhausted before connect");
            }
            socket.connect(new InetSocketAddress(host, port), connectMillis);

            int readMillis = deadline.remainingMillis();
            if (readMillis == 0) {
                throw new SocketTimeoutException("deadline exhausted before read");
            }
            socket.setSoTimeout(readMillis);

            String request = "GET /books/" + id + " HTTP/1.1\r\n"
                    + "Host: " + host + ":" + port + "\r\n"
                    + "Connection: close\r\n\r\n";
            socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
            socket.getOutputStream().flush();
            return readAll(socket.getInputStream());
        }
    }

    private static String readAll(InputStream in) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] buffer = new byte[4096];
        int n;
        while ((n = in.read(buffer)) != -1) {
            out.write(buffer, 0, n);
        }
        return out.toString(StandardCharsets.ISO_8859_1);
    }
}

Connection: close 让服务端在响应后关闭连接,readAll 才能靠 EOF 结束读取。真正的 HTTP 客户端要解析 Content-Length 或 chunked,那是第 8 章的事;这里先保证「读一定会在预算内结束」。

这段代码还有两个边界:

  • DNS 不在预算内。 new InetSocketAddress(host, port) 在 connect 之前解析域名。如果 host 是域名而不是 IP,DNS 卡住时 Deadline 帮不上忙。生产配置里 libraryd 用 127.0.0.1,正是为了避开这个问题;换成服务名时要把解析放进带超时的服务发现。
  • 写仍然可能阻塞。 socket.getOutputStream().write(...) 没有超时。请求头只有几百字节,正常情况下能一次写入内核缓冲区;如果对端完全不读,写仍可能卡住。更完整的客户端会把 socket 设为非阻塞,或者用独立的 watchdog 在预算到点时 close()。

常见错误与边界

错误一:用 new Socket(host, port) 然后奇怪为什么连接能挂很久。 这个构造函数没有超时参数,建连时间由内核决定。需要可控建连,必须 new Socket() 加 connect(address, timeout)。

错误二:把 connect 超时当成整次请求超时。 connect 1 秒只覆盖握手,之后的 DNS、读响应、写请求都不在它的管辖内。总预算要自己用 Deadline 串起来。

错误三:以为 setSoTimeout 能管住写。 本机实验里设置了 1000ms 读超时,线程仍然在 SocketDispatcher.write0 上阻塞超过 3 秒。写需要非阻塞通道或外部 watchdog。

错误四:捕获 SocketTimeoutException 后直接重试非幂等请求。 超时只说明「没在预算内拿到结果」,不说明服务端有没有执行。对 POST /loans 这类请求盲目重试,可能生成两笔借阅。重试需要幂等键或服务端去重,14.2 会展开。

错误五:超时后不关闭套接字。 读超时不会关闭连接,本机第二次读还能拿到 late。如果业务决定放弃这次请求,必须显式 close();留在连接池里会污染下一次请求,留在注册表里会泄漏文件描述符。

边界:setSoTimeout(0) 是无限等待。 用剩余预算算超时时,0 必须被拦截。Deadline.remainingMillis() 返回 0 时抛异常,而不是把 0 传给 setSoTimeout,就是为了避免这个反转。

边界:超时时间不是精度保证。 本机 connect 1000 实测 1025ms,read 1000 实测 1002ms。定时器有调度粒度,网络栈也有开销;拿 1000ms 做硬实时判断会失望。

小结

  • connect(addr, ms) 的 1000ms 只覆盖 TCP 握手,本机实测 1025ms 抛 Connect timed out,不覆盖 DNS 解析。
  • setSoTimeout 管每次 read,到点抛 Read timed out;超时后连接仍可用,本机第二次读拿到了数据。
  • 写没有超时 API;发送缓冲区满时线程卡在 SocketDispatcher.write0,setSoTimeout 对写无效。
  • NIO 非阻塞把超时从异常变成状态:读写返回 0,Selector.select(ms) 返回 0,截止时间由调用方维护。
  • 用 Deadline 串起 connect 和 read,避免两个 1 秒叠加成 2 秒;剩余 0 毫秒必须拦截,不能传给 setSoTimeout。
  • 超时只代表结果未知,不代表请求没执行;非幂等请求的重试需要幂等键。

能连上、能读到、也能在预算内放弃之后,还剩一个方向没处理:连接怎么结束。客户端发完请求只是关了写方向,服务端要读到 EOF 才知道请求结束;关得不对还会触发 RST,把还没发出去的响应丢掉。下一节拆半关闭和优雅关闭。

阅读导航:上一节:3.1 Socket 与 ServerSocket 基本用法 · 下一节:3.3 半关闭、RST 与优雅关闭 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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