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

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

5680177

前言

去年 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 改动的代码可能就几行,但测试代码写起来还是比较头疼的。

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

为你推荐

在调试器里看LINUX内核态栈溢出
图灵最先发明了栈,但没有给它取名字。德国人鲍尔也“发明”了栈,取名叫酒窖。澳大利亚人汉布林也“发明”了栈,取名叫弹夹。1959年,戴克斯特拉在度假时想到了Stack这个名字,后来被广泛使用。
LONG究竟有多长,从皇帝的新衣到海康SDK
转眼之间初中毕业30年了,但我仍清楚的记得初中英语的一篇课文,题目叫《皇帝的新装》(“The king’s new clothes”)。这篇课文的前两句话是:”Long long ago, there
雕刻在LINUX内核中的LINUS故事
因为LINUX操作系统的流行,Linus 已经成为地球人都知道的名人。虽然大家可能都听过钱钟书先生的名言:“假如你吃个鸡蛋觉得味道不错,又何必认识那个下蛋的母鸡呢?” 但是如果真是遇到一个“特别显赫”
如何使用Linux内核中没有被导出的变量或函数?
本文详细介绍了使用EXPORT_SYMBOL宏导出函数或变量、使用kallsyms_lookup_name()查找函数或变量的虚拟地址以及内核模块中直接使用内核函数的虚拟地址等3种方案解决没有被EXPORT_SYMBOL 相关的宏导出的变量或函数不能直接使用的问题
LINUX网络子系统中DMA机制的实现
我们先从计算机组成原理的层面介绍DMA,再简单介绍Linux网络子系统的DMA机制是如何的实现的。 计算机组成原理中的DMA 以往的I/O设备和主存交换信息都要经过CPU的操作。不论是最早的轮询方式,
内存泄漏(增长)火焰图
本文总结了在分析内存增长和内存泄漏问题用到的4种追踪方法得到有关内存使用情况的代码路径,使用栈追踪技术对代码路径进行检查,并且会以火焰图的形式把它们可视化输出,在Linux上演示分析过程,随后概述其它系统的情况。
为什么容器内存占用居高不下,频频 OOM(续)
在之前的文章《[为什么容器内存占用居高不下,频频 OOM](https://heapdump.cn/article/1589003)》 中,我根据现状进行了分析和说明,收到了很多读者的建议和疑
通过生产者与消费者模型感受死锁
一. 实验目的及实验环境 1.实验目的通过观察、分析实验现象,深入理解产生死锁的原因,学会分析死锁的方法, 并利用 pstack、 gdb 或 core 文件分析( valgrind (DRD+Hel
7
17
关于作者

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