2022年1月

Java socket timeout 在 Linux 的实现

Block IO (SocketInputStream)

我们从 java.net.SocketInputStream 看起. 它的 read 方法传入 timeout 的值. 如果不传入 timeout 值, 则这个 timeout 的值是通过 setOption 设置的, 若没有设置过, 那么它的值就是 0.

也就是说每次 read 方法被调用, 都针对这次 read 操作有一个重新计时的 timeout时钟. 如果一个网络数据要读好几次, 每次 read 都会重新计时这个 timeout.

那么这个 timeout 最终在哪里使用的呢? 下面是每一步调用的栈:

  1. java.net.SocketInputStream.read(byte b[], int off, int length, int timeout)
  2. java.net.SocketInputStream.socketRead(FileDescriptor fd, byte b[], int off, int length, int timeout)
  3. native java.net.SocketInputStream.socketRead0(FileDescriptor fd, byte b[], int off, int length, int timeout)
  4. SocketInputStream.c
    Java_java_net_SocketInputStream_socketRead0(JNIEnv *env, jobject this, jobject fdObj, jbyteArray data, jint off, jint len, jint timeout) 点过上面链接, 仔细查看路径会发现, 这个SocketInputStream.c既不是 shared 代码, 也不是 Linux 的代码, 而是 Solaris, 而我们讨论的是 Linux 的代码, 为什么给出的是 Solaris 代码呢? 这个问答给出了明确的答案: SocketInputStream.c 包含了 net_util.h, 而 net_util.h 又包含了 net_util_md.h. 而 net_util_md.h 里面使用 #if defined(__linux__) || defined(MACOSX) 来区分不同的 OS.
  5. 上面的 SocketInputStream.c 又把 timeout 参数传给了 NET_Timeout 函数. NET_Timeoutlinux_close.c (注意还是 Solaris 目录)里面定义:

    /*
    * Wrapper for poll(s, timeout).
    * Auto restarts with adjusted timeout if interrupted by
    * signal other than our wakeup signal.
    */
    int NET_Timeout(int s, long timeout) {
  6. 上面的注释中很好的说明了 它只是用来封装 poll 系统调用, 具体的调用就是:
    在一个无限循环中, 每次用剩余的 timeout 时间去看看有没有可读数据( poll()), 直到读到, 若没读到, 则计算剩余时间, 继续用剩余时间去读, 直到读到, 或者 timeout 没有剩余.

    if (timeout > 0) {
       gettimeofday(&t, NULL);
       prevtime = t.tv_sec * 1000  +  t.tv_usec / 1000;
     }
    
     for(;;) {
       struct pollfd pfd;
       int rv;
       threadEntry_t self;
    
       /*
        * Poll the fd. If interrupted by our wakeup signal
        * errno will be set to EBADF.
        */
       pfd.fd = s;
       pfd.events = POLLIN | POLLERR;
    
       startOp(fdEntry, &self);
       rv = poll(&pfd, 1, timeout);
       endOp(fdEntry, &self);
    
       /*
        * If interrupted then adjust timeout. If timeout
        * has expired return 0 (indicating timeout expired).
        */
       if (rv < 0 && errno == EINTR) {
           if (timeout > 0) {
               gettimeofday(&t, NULL);
               newtime = t.tv_sec * 1000  +  t.tv_usec / 1000;
               timeout -= newtime - prevtime;
               if (timeout <= 0) {
                   return 0;
               }
               prevtime = newtime;
           }
       } else {
           return rv;
       }
    
     }

    NIO (SocketChannel)

    Java New IO 可以选择是否是 block 模式.
    socketChannel.configureBlocking(false);
    这主要影响到这 3 个 API 的操作

  7. connect();
  8. read();
  9. write();

    注意到, 他们和之前 Blocking IO 的区别: 他们都没有地方设置 timeout 的值. 
    connect(): 若同步, 则等到建立连接或者 IO error 产生; 若异步, 则立马返回, 后续通过一直调用 finishConnect() 来测试是不是已经建立连接;
    read(): 若同步, 直到读到数据或 IO 出错; 若异步, 读一次, 立马返回, 不管是不是读到数据, 通过返回值判断读多少;
    write(): 若同步, 直到写完或者 IO 出错; 若异步, 写一次, 立马返回, 不管是不是有数据写出, 通过返回值判断写多少;

sun.nio.ch.SocketChannelImpl 里面的read()实现(写类似):

if (blocking) {
     do {
          n = IOUtil.read(fd, buf, -1, nd);
     } while (n == IOStatus.INTERRUPTED && isOpen());
} else {
     n = IOUtil.read(fd, buf, -1, nd);
}

即便使用 non-blocking 的方式, 为了保证连接建立好, 读完整, 写完数据, 就要业务线程使用 loop 的方式一直检查这些连接操作,读操作, 写操作是不是好了. 这样并不能带来更好的性能, 相反, 需要更多的 CPU 时间.

所以, 为了更好的性能, 就单独找个线程, 来负责所有这些 IO 操作的连接, 读, 写操作. 其它线程就去做其它事情去了, 这个负责 IO 操作的线程为了更好的同时处理多个 IO, 就使用 Selector 的方式, 相当于一个人看守多个流水线.

到此, 我们还没有涉及 timeout, 并且上面 API 中也根本没有提到 timeout, 那么 non-blocking 的方式中, timeout 是怎么实现的呢? 通常都是负责 IO 的线程来实现的, 它每次批量 select() 之后, 就会查看一遍是不是已经有超时的 IO. 比如 JDK 自带的 jdk.internal.net.http.HttpClientImpl 中的 purgeTimeoutsAndReturnNextDeadline 方法, 就负责每次 select() 完查看是否有 timeout 的 IO.
https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java#L1210

Blocking IO 里面的 timeout 每次调用 read/write API 的时候, 直接传到 epoll() 方法去的, 也就是说如果一个 response 调用了 read() 多次, 那么每次 read() 都是重新计时 timeout 的值. 但是在 NIO 里面, 我们看到 JDK 提供的这个HttpClientImpl 实现里面, 使用 NIO 的方式, 它每次调用 read() 方法是不会 reset timeout 值的, 这个 timeout 值的使用方式是: 从一开始便扣减里面剩余的时间. 这 2 种方式对于读一个很大数据流是有影响的.

另外这篇文章详细介绍了各种 http client 是否支持 sync, async, 是使用 future 的方式, 还是使用 callback 的方式:
https://www.mocklab.io/blog/which-java-http-client-should-i-use-in-2020/

可以调节下面代码的 connect timeout 和 read timeout 值, 来观察或者 debug 是如何实现的 timeout:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;

public class NioTest {

    public static void main(String[] args) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://www.tianxiaohui.com/"))
                .timeout(Duration.ofMillis(1000))
                .header("Content-Type", "application/json")
                 .GET()
                .build();
        HttpClient client = HttpClient.newBuilder()
                  .version(Version.HTTP_1_1)
                  .connectTimeout(Duration.ofMillis(1000))
                  .build();
        
        client.sendAsync(request, BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .thenAccept(System.out::println)
                .exceptionally(t -> {System.out.println(t.getMessage());t.printStackTrace(); return null;}); 
           
           try {
            Thread.sleep(600000);
            System.out.println();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

关于 Linux poll 的 timeout 参数的解释

最近研究 JDK 和 Netty 里面 socket timeout 的实现, 代码层面从 Java 代码一直追踪到 Linux 上的系统调用 poll. 当查看 poll 的手册的时候, 注意到一个不正常的描述.

首先, 通过 google 找到的是这个手册: https://linux.die.net/man/2/poll. 这个手册里面有关于 poll 的 timeout 参数的描述是:

The timeout argument specifies the minimum number of milliseconds that poll() will block.

注意, 它使用的是 "minimum", 那么对于正数的 timeout 值, 我可以有这些可能的理解:

  1. 不管有没有感兴趣的事件发生, 我最少必须等 timeout 的毫秒数;
  2. 在 timeout 值的时间窗口内, 有感兴趣的事情发生, 就直接返回, 若没有, 最少等 minimum 毫秒数;
    那么, 对于以上不管哪种理解, 最少的等待时间是 timeout 毫秒数, 那么最大的等待时间呢? 如果在 timeout 毫秒数内么有感兴趣的事件发生, 要等多久呢?

怀着这个问题, 我又返回去看 JDK 里的代码:
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/windows/native/java/net/SocketInputStream.c#l101
还是无法理解.

于是重新 google 搜索, 找到了这个问答:
https://stackoverflow.com/questions/529975/what-does-poll-do-with-a-timeout-of-0
pollMan.png
回答里面明显表明这是最大等待时间, 于是重新找另外的 Linux man page:
https://man7.org/linux/man-pages/man2/poll.2.html

The timeout argument specifies the number of milliseconds that poll() should block waiting for a file descriptor to become ready.

这里的意思, 就没有任何歧义了, 就是最大等待时间.

HTTP post/put 的 payload 的内容(content-type)类型

虽然看过几遍 HTTP 权威指南, 可是每次遇到 post 上传文件或者看到 Postman 的 form-data, x-www-form-urlencoded 的时候, 还是有点迷糊. 今天看 everything about curl 的时候, 又谈到了 post 的 payload 内容类型. 索性把它理清楚, 方便自己以后查找.

关于 POST 和 PUT 区别

有 ID, 每次根据 ID 去创建/更新, 就用 PUT, 具有幂等性, 多次提交不影响结果.
没有 ID, 每次都创建, 就用 POST, 不具有幂等性, 多次提交产生多次内容.

网页表单(FORM)提交

网页表单提交时, 若 method 为 POST, 表单的 enctype 属性决定了提交的负载内容(payload)的 MIME(网络多媒体类型)类型. enctype 是 encoding type 的缩写, 表示了内容的编码类型, 相当于告诉接受请求的服务器: 我这边把发送的内容经过了某种编码类型A 编码, 你收到之后也用同样的类型解码一下. 现在(20220102)enctype 可能的值(反映到 Content-Type)只有3种:

  1. application/x-www-form-urlencoded 只支持键值对,键值对中间用&分隔开, 比如 name=eric&gender=1&age=50
  2. multipart/form-data 如果表单的某个值为文件时(上传文件), 使用这种方式, 否则使用上面编码方式(简单).
  3. text/plain HTML5 新加的, 主要用于调试.

除去第三种调试用的, 我们在网页上看到的基本就是上面2种, 如果没有文件上传, 基本是第一种, 因为它最简单, 基本就是把 form 的内容根据键值对使用&连接到一起.
multipart/form-data 要对表单内容的每个项目使用分隔符分开, 它的分隔符比较长, 中间还有空行. 比如我上传一个图片文件(sni.png)和另外一个字段(key1=value1), 它的负载的内容是:

------WebKitFormBoundarycsbFnSl9t4kuAjfv
Content-Disposition: form-data; name="a"; filename="sni.png"
Content-Type: image/png

.PNG
.
...
IHDR................e....sRGB........leXIfMM.*.
<<中间省略 png 里面的1000多行二进制内容>>
...adfas
------WebKitFormBoundarycsbFnSl9t4kuAjfv
Content-Disposition: form-data; name="key1"

value1
------WebKitFormBoundarycsbFnSl9t4kuAjfv--

Ajax 请求表单/代码(微服务)提交表单

这种情况下, Content-type 可以是其它类型: application/xml, application/json

查看 DNS 查询记录

要使用外部服务, 就要连接外部 endpoint, 通常我们都是用 FQDN 去连接外部, 偶尔直接使用 IP 地址. 如果使用 FQDN, 那么就涉及 DNS 查询. 通常 DNS 记录都有 TTL (time to live)时限, 时限一过, 就要重新 DNS 查询.
使用 dig 命令可以查看一条 DNS 记录的 ttl:

$ dig +nocmd +noall +answer +ttlid a www.tianxiaohui.com
www.tianxiaohui.com.    389    IN    A    103.144.218.
$ dig +nocmd +noall +answer +ttlid a www.baidu.com
www.baidu.com.        1200    IN    CNAME    www.a.shifen.com.
www.a.shifen.com.    239    IN    CNAME    www.wshifen.com.
www.wshifen.com.    239    IN    A    45.113.192.102
www.wshifen.com.    239    IN    A    45.113.192.101

上面结果第二列就是 ttl, 单位是秒.

如何查看 Linux 上的 DNS 查询

使用 tcpdump 查看 53 端口的流量, 就能查看 DNS 查询记录 (

$ sudo tcpdump -l port 53  # -l 表示使用 buffer
06:29:08.312928 IP 10.226.197.72.40898 > sin-dns.tianxiaohui.com.domain: 42885+ A? firestore.googleapis.com. (42)
06:29:08.422806 IP sin-dns.tianxiaohui.com.domain > 10.226.197.72.40898: 42885 1/0/0 A 142.250.4.95 (58)
06:29:45.507982 IP 10.226.197.72.5013 > sin-dns.tianxiaohui.com.domain: 2509+ A? github.com. (28)
06:29:45.575657 IP sin-dns.tianxiaohui.com.domain > 10.226.197.72.5013: 2509 1/0/0 A 20.205.243.166 (44)
06:29:45.576415 IP 10.226.197.72.42090 > sin-dns.tianxiaohui.com.domain: 64400+ A? github.com. (28)
06:29:45.640234 IP sin-dns.tianxiaohui.com.domain > 10.226.197.72.42090: 64400 1/0/0 A 20.205.243.166 (44)
06:30:05.738311 IP 10.226.197.72.54902 > sin-dns.tianxiaohui.com.domain: 51140+ A? translate.google.com. (38)

如何查看一个 Java 应用程序的 DNS 查询

Java 应用程序的 DNS 最近的查询记录都记录在 InetAddress 类的某个字段中, 所以对这个字段读, 或者你有一个 heap dump, 查看这个字段, 就能查看到最近的 DNS 记录. 详细的方法, 可以查看这段代码:
https://github.com/manecocomph/myJavaAgent/blob/master/src/com/tianxiaohui/java/agent/SampleAgent.java#L191