性能文章>记一次 Kotlin Ktor 库的 PR 提交记录(TCP 自连接)>

记一次 Kotlin Ktor 库的 PR 提交记录(TCP 自连接)原创

6680188

前言

去年 techday 上有幸邀请到了 Go 夜读的大佬杨文,他跟我们分享了开源的主题,深受鼓舞。正好前段时间测试 Kotlin 的官方库 ktor 发现了一个很冷门的问题,于是提了一个 PR。经过了一个月漫长的等待,终于有人 review 并 merge 到 master 分支了,估计下个 release 就可以看到了。

这里记录了一下相关的内容,PR 地址在这里:https://github.com/ktorio/ktor/pull/2237

文章的主要内容是:

  • TCP 自连接是什么
  • Linux 高版本内核 connect、bind(0) 端口号分配的奇偶性分析
  • 如何修复 TCP 自连接的代码

1.背景说明

这个 PR 是什么?

复现的代码如下,getAvailablePort() 用于寻找一个可用的偶数的端口号,测试例子中返回的是 42064。至于为什么这里的端口号要是偶数,我后面会重点说。


fun testSelfConnect() {
    runBlocking {
        // Find a port that would be used as a local address.
        val port = getAvailablePort()

        val tcpSocketBuilder = aSocket(ActorSelectorManager(Dispatchers.IO)).tcp()
        // Try to connect to that address repeatedly.
        for (i in 0 until 100000) {
            try {
                val socket = tcpSocketBuilder.connect(InetSocketAddress("127.0.0.1", port))
                println("connect to self succeed: ${socket.localAddress} to ${socket.remoteAddress}")
                System.`in`.read()
                break
            } catch (ex: Exception) {
                // ignore
            }
        }
    }
}

运行上面的代码,会出现一个源端口号和目标端口号一样的连接。

image.png

这显然不正常,如果这种情况发生了,如果服务端程序就无法再监听 42064 端口。这个问题的本质就是 TCP 的自连接,这个 PR 就是为了解决这个问题。接下来我们来看什么是 TCP 自连接。

2.TCP 自连接

TCP 的自连接是一个比较有意思的现象,甚至很多人认为是 Linux 内核的 bug。我们先来看看 TCP 的自连接是什么。

新建一个脚本 self_connect.sh,内容如下:


while true
do
 telnet 127.0.0.1 50000
done

执行这段脚本之前先用 netstat 等命令确认 50000 没有进程监听。然后执行脚本,经过一段时间,telnet 居然成功了。


Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.

使用 netstat 查看当前的 50000 端口的连接状况,如下所示。


Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:50000         127.0.0.1:50000         ESTABLISHED 24786/telnet

可以看到源 IP、源端口是 127.0.0.1:50000,目标 ip、目标端口也是 127.0.0.1:50000,通过上面的脚本,我们连上了本来没有监听的端口号。

3.自连接原因分析

自连接成功的抓包结果如下图所示。

image.png

对于自连接而言,上图中 wireshark 中的每个包的发送接收双方都是自己,所以可以理解为总共是六个包,包的交互过程如下图所示。

image.png

这个图是不是似曾相识?前四个包的交互过程就是 TCP 同时打开的过程。

当一方主动发起连接时,操作系统会自动分配一个临时端口号给连接主动发起方。如果刚好分配的临时端口是 50000 端口,过程如下:

  • 第一个包是发送 SYN 包给 50000 端口
  • 对于发送方而已,它收到了这个 SYN 包,以为对方是想同时打开,会回复 SYN+ACK
  • 回复 SYN+ACK 以后,它自己就会收到这个 SYN+ACK,以为是对方回的,对它而已握手成功,进入 ESTABLISHED 状态

4.自连接的危害

设想一个如下的场景:

  • 你写的业务系统 B 会访问本机服务 A,服务 A 监听了 50000 端口
  • 业务系统 B 的代码写的稍微比较健壮,增加了对服务 A 断开重连的逻辑
  • 如果有一天服务 A 挂掉比较长时间没有启动,业务系统 B 开始不断 connect 重连
  • 系统 B 经过一段时间的重试就会出现自连接的情况
  • 这时服务 A 想启动监听 50000 端口就会出现地址被占用的异常,无法正常启动

如果出现了自连接,至少有两个显而易见的问题:

  • 自连接的进程占用了端口,导致真正需要监听端口的服务进程无法监听成功
  • 自连接的进程看起来 connect 成功,实际上服务是不正常的,无法正常进行数据通信

5.如何解决自连接问题

自连接比较罕见,但一旦出现逻辑上就有问题了,因此要尽量避免。解决自连接有两个常见的办法。

  • 让服务监听的端口与客户端随机分配的端口不可能相同即可
  • 出现自连接的时候,主动关掉连接

对于第一种方法,客户端随机分配的范围由 /proc/sys/net/ipv4/ip_local_port_range 文件决定,在我的 Centos 8 上,这个值的范围是 32768~60999,只要服务监听的端口小于 32768 就不会出现客户端与服务端口相同的情况。这种方式比较推荐。

6.如何修改这个问题

只需要在连接建立以后判断是不是自连接,如果是自连接,close 这个连接即可。

image.png

麻烦的是写测试用例,首先要获取一个可用端口来测试,如果我只是随机来选一个端口号,有可能这个端口本身就被服务器的某个程序锁监听,很容易出现端口号冲突的情况。所以一开始我采用的是 bind(0)端口由内核来分配一个空闲的端口出来,如下所示。


private fun getAvailablePort(): Int {
    val port = ServerSocket().apply {
                bind(InetSocketAddress("127.0.0.1", 0))
                close()
            }.localPort
}

问题就出在这里,用这种方式我在 ubuntu 18.04 上测试怎么样都复现不了问题,换成固定的 50000 等端口就可以复现。

7.为什么上面的测试代码一定连接偶数端口才可以呢?

早期的 Linux 内核版本上,connect 非偶数端口也是可以复现的,在新的 4.2 内核版本中引入了一个特性(部分 Linux 发行版本有没有 backport 这个 feature) https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=07f4c90062f8fc7c8c26f8f95324cbe8fa3145a5 ,如下图所示。
image.png

/proc/sys/net/ipv4/ip_local_port_range 文件指定了临时端口号的下界 low 和上界 high,默认情况,low 是偶数,在我的电脑上 low 和 high 的值分别是 32768 和 60999。

简单来说新版内核对端口的分配策略做了一些调整:

  • 优先给 bind(0) 分配随机的与 low 奇偶性不同的端口,也就是奇数端口。如果奇数端口号分配完了,才去尝试分配偶数端口
  • 优先给 connect 分配与 low 奇偶性相同的临时端口,也就是偶数端口。如果偶数端口号分配完了,才去尝试分配奇数端口

可以写一段下面的代码来测试。


#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void print_local_port() {
    int sockfd;
    if (sockfd = socket(AF_INET, SOCK_STREAM, 0), -1 == sockfd) {
        perror("socket create error");
    }
    // 连接本地的 8080 端口的服务器
    const struct sockaddr_in remote_addr = {
            .sin_family = AF_INET,
            .sin_port   = htons(8080),
            .sin_addr   = htonl(INADDR_ANY)
    };
    if (connect(sockfd, (const struct sockaddr *) &remote_addr, sizeof(remote_addr)) < 0) {
        perror("connect error");
    }
    // 获取本地套接字地址
    const struct sockaddr_in local_addr;
    socklen_t local_addr_len = sizeof(local_addr);
    if (getsockname(sockfd, (struct sockaddr *) &local_addr, &local_addr_len) < 0) {
        perror("getsockname error");
    }
    printf("local port: %d\n", ntohs(local_addr.sin_port));
    close(sockfd);
}

int main() {
    int i;
    for (i = 0; i < 10; i++) {
        print_local_port();
    }
    return 0;
}

运行上面的代码就可以看到 connect 10 次的本地端口号是全是偶数。


$ ./a.out 
local port: 49238
local port: 49240
local port: 49242
local port: 49244
local port: 49246
local port: 49248
local port: 49250
local port: 49252
local port: 49254
local port: 49256

也可以写一段类似的代码来测试在端口资源充足的情况下,bind(0) 内核会随机分配一个奇数的端口号。


// bind(0)
const struct sockaddr_in serv_addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(0),
        .sin_addr   = htonl(INADDR_ANY)
};

if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
    perror("bind error");
}

// 获取本地套接字地址
const struct sockaddr_in local_addr;
socklen_t local_addr_len = sizeof(local_addr);
if (getsockname(sockfd, (struct sockaddr *) &local_addr, &local_addr_len) < 0) {
    perror("getsockname error");
}

printf("bind local port: %d\n", ntohs(local_addr.sin_port));
close(sockfd);

在新版的内核中输出结果如下,可以看到 bind(0) 返回的随机端口号全是奇数。


$ ./a.out
bind local port: 37173
bind local port: 43605
bind local port: 51155
bind local port: 37209
bind local port: 45985
bind local port: 57833
bind local port: 39517
bind local port: 45873
bind local port: 42387
bind local port: 53887

因此在之前的 getAvailablePort 例子中,bind(0) 返回的随机端口都是奇数,connect 的临时端口号都是偶数,这下永远无法 self connect 成功了,也就无从测试是否改动是必要的了。

8.bind(0) 端口号奇偶性源码分析

bind 是一个系统调用,这个函数最终调用到了 inet_csk_find_open_port 函数里,调用栈如下图所示。

image.png

这里有一个很关键的一行:

offset |= 1U

实际上这句话的意思是将生成随机数变为奇数,于是后面的 port 生成方式


port = low + offset;

这样 low 会与一个奇数相加:

  • 如果 low 是一个奇数,则 port 就是一个偶数
  • 如果 low 是一个偶数,则 port 就是一个奇数
    用这种方式,就实现了生成的端口号与端口范围的下界 low 值保持奇偶相反。在 low 值默认为偶数的情况下,bind(0) 随机生成的端口号就是一个奇数。

9.connect 临时端口号的奇偶性分析

connect 临时端口号的分配源码在 __inet_hash_connect 中实现

image.png

可以看到,与 bind(0) 正好相反,它将 offset 通过下面这种方式强制变为了偶数。


offset &= ~1U;

这样后面与 low 相加,port 就与 low 的奇偶性保持一致。

10.测试代码修改

那要怎么改呢?这里简单在 for 循环里尝试取一个可用的偶数端口即可。


private fun getAvailablePort(): Int {
    while (true) {
        val port = ServerSocket().apply {
            bind(InetSocketAddress("127.0.0.1", 0))
            close()
        }.localPort

        if (port % 2 == 0) {
            return port
        }

        try {
            // try bind the next even port
            ServerSocket().apply {
                bind(InetSocketAddress("127.0.0.1", port + 1))
                close()
            }
            return port + 1
        } catch (ex: Exception) {
            // ignore
        }
    }
}

11.番外篇

这个问题不是 Kotlin 或者 Java 特有的,我在 Golang 的源码时早期的 connect 的代码也有这个问题,不过后面有人提了 issue 修复了,代码如下所示。


func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
 fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)

 // TCP has a rarely used mechanism called a 'simultaneous connection' in
 // which Dial("tcp", addr1, addr2) run on the machine at addr1 can
 // connect to a simultaneous Dial("tcp", addr2, addr1) run on the machine
 // at addr2, without either machine executing Listen. If laddr == nil,
 // it means we want the kernel to pick an appropriate originating local
 // address. Some Linux kernels cycle blindly through a fixed range of
 // local ports, regardless of destination port. If a kernel happens to
 // pick local port 50001 as the source for a Dial("tcp", "", "localhost:50001"),
 // then the Dial will succeed, having simultaneously connected to itself.
 // This can only happen when we are letting the kernel pick a port (laddr == nil)
 // and when there is no listener for the destination address.
 // It's hard to argue this is anything other than a kernel bug. If we
 // see this happen, rather than expose the buggy effect to users, we
 // close the fd and try again. If it happens twice more, we relent and
 // use the result. See also:
 // https://golang.org/issue/2690
 // https://stackoverflow.com/questions/4949858/
 //
 // The opposite can also happen: if we ask the kernel to pick an appropriate
 // originating local address, sometimes it picks one that is already in use.
 // So if the error is EADDRNOTAVAIL, we have to try again too, just for
 // a different reason.
 //
 // The kernel socket code is no doubt enjoying watching us squirm.
 for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ {
  if err == nil {
   fd.Close()
  }
  fd, err = internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
 }

 if err != nil {
  return nil, err
 }
 return newTCPConn(fd), nil
}

func selfConnect(fd *netFD, err error) bool {
 // If the connect failed, we clearly didn't connect to ourselves.
 if err != nil {
  return false
 }

 // The socket constructor can return an fd with raddr nil under certain
 // unknown conditions. The errors in the calls there to Getpeername
 // are discarded, but we can't catch the problem there because those
 // calls are sometimes legally erroneous with a "socket not connected".
 // Since this code (selfConnect) is already trying to work around
 // a problem, we make sure if this happens we recognize trouble and
 // ask the DialTCP routine to try again.
 // TODO: try to understand what's really going on.
 if fd.laddr == nil || fd.raddr == nil {
  return true
 }
 l := fd.laddr.(*TCPAddr)
 r := fd.raddr.(*TCPAddr)
 return l.Port == r.Port && l.IP.Equal(r.IP)
}

这里详细解释了为什么有 selfConnect 方法的判断,判断是否是自连接的逻辑是判断源 IP 和目标 IP 是否相等,源端口号和目标端口号是否相等。

一点感想
提交一个 pr 改动的代码可能就几行,但测试代码写起来还是比较头疼的。

点赞收藏
挖坑的张师傅

机械工业出版社《深入理解 JVM 字节码》作者,掘金小册作者《JVM 字节码从入门到精通》、《深入理解TCP 协议》作者,Vim 死忠粉、Kotlin&Go 爱好者、能抓一手好包、喜欢底层技术和分享。微信公众号:张师傅的博客(shifuzhang01)

请先登录,查看18条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
8
18

机械工业出版社《深入理解 JVM 字节码》作者,掘金小册作者《JVM 字节码从入门到精通》、《深入理解TCP 协议》作者,Vim 死忠粉、Kotlin&Go 爱好者、能抓一手好包、喜欢底层技术和分享。微信公众号:张师傅的博客(shifuzhang01)