一、前言

  • 在 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,但在内核中其实是一套内核对象组合,大体结构如下:

  • 从上图可以看到,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 内核对象,申请 fdsock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));......
}

三、connect 发起连接

① 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);// 进行 connecterr = 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_SENTtcp_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。

② 选择可用端口

  • 找到 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;}}// 未使用的话,直接 okgoto 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 中设置的可用端口的范围是不是太小了。

③ 端口被使用过怎么办?

  • 回顾刚才的 __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 足够多,单机发出百万条连接没有任何问题。

④ 发起 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) {// 申请并设置 skbbuff = 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);// 实际发出 synerr = 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 时端口如何选择

  • 在上文中,我们看到 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 足够多,单机发出百万条连接没有任何问题。
    • 如下所示,在客户机上实验时的实际截图,来实际看一下一个端口号确实是被用在了多条连接上:

    • 截图中左边的 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 的范围,而是事先就应该保持一个充足的范围。

【网络通信与信息安全】之深入解析TCP连接中如何确定客户端的端口号相关推荐

  1. 解析TCP连接之“三次握手”和“四次挥手”

    葡萄美酒夜光杯,欲饮琵琶马上催. 醉卧沙场君莫笑,古来征战几人回?----唐 · 王翰 · <凉州词> 前言 不管是面试别人还是被别人面试,有很大的可能会被问到TCP的"三次握手 ...

  2. TCP连接中TIME_WAIT连接过多

    2019独角兽企业重金招聘Python工程师标准>>> TCP连接中TIME_WAIT连接过多 转载于:https://my.oschina.net/meowmeow/blog/36 ...

  3. 几种TCP连接中出现RST的情况

    UNIX网络编程上说:产生RST的三个条件是:目的地为某端口的SYN到达,然而在该端口上并没有正在监听 的服务器:TCP想取消一个已有链接:TCP接收到一个根本不存在的连接上的分节. 几种TCP连接中 ...

  4. 网络基础2-3(TCP协议,三次握手,四次挥手,TIME_WAIT状态的作用,TCP如何保证可靠传输,TCP连接中状态转化,滑动窗口,流量控制,快速重传,拥塞窗口,延迟应答,捎带应答,粘包问题)

    TCP协议 TCP协议概念 TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制 TCP协议 ...

  5. 【Tcp】TCP连接中存在大量TIME_WAIT、CLOSE_WAIT的原因【转】

    TCP连接中存在大量TIME_WAIT.CLOSE_WAIT的原因 TCP通信图 TIME_WAIT CLOSE_WAIT TCP通信图 TIME_WAIT 表示客户端主动关闭socket. 原因: ...

  6. 关于tcp连接中timewait的作用

    今天简单的谈一下tcp连接中timewait的作用,如果没有timewait会发生什么呢? 我们知道首先请求关闭连接的一方会存在timewait状态. 首先我们来看一下tcp四次挥手的过程示意图: 客 ...

  7. TCP连接中time_wait在开发中的影响-搜人以鱼不如授之以渔

    根据TCP协议定义的3次握手断开连接规定,发起socket主动关闭的一方socket将进入TIME_WAIT状态,TIME_WAIT状态将持续2个MSL(Max Segment Lifetime),T ...

  8. TCP连接中的ACK与ack

    在TCP协议三报文握手建立连接的过程中,TCP请求报文段中存在ACK和ack两个数值.要搞清楚这两个数值的含义,得了解TCP报文段的首部格式: 1.TCP报文段的首部格式: TCP报文段分为首部与数据 ...

  9. Linux数据链路tcp失败,TCP连接中的异常断开情况处理

    1.TCP连接中可能出现的异常断开情况 假设存在这样一种情况:在两个不同的主机Machine1.Machine2系统上分别运行两个应用程序Application1.Application2,在Appl ...

最新文章

  1. sparkcore写mysql_spark读写mysql
  2. Java黑皮书课后题第9章:*9.4(使用Random类)编写一个程序,创建一个种子为1000的Random对象,然后使用nextInt(100)方法显示0到100之间的前50个随机整数
  3. [转] getBoundingClientRect判断元素是否可见
  4. matlab绘制二元一次函数图像_【八上数学】 一次函数必考知识点(下)
  5. 食物链 POJ - 1182
  6. ubuntu 简单配置samba
  7. java 根据圆心计算圆弧上点的经纬度_【控制测量学】-高斯投影正算公式以及java代码
  8. 表单绑定复选框的值和图片上传
  9. 2022年 微信大数据挑战赛
  10. java学习笔记(3.31)
  11. 计算机图形学——Bresenham画线算法
  12. 明华M1读卡器操作基本方法
  13. fastdfs 原理
  14. 计算机主板电源接口8pin,菜鸟老鸟都要知道 电源接口图文全教程
  15. PHP域名授权查询源码,域名授权系统V1.2完整PHP源码下载_域名授权正版查询系统_源码完全开源...
  16. 双目视觉(1)---立体匹配介绍
  17. iOS 系统视频播放器简单介绍
  18. 创建一个urdf机器人_ROS机器人Diego 1#制作(十六)创建机器人的urdf模型描述文件详解...
  19. 真北敏捷 | 明治维新与敏捷:思想、制度和器物
  20. 腾讯云运维tca题库

热门文章

  1. oracle对大对象类型操作:blob,clob,nclob,bfile
  2. c++ 读写文本文件
  3. Python【每日一问】16
  4. python中硬要写抽象类和抽象方法
  5. Django 模板中使用css, javascript
  6. Vue2+VueRouter2+webpack 构建项目实战(二):目录以及文件结构
  7. 关于eclipse里启动Tomcat访问不到8080页面的问题
  8. Unity3D学习笔记——Unity3D的窗口布局
  9. 计算机位数与内存相关,弄懂电脑的各种位数、内存、存储
  10. powergrep linux版本,PowerShell实现简单的grep功能