开发内功修炼@张彦飞开发内功修炼@张彦飞

talk is cheap,
show me the code!

TCP连接中客户端的端口号是如何确定的?

大家好,我是飞哥!

在 TCP 连接中,客户端在发起连接请求前会先确定一个客户端端口,然后用这个端口去和服务器端进行握手建立连接。那么在 Linux 上,客户端的端口到底是如何被确定下来的呢?

事实上很多我们平时遇到的问题都和这个端口选择过程相关,如果能深度理解这个过程,将有助于我们对这些问题的深刻理解。

  • Cannot assign requested address 报错是怎么回事?
  • 一个客户端端口可以同时用在两条 TCP 连接上吗?

还是让我们借助一段简单到只有两句的代码,从这个来讲起!

int main(){
    fd = socket(AF_INET,SOCK_STREAM, 0);
    connect(fd, ...);
    ...
}

一、创建 socket

客户端在发起连接的时候,需要事先创建一个 socket。在 c 语言中,就是调用 socket 函数,例如 socket(AF_INET,SOCK_STREAM, 0) 这句。

socket 函数执行完毕后,在用户层视角我们是看到返回了一个文件描述符 fd。但在内核中其实是一套内核对象组合,大体结构如下。

1_socket.png

从上图我们看到,socket 在内核里并不是一个内核对象。而是包含 file、socket、sock 等多个相关内核对象构成,每个内核对象还定义了 ops 操作函数集合。 在后面的内核源码执行过程中,我们需要时不时回头来看这些内核对象,这里先简单了解一下就行。

这些内核对象都是在 socket 系统调用执行过程中创建出来的。为了避免喧宾夺主,这里只列出入口代码,详细过程就不展开介绍了。

//file: net/socket.c
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
    //创建 socket、sock 等内核对象,并初始化
    sock_create(family, type, protocol, &sock);

    //创建 file 内核对象,申请 fd
    sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));

    ......
}

二、connect 发起连接

接下来我们就进入到 connect 函数的执行过程中来。由于这个过程比较长,所以我们分成几个小节来进行讨论。

2.1 connect 调用链展开

当我们在客户端机上调用 connect 函数的时候,事实上会进入到内核的系统调用源码中进行执行。

//file: net/socket.c
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,
        int, addrlen)
{
    struct socket *sock;

    //根据用户 fd 查找内核中的 socket 对象
    sock = sockfd_lookup_light(fd, &err, &fput_needed);

    //进行 connect
    err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,
                 sock->file->f_flags);
    ...
}

这段代码首先根据用户传入的 fd(文件描述符)来查询对应的 socket 内核对象。在第一节中我们看了 socket 内核对象结构,据此可以知道接下来 sock->ops->connect 其实调用的是 inet_stream_connect 函数。

//file: ipv4/af_inet.c
int inet_stream_connect(struct socket *sock, ...)
{    
    ...
    __inet_stream_connect(sock, uaddr, addr_len, flags);
}

int __inet_stream_connect(struct socket *sock, ...)
{
    struct sock *sk = sock->sk;

    switch (sock->state) {
        case SS_UNCONNECTED:
            err = sk->sk_prot->connect(sk, uaddr, addr_len);
            sock->state = SS_CONNECTING;
            break;
    }
    ...
}

刚创建完毕的 socket 的状态就是 SS_UNCONNECTED,所以在 \_\_inet_stream_connect 中的 switch 判断会进入到 case SS_UNCONNECTED 的处理逻辑中。

上述代码中 sk 取的是 sock 对象。继续回顾第一节中 socket 的内核数据结构图,可以得知 sk->sk_prot->connect 实际上对应的是 tcp_v4_connect 方法。

我们来看 tcp_v4_connect 的代码,它位于 net/ipv4/tcp_ipv4.c。

//file: net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
    //设置 socket 状态为 TCP_SYN_SENT
    tcp_set_state(sk, TCP_SYN_SENT);

    //动态选择一个端口
    err = inet_hash_connect(&tcp_death_row, sk);

    //函数用来根据 sk 中的信息,构建一个完成的 syn 报文,并将它发送出去。
    err = tcp_connect(sk);
}

在 tcp_v4_connect 中我们终于看到了选择端口的函数,那就是 inet_hash_connect。

2.2 选择可用端口

我们找到 inet_hash_connect 的源码,我们来看看到底端口是如何选择出来的。

//file:net/ipv4/inet_hashtables.c
int inet_hash_connect(struct inet_timewait_death_row *death_row,
              struct sock *sk)
{
    return __inet_hash_connect(death_row, sk, inet_sk_port_offset(sk),
            __inet_check_established, __inet_hash_nolisten);
}

这里需要提一下在调用 \_\_inet_hash_connect 时传入的两个重要参数。

  • inet_sk_port_offset(sk):这个函数是根据要连接的目的 IP 和端口等信息生成一个随机数。
  • \_\_inet_check_established:检查是否和现有 ESTABLISH 的连接是否冲突的时候用的函数

了解了这两个参数后,让我们进入 \_\_inet_hash_connect。这个函数比较长,为了方便理解,我们先看前面这一段。

//file:net/ipv4/inet_hashtables.c
int __inet_hash_connect(...)
{
    //是否绑定过端口
    const unsigned short snum = inet_sk(sk)->inet_num;

    //获取本地端口配置
    inet_get_local_port_range(&low, &high);
        remaining = (high - low) + 1;

    if (!snum) {
        //遍历查找
        for (i = 1; i <= remaining; i++) {
            port = low + (i + offset) % remaining;
            ...
        }
    }
}

在这个函数中首先判断了 inet_sk(sk)->inet_num,如果我们调用过 bind,那么这个函数会选择好端口并设置在 inet_num 上。这个我们后面专门分一小节介绍。这里我们假设没有调用过 bind,所以 snum 为 0。

接着调用 inet_get_local_port_range,这个函数读取的是 net.ipv4.ip_local_port_range 这个内核参数。 来读取管理员配置的可用的端口范围。

该参数的默认值是 32768 61000,意味着端口总可用的数量是 61000 - 32768 = 28232 个。如果你觉得这个数字不够用,那就修改你的 net.ipv4.ip_local_port_range 内核参数。

接下来进入到了 for 循环中。其中offset 是我们前面说的,通过 inet_sk_port_offset(sk) 计算出的随机数。那这段循环的作用就是从某个随机数开始,把整个可用端口范围来遍历一遍。直到找到可用的端口后停止。

那么我们接着来看,如何来确定一个端口是否可以使用呢?

//file:net/ipv4/inet_hashtables.c
int __inet_hash_connect(...)
{
    for (i = 1; i <= remaining; i++) {
        port = low + (i + offset) % remaining;

        //查看是否是保留端口,是则跳过
        if (inet_is_reserved_local_port(port))
            continue;

        // 查找和遍历已经使用的端口的哈希链表
        head = &hinfo->bhash[inet_bhashfn(net, port,
                hinfo->bhash_size)];
        inet_bind_bucket_for_each(tb, &head->chain) {

            //如果端口已经被使用
            if (net_eq(ib_net(tb), net) &&
                tb->port == port) {

                //通过 check_established 继续检查是否可用
                if (!check_established(death_row, sk,
                            port, &tw))
                    goto ok;
            }
        }

        //未使用的话,直接 ok
        goto ok;
    }

    return -EADDRNOTAVAIL;
ok:    
    ...        
}

首先判断的是 inet_is_reserved_local_port,这个很简单就是判断要选择的端口是否在 net.ipv4.ip_local_reserved_ports 中,在的话就不能用。

如果你因为某种原因不希望某些端口被内核使用,那么就把它们写到 ip_local_reserved_ports 这个内核参数中就行了。

整个系统中会维护一个所有使用过的端口的哈希表,它就是 hinfo->bhash。接下来的代码就会在这里进行查找。如果在哈希表中没有找到,那么说明这个端口是可用的。至此端口就算是找到了。

遍历完所有端口都没找到合适的,就返回 -EADDRNOTAVAIL,你在用户程序上看到的就是 Cannot assign requested address 这个错误。怎么样,是不是很眼熟,你见过它的对吧,哈哈!

/* Cannot assign requested address */
#define    EADDRNOTAVAIL    99    

以后当你再遇到 Cannot assign requested address 错误,你应该想到去查一下 net.ipv4.ip_local_port_range 中设置的可用端口的范围是不是太小了。

2.3 端口被使用过怎么办

回顾刚才的 \_\_inet_hash_connect, 为了描述简单我们之前跳过了已经在 bhash 中存在时候的判断。 这是由于其一这个过程比较长,其二这段逻辑很有价值,所以飞哥单独拉一小节出来。

//file:net/ipv4/inet_hashtables.c
int __inet_hash_connect(...)
{
    for (i = 1; i <= remaining; i++) {
        port = low + (i + offset) % remaining;

        ...
        //如果端口已经被使用
        if (net_eq(ib_net(tb), net) &&
                tb->port == port) {
            //通过 check_established 继续检查是否可用
            if (!check_established(death_row, sk, port, &tw))
                goto ok;
        }
    }
}

port 已经在 bhash 中如果已经存在,就表示有其它的连接使用过该端口了。请注意,如果 check_established 返回 0,该端口仍然可以接着使用!

这里可能会让很多同学困惑了,一个端口怎么可以被用多次呢?

回忆下四元组的概念,两对儿四元组中只要任意一个元素不同,都算是两条不同的连接。以下的两条 TCP 连接完全可以同时存在(假设 192.168.1.101 是客户端,192.168.1.100 是服务端)

  • 连接1:192.168.1.101 5000 192.168.1.100 8090
  • 连接2:192.168.1.101 5000 192.168.1.100 8091

check_established 作用就是检测现有的 TCP 连接中是否四元组和要建立的连接四元素完全一致。如果不完全一致,那么该端口仍然可用!!!

这个 check_established 是由调用方传入的,实际上使用的是 \_\_inet_check_established。我们来看它的源码。

//file: net/ipv4/inet_hashtables.c
static int __inet_check_established(struct inet_timewait_death_row *death_row,
                    struct sock *sk, __u16 lport,
                    struct inet_timewait_sock **twp)
{
    //找到hash桶
    struct inet_ehash_bucket *head = inet_ehash_bucket(hinfo, hash);

    //遍历看看有没有四元组一样的,一样的话就报错
    sk_nulls_for_each(sk2, node, &head->chain) {
        if (sk2->sk_hash != hash)
            continue;
        if (likely(INET_MATCH(sk2, net, acookie,
                      saddr, daddr, ports, dif)))
            goto not_unique;
    }

unique:
    //要用了,记录,返回 0 (成功)
    return 0;
not_unique:
    return -EADDRNOTAVAIL;    
}

该函数首先找到 inet_ehash_bucket,这个和 bhash 类似,只不过是所有 ESTABLISH 状态的 socket 组成的哈希表。然后遍历这个哈希表,使用 INET_MATCH 来判断是否可用。

这里 INET_MATCH 源码如下:

// include/net/inet_hashtables.h
#define INET_MATCH(__sk, __net, __cookie, __saddr, __daddr, __ports, __dif) \
 ((inet_sk(__sk)->inet_portpair == (__ports)) &&  \
  (inet_sk(__sk)->inet_daddr == (__saddr)) &&  \
  (inet_sk(__sk)->inet_rcv_saddr == (__daddr)) &&  \
  (!(__sk)->sk_bound_dev_if ||    \
    ((__sk)->sk_bound_dev_if == (__dif)))  &&  \
  net_eq(sock_net(__sk), (__net)))

在 INET_MATCH 中将 \_\_saddr、\_\_daddr、\_\_ports 都进行了比较。当然除了 ip 和端口,INET_MATCH还比较了其它一些东东,所以 TCP 连接还有五元组、七元组之类的说法。为了统一,咱们还沿用四元组的说法。

如果 MATCH,就是说就四元组完全一致的连接,所以这个端口不可用。也返回 -EADDRNOTAVAIL。

如果不 MATCH,哪怕四元组中有一个元素不一样,例如服务器的端口号不一样,那么就 return 0,表示该端口仍然可用于建立新连接。

所以一台客户端机最大能建立的连接数并不是 65535。只要 server 足够多,单机发出百万条连接没有任何问题。

2.4 发起 syn 请求

再回到 tcp_v4_connect,这时我们的 inet_hash_connect 已经返回了一个可用端口了。接下来就进入到 tcp_connect,如下源码所示。

//file: net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
    ......

    //动态选择一个端口
    err = inet_hash_connect(&tcp_death_row, sk);

    //函数用来根据 sk 中的信息,构建一个完成的 syn 报文,并将它发送出去。
    err = tcp_connect(sk);
}

到这里其实就和本文要讨论的主题没有关系了,所以我们只是简单看一下。

//file:net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{
    //申请并设置 skb
    buff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);
    tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);

    //添加到发送队列 sk_write_queue 上
    tcp_connect_queue_skb(sk, buff);

    //实际发出 syn
    err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
          tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);

    //启动重传定时器
    inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
                  inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
}

tcp_connect 一口气做了这么几件事

  • 申请一个 skb,并将其设置为 SYN 包
  • 添加到发送队列上
  • 调用 tcp_transmit_skb 将该包发出
  • 启动一个重传定时器,超时会重发

三、bind 时端口如何选择

在 2.2 小节中,我们看到 connect 选择端口之前先判断了 inet_sk(sk)->inet_num 有没有值。如果有的话就直接用这个,而会跳过端口选择过程。

那么这个值是从哪儿来的呢?不卖关子,它就是在对 socket 使用 bind 时设置的。

不只是服务器端,哪怕是对于客户端,也可以对 socket 使用 bind 来绑定 IP 或者端口。如果使用了 bind,那么在 bind 的时候就会确定好端口,并设置到 inet_num 变量中。

一般非常不推荐在客户端角色下使用 bind。因为这会打乱 connect 里的端口选择过程。

bind 的时候,如果传了端口,那么 bind 就会尝试使用该端口。如果端口号传的是 0 ,那么 bind 有一套独立的选择端口号的逻辑。

//file: net/ipv4/af_inet.c
int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
    struct sock *sk = sock->sk;
    ...

    //用户传入的端口号
    snum = ntohs(addr->sin_port);

    //不允许绑定 1024 以下的端口
    if (snum && snum < PROT_SOCK &&
        !ns_capable(net->user_ns, CAP_NET_BIND_SERVICE))
        goto out;

    //尝试确定端口号
    if (sk->sk_prot->get_port(sk, snum)) {
        inet->inet_saddr = inet->inet_rcv_saddr = 0;
        err = -EADDRINUSE;
        goto out_release_sock;
    }

根据第一节中的 socket 内核对象,能找到 sk->sk_prot->get_port 实际调用的是 inet_csk_get_port。该函数来尝试确定端口号,如果尝试失败,返回 EADDRINUSE。你的应用程序将会显示一条错误信息 “Address already in use”。

#define    EADDRINUSE    226    /* Address already in use */

我们简单看一下如果用户没有传入端口(传入的为 0),bind 是怎么选择端口的。

//file: net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
    ...

    if (!snum) {
        inet_get_local_port_range(&low, &high);
        remaining = (high - low) + 1;
        smallest_rover = rover = net_random() % remaining + low;

        do {
            if (inet_is_reserved_local_port(rover))
                goto next_nolock;

            head = &hashinfo->bhash[inet_bhashfn(net, rover,
                        hashinfo->bhash_size)];
            inet_bind_bucket_for_each(tb, &head->chain)

                // 冲突检测
                if (!inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb, false)) {
                    snum = rover;
                    goto tb_found;
                }

        } while (--remaining > 0);
    }
}

这段逻辑和 connect 很像,通过 net_random 来从 net.ipv4.ip_local_port_range 指定的端口范围内一个随机位置开始遍历。也会跳开 ip_local_reserved_ports 保留端口配置。 通过 inet_csk(sk)->icsk_af_ops->bind_conflict 进行冲突检测。

inet_csk_bind_conflict 这个函数整体比较复杂,不过我们只需要了解一点就好,该函数和 connect 中端口选择逻辑不同的是,并不会到 ESTABLISH 的哈希表进行可用检测,只在 bind 状态的 socket 里查。所以默认情况下,只要端口用过一次就不会再次使用

四、结论

客户端建立连接前需要确定一个端口,该端口会在两个位置进行确定。

第一个位置,也是最主要的确定时机是 connect 系统调用执行过程。在 connect 的时候,会随机地从 ip_local_port_range 选择一个位置开始循环判断。找到可用端口后,发出 syn 握手包。如果端口查找失败,会报错 “Cannot assign requested address”。这个时候你应该首先想到去检查一下服务器上的 net.ipv4.ip_local_port_range 参数,是不是可以再放的多一些。

如果你因为某种原因不希望某些端口被使用到,那么就把它们写到 ip_local_reserved_ports 这个内核参数中就行了,内核在选择的时候会跳过这些端口。

另外注意即使是一个端口是可以被用于多条 TCP 连接的。所以一台客户端机最大能建立的连接数并不是 65535。只要 server 足够多,单机发出百万条连接没有任何问题。我给大伙儿贴一下我实验时候在客户机上实验时的实际截图,来实际看一下一个端口号确实是被用在了多条连接上了。

reuse.png

截图中左边的 192 是客户端,右边的 119 是服务器的 ip。可以看到客户端的 10000 这个端口号是用在了多条连接上了的。

第二个位置,如果在 connect 之前使用了 bind,将会使得 connect 时的端口选择方式无效。转而使用 bind 时确定的端口。bind 时如果传入了端口号,会尝试首先使用该端口号,如果传入了 0 ,也会自动选择一个。但默认情况下一个端口只会被使用一次。所以对于客户端角色的 socket,不建议使用 bind !

最后我再想多说一句,上面的选择端口的都是从 ip_local_port_range 范围中的某一个随机位置开始循环的。如果可用端口很充足,则能快一点找到可用端口,那循环很快就能退出。假设实际中 ip_local_port_range 中的端口快被用光了,这时候内核就大概率得把循环多执行很多轮才能找到,这会导致 connect 系统调用的 CPU 开销的上涨。

所以,最好不要等到端口不够用了才加大 ip_local_port_range 的范围,而是事先就应该保持一个充足的范围。

写在最后,由于我的这些知识在公众号里文章比较分散,很多人似乎没有理解到我对知识组织的体系结构。而且图文也不像视频那样理解起来更直接。所以我在知识星球上规划了视频系列课程,包括硬件原理、内存管理、进程管理、文件系统、网络管理、Golang语言、容器原理、性能观测、性能优化九大部分大约 120 节内容,每周更新。加入方式参见我要开始搞知识星球啦如何才能高效地学习技术,我投“融汇贯通”一票

Github:https://github.com/yanfeizhang/coder-kung-fu
关注公众号:微信扫描下方二维码
qrcode2_640.png

本原创文章未经允许不得转载 | 当前页面:开发内功修炼@张彦飞 » TCP连接中客户端的端口号是如何确定的?