海外的对象存储底层直接使用公有云,由于某些莫名其妙的原因,客户端不定期出现大量的 Read timed out 异常,部分异常栈如下。在和公有云的技术支持人员沟通过程中,我发现双方都不能很明确的指出到底是哪个环节超时了。

java.net.SocketTimeoutException: Read timed out
	at java.base/java.net.SocketInputStream.socketRead0(Native Method) ~[na:na]
	at java.base/java.net.SocketInputStream.socketRead(SocketInputStream.java:115) ~[na:na]
	at java.base/java.net.SocketInputStream.read(SocketInputStream.java:168) ~[na:na]
	at java.base/java.net.SocketInputStream.read(SocketInputStream.java:140) ~[na:na]
	at java.base/sun.security.ssl.SSLSocketInputRecord.read(SSLSocketInputRecord.java:478) ~[na:na]
	at java.base/sun.security.ssl.SSLSocketInputRecord.readHeader(SSLSocketInputRecord.java:472) ~[na:na]
	at java.base/sun.security.ssl.SSLSocketInputRecord.bytesInCompletePacket(SSLSocketInputRecord.java:70) ~[na:na]
	at java.base/sun.security.ssl.SSLSocketImpl.readApplicationRecord(SSLSocketImpl.java:1374) ~[na:na]
	at java.base/sun.security.ssl.SSLSocketImpl$AppInputStream.read(SSLSocketImpl.java:986) ~[na:na]
	at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137) ~[httpcore-4.4.13.jar!/:4.4.13]

众所周知,Http1.1 协议可以分成两个过程

  1. 客户端向服务端发送数据
  2. 客户端从服务端读取数据

tcp.png

所以 Http Read Timeout 的具体范围可能是指

  • 上图 2-3 的时间 ?
  • 上图 2-4 的时间 ?
  • 上图 3-4 的时间 ?
  • Tcp 层任意两个 Segment 的间隔时间 ?
  • Tcp 层任意两个 Receive Segment 的间隔时间 ?
  • Tcp 层任意两个 Send Segment 的间隔时间 ?

从 Http Client 的 ReadTimeout 参数看, 我最开始认为是 $ 2-4的时间 $ ; 后来想到 $2-4的时间$ 受下载对象的大小影响很大,但不论对象大小,该参数值都是一样的,所以应该是 $2-3的时间$ ; 后来我看上面的异常栈时,才发现是 Socket 的 Timeout。咨询同事说此参数表示 $Tcp 任意两个Segment 的间隔时间$ ; 我觉得他说的很有道理,直到有一天我看到了 WriteTimeout 这个参数。艹,如果 ReadTimeout 代表 $Tcp 层任意两个 Segement 的间隔时间$,那 WriteTimeout 和 ReadTimeout 有什么区别呢。 所以 ReadTimeout 实际代表的是 $Tcp 层任意两个 Receive Segment 的间隔时间$?。对,一定是这样的…..吗?

还是看看源码吧…

首先复现一下问题,随便起个 Http Server

// server
@GetMapping(path = {"/testTimeout"})
public String testTimeout(){
    Thread.sleep(10000L);
    return "YES";
}
  1. 拿 OkHttp 试试
// client
val okClient = OkHttpClient()
    .newBuilder()
    .readTimeout(5000, TimeUnit.MILLISECONDS)
    .build()

okClient
    .newCall(Request
        .Builder()
        .url("http://127.0.0.1:8080/testTimeout")
        .build())
    .execute()

结果显而易见的超时了…

Caused by: java.net.SocketTimeoutException: Read timed out
	at java.base/java.net.SocketInputStream.socketRead0(Native Method)
	at java.base/java.net.SocketInputStream.socketRead(SocketInputStream.java:115)
	at java.base/java.net.SocketInputStream.read(SocketInputStream.java:168)
	at java.base/java.net.SocketInputStream.read(SocketInputStream.java:140)
	at okio.Okio$2.read(Okio.java:140)
	at okio.AsyncTimeout$2.read(AsyncTimeout.java:237)
	... 20 more

根据异常跳到源码中继续看一下

class SocketInputStream extends FileInputStream {
/**
 * Reads into an array of bytes at the specified offset using
 * the received socket primitive.
 * @param fd the FileDescriptor
 * @param b the buffer into which the data is read
 * @param off the start offset of the data
 * @param len the maximum number of bytes read
 * @param timeout the read timeout in ms
 * @return the actual number of bytes read, -1 is
 *          returned when the end of the stream is reached.
 * @exception IOException If an I/O error has occurred.
 */
private native int socketRead0(FileDescriptor fd,
                               byte b[], int off, int len,
                               int timeout)
    throws IOException;
}

参数中有个 $Timeout$,但是源码没有说明其具体含义,猜测是从调用这个方法开始计时,如果经过 $Timeout$ 时间没有读到任何数据,就报超时异常。。听起来倒也河狸,继续看看其它的实现吧。

  1. Apache HttpClient
// Apache HttpClient 没有看到 ReadTimeout 的参数,反而用 SocketTimeout 代替。
val timeoutConfig = RequestConfig.custom()
    .setSocketTimeout(5 * 1000).build()
val httpClient: HttpClient = HttpClientBuilder.create()
    .setDefaultRequestConfig(timeoutConfig)
    .build()
httpClient.execute(HttpGet("http://127.0.0.1:8080/testTimeout"))
Exception in thread "main" java.net.SocketTimeoutException: Read timed out
	at java.base/java.net.SocketInputStream.socketRead0(Native Method)
	at java.base/java.net.SocketInputStream.socketRead(SocketInputStream.java:115)
	at java.base/java.net.SocketInputStream.read(SocketInputStream.java:168)
	at java.base/java.net.SocketInputStream.read(SocketInputStream.java:140)
	at org.apache.http.impl.conn.LoggingInputStream.read(LoggingInputStream.java:84)
	at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)

同 OkHttp 一样的异常栈,再看一下 Python 的…

  1. Python Requests

20211116204348

如上图红色的部分,最核心的代码 $sock.settimeout(read_timeout)$ 设置了 socket 的 timeout。

20211116205156

而 Socket 的 $recv$ 又是一个 native 方法。。。

以上种种看起来都像 Socket 有一个 timeout 的参数,设置参数后,如果在定义时间内没有读到数据,就会报超时异常。而且该时间不是和 Tcp 连接绑定的,否则在 Java 源码没必要在 $sockRead0$ 方法中传入此参数。反映到 Http 层,表现为 $2-3$ 的时间。

解释很合理,直到有一天写着代码的我想起,Tcp 有四个定时器。

  1. 重传定时器: 超时后重新发送数据报文段
  2. 坚持定时器: 超时后发送窗口探测报文段
  3. 保活定时器: 超时后发送连接探测报文段
  4. 时间等待计时器: 超时后断开连接

Tcp 有四个定时器,但是没有用来实现上文超时后异常的 超时定时器。我理解 Socket 只是 Tcp 协议的具体实现,虽然有 Posix Sockets,有 Berkeley Sockets…,但他们都遵守 Tcp 协议,对于 Tcp 未规定的部分没必要做额外实现。

想不通为什么,先找找 socket 的方法签名 20211116233734 我突然有点害怕,无论哪个重载都没有 timeout。我看着眼前的代码觉得好陌生,一定是哪里错了,我的后背不觉地渗出致密的汗水,双手止不住地发抖。连夜打车回到家里,战战兢兢翻开吃灰已久的《TCP/IP详解卷1:协议》,拿着放大镜仔细看了半夜,依然没有看到哪里有我的 超时定时器。如果 Tcp 层没有 Timeout,Socket 实现也没有 Timeout,那么 Timeout 是怎么做的呢?不会像 Java 一样另外开一个线程计时吧,不会吧,不会吧,那可太蠢了。

  1. Python SocketTimeout

    略却各种 if 判断和边界条件后,Linux 机器上 recv 实现的核心如下

    struct pollfd pollfd;
    pollfd.fd = sock_fd;
    pollfd.events = POLLIN
    poll(&pollfd, 1, timeout)
    recv(sock_fd, cbuf, len, flag)
    

    超时使用poll实现,意料之外,又好像清理之中。

  2. Java SocketTimeout

    回过头再看 Java SocketTimeout 的实现,亦是同理,就不贴代码了。 只是没想到对于 Java BIO,仍然可以借用 IO 多路复用实现超时机制。

到了这里,Read Timeout 的具体含义也已明晓。 可以理解为客户端从每次调用 socket.read() 开始计时,若相应时间段内没有读取到数据,就会有 Timeout 的异常。

然而客户端怎么调用 socket.read() 却不是唯一的,但至少要调用两次。 第一次读取到 Http Header 获取 Response 中的 Content-Length,第二次根据 Content-Length 读取相应大小的 Response Body,中间是否存在分段各客户端根据自己实现不尽相同。 所以出现 Read Timeout 大概率是 Response Header 或者 Body 没有及时返回

socket.read() 超时很好理解,只要服务端没有及时发送数据就会出现,但 socket.write() 实际也会超时,这也是 Write Timeout 的含义,但超时的原因可就是另一回事了。

问题已解,我奇怪的却是为啥我不继续看 epoll 的实现? 程序员总是喜欢说钻研底层,那么多底才是底呢,为什么我看到 Java 中的 SocketTimeout 后会仍存疑问,看到 epoll 的调用却自认豁然开朗? 我认为知识可以分为两种,知道自己知道的知道自己不知道的。 知道自己知道的知识如果遇到问题,总会刨根问底,差个明白,想必这应是每个人天生的好奇心。 然知道自己不知道的知识遇到问题,若无仔细探究的计划,只会搁置一旁,由它不知道去吧。