理解内核源端口选择--TCP
当 TCP 客户端向外发起连接的时候, 程序通常只会设置 TCP 四元组中的 Dst IP 和 Dst Port, 而 Src IP 和 Src Port 则是由本端内核决定.
其中 Src IP 由路由决定, 而 Src Port 则在 ephemeral range 中选择. 后者可以通过 sysctl 命令查询.
root@switch-router: sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768 60999
那么, 内核何时确定 Src Port ? 如何选择 ? 内核可以选择相同的端口吗 ? 本文重点回答这几个问题.
何时确定 Src Port
这里要分两种情况: (1) connect 前不使用 bind() (2) connect 前使用 bind()
大部分情况: 不使用 bind()
大部分情况下, 客户端都不会使用 bind(), 此时, 内核在用户调用 connect() 时进行源端口选择.
路径大概是:
inet_stream_connect
|- __inet_stream_connect
|- tcp_v4_connect
|- inet_hash_connect
|- __inet_hash_connect ====> 进行源端口选择
小部分情况: 使用 bind()
如果在 connect() 前使用了 bind(), 则之后的端口会在此时就决定
inet_bind
|-- __inet_bind
|-- sk->sk_prot->get_port(sk, snum)
|- inet_csk_get_port ====> 进行源地址绑定
如何选择
这里要先展示内核是如何组织相关的数据结构的. 全局数据 tcp_hashinfo
是内核 tcp 所有 sock 的组织者.
其中包含了三组 hash 桶:
ehash 保存已经 TCP_ESTABLISHED 的 sk bhash 保存本端 sk 的占用情况 lhash 保存本端处于 LISTEN 状态的 sk
struct inet_hashinfo {
struct inet_ehash_bucket *ehash;
spinlock_t *ehash_locks;
unsigned int ehash_mask;
unsigned int ehash_locks_mask;
struct kmem_cache *bind_bucket_cachep;
struct inet_bind_hashbucket *bhash;
unsigned int bhash_size;
/* The 2nd listener table hashed by local port and address */
unsigned int lhash2_mask;
struct inet_listen_hashbucket *lhash2;
};
bhash 的组织形式如下图所示:
端口经过 hash 函数计算,作为链表元素挂到对应的链上, 旗下还有一条链表,保存所有使用该端口的 sk.
挂到同一个端口的 sk 的源端口是相同的, 但四元组一定不是完全一样的
不使用 bind()
内核在__inet_hash_connect()
进行源端口选择. 选择范围是 ip_local_port_range
,
它将从这个区间的 start 位置开始搜索遍历, 检查该端口是否可用
start 由 dst IP 和 dst Port 计算而来. 这样做可以让连接到同一个目的地址端口的连接选择端口是相近的.
可用的判断条件是是否存在相同的四元组, 这通过查询 ehash 完成.
使用 bind()
此时, tcp 在 inet_csk_get_port
进行端口选择.
根据bind()
的端口, 又分为两种情况: 0 和 非0
bind()到0
这表示应用程序让内核自己选择一个端口, 注意此时应用程序还没有connect()
, 也就还不确定 Dst IP 和 Dst Port.
inet_csk_get_port
|-- inet_csk_find_open_port
此时的端口选择和不使用 bind() 时,有相似的地方,也有不同的地方, 相似的地方是也是从 ip_local_port_range
遍历搜索,
不同之处在于测试是否可用时, 不会再去 ehash 中查找 (因为此时连接还没有建立), 而且此时是一旦二元组(源地址+源端口)一致就判决为冲突
举个例子:
假设现在有一条连接: 1.1.1.1:12345 <-> 2.2.2.2:8080
此时本端 socket 绑定 1.1.1.1:12345 就不行, 即使这个 socket 将来是为了向 3.3.3.3 发起连接也不行.
bind()到非0
这种情况比较简单, 不需要内核选择了, 内核只需要检查指定的端口是否冲突就可以, 判决条件与bind()
到0一致.
内核可以选择相同的端口吗 ?
完全可以!只要不使用 bind()
, 且它们连接目标地址端口不完全一样
做一个简单的实验: 修改 ip_local_port_range
的范围到一个特定值 60100 (如此一来, 内核一定选择此端口)
sysctl -w net.ipv4.ip_local_port_range="60100 60100"
在本地启动两个 tcp server (代码在文末), 分别监听 8200 和 8201 端口, 再启动两个 tcp client 分别连接.
root@switch-router:/home/root # ss -npt | grep 127.0.0.1
ESTAB 0 0 127.0.0.1:8200 127.0.0.1:60100 users:(("python3",pid=848846,fd=4))
ESTAB 0 0 127.0.0.1:60100 127.0.0.1:8201 users:(("python3",pid=849082,fd=3))
ESTAB 0 0 127.0.0.1:60100 127.0.0.1:8200 users:(("python3",pid=848980,fd=3))
ESTAB 0 0 127.0.0.1:8201 127.0.0.1:60100 users:(("python3",pid=848859,fd=4))
可以看到两个客户端都如预期选择了 60100 端口.
附录
服务端代码
import sys
import time
def main():
if len(sys.argv) != 3:
print("Usage: python tcp_client.py <server_ip> <port>")
return
server_ip = sys.argv[1]
port = int(sys.argv[2])
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((server_ip, port))
time.sleep(30)
if __name__ == "__main__":
main()
客户端代码
import socket
import sys
def main():
if len(sys.argv) != 2:
print("Usage: python tcp_server.py <port>")
return
port = int(sys.argv[1])
# Create a socket object
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Bind to the specified port
server_socket.bind(('localhost', port))
# Listen for connections
server_socket.listen(5)
print("Server started, listening on port %d..." % port)
while True:
# Accept client connections
client_socket, addr = server_socket.accept()
print('Received connection from %s' % str(addr))
# Handle client connection here, without closing the sub-connections
if __name__ == "__main__":
main()