Skip to content

08 临时端口号是如何分配的

我们知道客户端主动发起请求 connect 时,操作系统会为它分配一个临时端口(ephemeral port)。在 linux 上 这个端口的取值范围由 /proc/sys/net/ipv4/ip_local_port_range 文件的值决定,在我的 CentOS 机器上,临时端口的范围是 32768~60999。

有两种典型的使用方式会生成临时端口:

  • 调用 bind 函数不指定端口
  • 调用 connect 函数

先来看 bind 调用的例子,故意注释掉端口的赋值,完整的代码如下。

c
int main(void) {
  int listenfd;
  socklen_t clilen;
  struct sockaddr_in cliaddr, servaddr;
  listenfd = socket(AF_INET, SOCK_STREAM, 0);
  bzero(&servaddr, sizeof(servaddr));
  servaddr.sin_family = AF_INET;
  servaddr.sin_addr.s_addr = htonl (INADDR_ANY);
  // 这里故意注释掉端口的赋值
  //  servaddr.sin_port = htons (9090);
  bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
  listen(listenfd, 5);
  clilen = sizeof(cliaddr);
  accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
  sleep(-1);
  return 1;
}

编译执行上面的代码,使用 netstat 可以看到 linux 自动为其分配了一个临时的端口 40843。

bash
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:40843           0.0.0.0:*               LISTEN      21608/./a.out

再来看第二个例子客户端 connect,使用 nc 或者 telnet 访问本地或远程的服务时,都会自动分配一个临时端口号。比如执行 nc localhost 8080 访问本机的 web 服务器,随后使用 netstat 查看连接状态,可以看到分配了临时端口号 37778。

bash
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:37778         127.0.0.1:8080          ESTABLISHED 22126/nc

01 临时端口号分配的源码分析

接下来的内容以 connect 为例,linux 内核版本是 3.10.0。核心的代码在 net/ipv4/inet_hashtables.c 中,为了方便我做了部分精简。

c
int __inet_hash_connect(struct sock *sk, u32 port_offset) {
  int low;  // 临时端口号的下界
  int high; // 临时端口号的上界
  static u32 hint; // 使用静态变量保存的递增值,减少 offset 冲突的可能性
  // port_offset 是根据源地址、目的地址、目标端口计算出的哈希值
  u32 offset = hint + port_offset;
  int port;

  // 读取 /proc/sys/net/ipv4/ip_local_port_range 的临时端号的上界和下界
  inet_get_local_port_range(net, &low, &high);

  // remaining 是临时端口号可分配值的范围
  int remaining = (high - low) + 1;

  /* By starting with offset being an even number,
   * we tend to leave about 50% of ports for other uses,
   * like bind(0).
   */
  offset &= ~1; // 将最后一位置为 0

  int i;
  // 从 0 开始遍历,查找未被占用的端口号
  for (i = 0; i < remaining; i++) {
    // 保证 port 的范围是在 low~high 之间
    port = low + (i + offset) % remaining;
    // 检查端口号是否属于保留端口号
    if (inet_is_reserved_local_port(port))
      continue;
    // 接下来检查端口是否被占用、等逻辑
    if (all_ok) {
      goto ok;
    }

  }
  ok:
      // 下次 connect 时 hint 递增,减少端口号冲突的概率
      hint += (i + 2) & ~1;
}

其中传入的 port_offset 的计算逻辑是在 net/core/secure_seq.c 的 secure_ipv4_port_ephemeral 方法中实现的,代码如下。

c
u32 secure_ipv4_port_ephemeral(__be32 saddr, __be32 daddr, __be16 dport)
{
    u32 hash[MD5_DIGEST_WORDS];
	net_secret_init();
	hash[0] = (__force u32)saddr; // 源地址
	hash[1] = (__force u32)daddr; // 目标地址
	hash[2] = (__force u32)dport ^ net_secret[14]; // 目标端口号
	hash[3] = net_secret[15];
	md5_transform(hash, net_secret); // 计算 MD5 值
	return hash[0];
}

因为此时还没有源端口,这个函数使用源地址、目标地址、目标端口号这三个元素进行 MD5 运算得到一个 offset 值,通过同一组源地址、目标地址、目标端口号计算出的 offset 值相等,这也是为什么需要加入地址 hint 的原因,否则使对同一个目标端口服务同时进行请求时,第一次 for 循环计算出来的端口都是一样的。加入了递增的 hint 以后,就可以避免这种情况了。

02 内核调试

以一次实际的计算为例,经过调试 linux 内核,在某一次 telnet localhost 2000 过程中,分配到的临时端口号是 48968,如下所示。

bash
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:48968         127.0.0.1:2000          ESTABLISHED 16475/telnet

下面看下计算的过程。

  • 根据 ip_local_port_range 的值,low=32768,high=48948,remaining=28232
  • 在我的虚拟机中,除了测试的代码没有跑其它的应用,分配端口号不会冲突,面代码中的 for 循环只会循环一次,i 值等于 0。
  • 在此次测试中 hint=32,port_offset=266836801
c
// offset = 32 + 266836801 = 0xfe79b61
u32 offset = hint + port_offset;

// offset = 0xfe79b60
offset &= ~1; // 将最后一位置为 0
// port = 32768 + (0 + 0xfe79b60) % 28232
// port = 32768 + 16200 = 48968
port = low + (i + offset) % remaining;

03 临时端口号分配完了会发生什么

如果短时间内大量 connect,耗尽了所有临时端口号会发生什么?我们来实测一下。

使用 sysctl 修改 ip_local_port_range 的范围,只允许分配一个端口 50001,如下所示。

bash
sudo sysctl -w net.ipv4.ip_local_port_range="50001 50001"

使用 nc 或者 telnet 等工具发起 TCP 连接,这里使用 nc -4 localhost 22,使用 netstat 查看当前连接信息,可以看到分配的临时端口为 50001,如下所示。

bash
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:50001         127.0.0.1:22            ESTABLISHED 18605/nc

再次执行 nc 发起连接,可以看到这次失败了,如下所示。

bash
nc -4 localhost 22

Ncat: Cannot assign requested address.

使用 strace 查看 nc 命令系统调用。

bash
strace nc -4 localhost 22

系统调用如下所示。

c
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
fcntl(3, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(3, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
connect(3, {sa_family=AF_INET, sin_port=htons(22), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EADDRNOTAVAIL (Cannot assign requested address)
...

可以看到 connect 调用返回了 EADDRNOTAVAIL 错误。使用 golang 的代码和结果如下所示。

go
package main

import (
	"fmt"
	"net"
	"time"
)

func main() {
	// 仅使用 ipv4
	_, err := net.Dial("tcp4", "localhost:22")
	if err != nil {
		fmt.Println(err)
	}
	time.Sleep(time.Minute * 10)
}

编译运行上面的 go 代码结果如下所示。

bash
dial tcp4 127.0.0.1:22: connect: cannot assign requested address