我通过我的灵魂与肉体得知,我之堕落乃为必需,我必然经历贪欲,我必然去追逐财富,体验恶心,陷于绝望的深渊,并由此学会去抵御它们。学会热爱这个世界,不再以某种欲愿与臆想出来的世界、某种想象的完美去衡量世界。–黑塞《悉达多》
写在前面
博文内容涉及 Linux TCP 三次握手涉及的系统调用内核函数分析
以及如何使用 BPF/eBPF 工具观测三次握手
篇幅问题,涉及TCP链路跟踪的一些BPF/eBPF 工具编写思路放到下一篇
本篇博客是Linux 网络性能调优系列之一,理解不足小伙伴帮忙指正:),生活加油
我通过我的灵魂与肉体得知,我之堕落乃为必需,我必然经历贪欲,我必然去追逐财富,体验恶心,陷于绝望的深渊,并由此学会去抵御它们。学会热爱这个世界,不再以某种欲愿与臆想出来的世界、某种想象的完美去衡量世界。–黑塞《悉达多》
持续分享技术干货,感兴趣小伙伴可以关注下 ^_^
下面是 TCP 三次握手的简单流程图,博文要讲的内容围绕下面的图展示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 客户端 服务端 | | --- 服务端 listen() 系统调用做好准备工作 | | 半连接队列和全连接队列的初始化 |--- [1] SYN (seq=x) --------->| --- 客户端 connect() 系统调用发起连接请求 | | | |--- 半连接队列:新增条目 (SYN_RECV) | | |<-- [2] SYN+ACK (seq=y, ack=x+1) --| | | |--- [3] ACK (ack=y+1) --------->| | | | |--- 半连接队列:移除条目 | |--- 全连接队列:新增条目 (ESTABLISHED) | | | |--- 服务端 accept() 取出全连接队列中的连接,TCP 连接建立 | | |<-- 数据交互/断开连接 ---------->|
按照上面的流程图,我们来梳理一下TCP三次握手,分析涉及到那些系统调用,内核参数,实现TCP 观测调优的目的
服务端 TCP listen 系统调用 listen 是最先被服务端调用的接口,在网络编程中,服务端程序在接收请求前都需要先执行 listen 系统调用,listen 监听的意思,顾名思义,我一直以为是监听端口的意思,而且好多代码也是这样介绍的,但是实际并不是
下面是一个 Python socket 的 demo 片段
1 2 3 4 self.server_socket.bind((self.host, self.port)) self.server_socket.listen(self.backlog)
listen 最主要的工作就是申请和初始化接收队列,包括全连接队列和半连接队列。其中全连接队列是一个链表,而半连接队列由于需要快速地查找,所以使用的是一个哈希表。
全/半两个队列是三次握手中很重要的两个数据结构
全连接队列 :已经完成三次握手的连接
半连接队列 :正在进行三次握手的连接
有了它们服务端才能正常响应来自客户端的三次握手。所以服务端都需要调用listen才行,并不是说监听端口的意思,可以说服务端处于握手监听状态(TCP_LISTEN),做好了握手前的准备工作。
下面详细看一下具体的系统调用和涉及的内核参数
TCP listen 系统调用认知 listen 系统调用的入口在 Linux 内核源码的net/socket.c文件中,找到listen系统调用的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 SYSCALL_DEFINE2(listen, int , fd, int , backlog) { struct socket *sock = sockfd_lookup_light(fd, &err, &fput_needed); if (sock) { int somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn; if ((unsigned int )backlog > somaxconn) backlog = somaxconn; err = sock->ops->listen(sock, backlog); } }
这段代码揭示了 listen 系统调用的几个关键步骤:
查找 socket 对象:用户态的文件描述符只是一个整数,内核需要通过它查找对应的 socket 内核对象
参数检查:获取内核参数 net.core.somaxconn,传入的 backlog 超过该值时会被截断为 somaxconn
调用协议栈实现:通过sock->ops->listen进入具体协议栈的 listen 函数
对于AF_INET(ipv4)类型的 socket,TCP 协议栈的 listen 实现 sock->ops->listen 指向的是 inet_listen 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 int inet_listen (struct socket *sock, int backlog) { struct sock *sk = sock->sk; if (old_state != TCP_LISTEN) { err = inet_csk_listen_start(sk, backlog); sk->sk_max_ack_backlog = backlog; } }
可以看到服务端的全连接队列最大长度 就是执行 listen 函数时传入的
backlog(sk->sk_max_ack_backlog = backlog
它的取值为 backlog和net.core.somaxconn之间较小的那个值,上面的
if ((unsigned int)backlog > somaxconn)backlog = somaxconn
这里的 inet_csk_listen_start 函数是真正创建和初始化连接队列的地方
1 2 3 4 5 6 7 8 9 int inet_csk_listen_start (struct sock *sk, const int nr_table_entries) { struct inet_connection_sock *icsk = inet_csk(sk); int rc = reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries); }
这里的 icsk->icsk_accept_queue 是一个 request_sock_queue 类型的对象,它包含了 TCP 连接建立过程中的两个队列,接收队列在源码中是如何定义的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct request_sock_queue { struct request_sock *rskq_accept_head ; struct request_sock *rskq_accept_tail ; struct listen_sock *listen_opt ; }; struct listen_sock { u32 nr_table_entries; struct request_sock *syn_table [1]; };
从这些定义可以看出:
全连接队列 是一个简单的链表结构,通过rskq_accept_head和rskq_accept_tail维护
半连接队列 实际上是一个哈希表(syn_table),用于快速查找第一次握手中创建的连接请求
上面讲了全连接的长度计算,看一下半连接队列长度如何计算的,reqsk_queue_alloc 内计算了半连接队列长度,半连接队列长度的计算相对复杂,涉及多个参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int reqsk_queue_alloc (struct request_sock_queue *queue , unsigned int nr_table_entries) { nr_table_entries = min_t (u32, nr_table_entries, sysctl_max_syn_backlog); nr_table_entries = max_t (u32, nr_table_entries, 8 ); nr_table_entries = roundup_pow_of_two(nr_table_entries + 1 ); for (lopt->max_qlen_log = 3 ; (1 << lopt->max_qlen_log) < nr_table_entries; lopt->max_qlen_log++); }
半连接队列长度的计算公式:半连接队列长度 = roundup_pow_of_two(max(min(backlog, somaxconn, tcp_max_syn_backlog),8) + 1)其中:
backlog是 listen 函数传入的参数
somaxconn是内核参数net.core.somaxconn
tcp_max_syn_backlog是内核参数net.ipv4.tcp_max_syn_backlog
roundup_pow_of_two表示上取整到2的整数次幂
实际案例计算
案例 : + net.core.somaxconn = 128 + net.ipv4.tcp_max_syn_backlog = 8192 + backlog = 512 计算过程: 1. min(512, 128) = 128 2. min(128, 8192) = 128 3. max(128, 8) = 128 4. roundup_pow_of_two(128 + 1) = 256 5. 最终半连接队列长度为256。
总结一下 listen 系统调用的核心作用:
创建和初始化接收队列 :包括全连接队列和半连接队列
设置队列长度 :根据用户参数和系统限制确定队列的最大长度
将socket状态设置为LISTEN :表示服务器已准备好接受连接
listen 调用观测调优 如何优化? listen 系统调用主要用于创建和初始化接收队列,设置队列长度,以及更改服务端的 socket 状态为 LISTEN 状态,如果遇到服务端连接队列无法正常初始化问题,首先考虑内存问题,其次考虑队列长度问题,考虑以下参数:
1 全连接队列长度 :
调整应用程序中listen函数的backlog参数
修改内核参数net.core.somaxconn
2 半连接队列长度 :
同时调整backlog、net.core.somaxconn和net.ipv4.tcp_max_syn_backlog
注意半连接队列的实际长度是经过计算后的2的幂次方值
一般情况下是不会发生失败的,除非系统资源极度紧张,而且这里的内存是虚拟内存,不是物理内存,实际的内存不足可能发生在客户端的第一次第二次握手。
其次对于硬件资源充足的机器,比如可打开的文件句柄和内存是一个很大的值,可是适当的调整对应的队列长度阈值参数,以支持更多的连接数
如何观测? 这里我们先写个 测试Demo
下面的是Demo 是通过 nc 和 socat 进行 listen 系统调用的,通过这个Demo,我们来关注一下如何进行 listen 调用观测
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 [root@developer ~] COUNT=${1:-20} DELAY=${2:-0.1} BASE_PORT=50000 echo "Testing frequent listen() calls" echo "Count: $COUNT , Delay: ${DELAY} s" echo "Press Ctrl+C to stop" echo for ((i=1; i<=COUNT; i++)); do PORT=$((BASE_PORT + i)) if command -v socat &>/dev/null; then timeout 0.5 socat TCP-LISTEN:$PORT ,fork,reuseaddr SYSTEM:"echo listening" 2>/dev/null & PID=$! sleep 0.01 kill $PID 2>/dev/null echo "[$i ] Called listen on port $PORT " elif command -v nc &>/dev/null; then timeout 0.5 nc -l $PORT 2>/dev/null & PID=$! sleep 0.01 kill $PID 2>/dev/null echo "[$i ] Called listen on port $PORT " else echo "Error: Need socat or netcat installed" exit 1 fi sleep $DELAY done echo echo "Test completed!" [root@developer ~] [root@developer ~] Testing frequent listen() calls Count: 20, Delay: 0.1s Press Ctrl+C to stop [1] Called listen on port 50001 [2] Called listen on port 50002 [3] Called listen on port 50003 [4] Called listen on port 50004 ......... [19] Called listen on port 50019 [20] Called listen on port 50020 Test completed! [root@developer ~]
先找几个bpf BCC 工具集来看一下
funccount 用于统计内核态和用户态的函数调用次数,支持静态和动态跟踪
reqsk_queue_alloc 是上面初始化连接队列时调用的第一个函数,通过 funccount 来统计它的调用次数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ┌──[root@liruilongs.github.io]-[~] └─$./test_listen.sh Testing frequent listen() calls Count: 20, Delay: 0.1s Press Ctrl+C to stop ..... [19] Called listen on port 50019 [20] Called listen on port 50020 Test completed! ┌──[root@liruilongs.github.io]-[~] └─$funccount reqsk_queue_alloc Tracing 1 functions for "b'reqsk_queue_alloc'" ... Hit Ctrl-C to end. ^C FUNC COUNT reqsk_queue_alloc 20 Detaching... ┌──[root@liruilongs.github.io]-[~] └─$
通过 listen 关键字匹配相关的调用函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [root@developer tools] Tracing 25 functions for "b'*listen*'" ... Hit Ctrl-C to end. ^C FUNC COUNT b'__sys_listen' 20 b'inet_csk_listen_start' 20 b'security_socket_listen' 20 b'__arm64_sys_listen' 20 b'inet_listen' 20 b'bpf_lsm_socket_listen' 20 b'inet_csk_listen_stop' 20 b'netlink_has_listeners' 27 Detaching... [root@developer tools]
可以分时间段统计,这里我们可以观测到服务端 listen 调用的频率,默认一个listen 调用是一个 socke 建立的话,我们可以大概观测服务端socket 建立频率。这里的 __sys_listen 是在 listen 系统的调用的入口层,在协议层上层,所以他包含了 TCP 和 UDP 的统计
1 2 3 4 5 6 7 8 9 10 [root@developer tools] Tracing 1 functions for "b'__sys_listen'" ... Hit Ctrl-C to end. FUNC COUNT FUNC COUNT b'__sys_listen' 17 FUNC COUNT b'__sys_listen' 3
其他相关的内核函数统计 ,inet_csk_listen_start 是 reqsk_queue_alloc 的上层函数,下面的跟踪是在另一台存在其他web服务的机器上的跟踪,所以可以看到跟踪的系统调用要大于上面的测试Demo中的 20 次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ┌──[root@liruilongs.github.io]-[~] └─$/usr/share/bcc/tools/funccount '*inet_csk_listen_start*' Tracing 1 functions for "b'*inet_csk_listen_start*'" ... Hit Ctrl-C to end. ^C FUNC COUNT inet_csk_listen_start 36 Detaching... ┌──[root@liruilongs.github.io]-[~] └─$ ┌──[root@liruilongs.github.io]-[~] └─$/usr/share/bcc/tools/funccount 'reqsk_queue_alloc' Tracing 1 functions for "b'reqsk_queue_alloc'" ... Hit Ctrl-C to end. ^C FUNC COUNT reqsk_queue_alloc 30 Detaching... ┌──[root@liruilongs.github.io]-[~] └─$
对于最上面的内核动态函数跟踪点,可以通过下面的方式查看
列出当前内核中所有可被 kprobe/kretprobe(内核函数探针)挂钩的内核函数名,这里用 listen 过滤了一下,可以看到好多熟悉的内核函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 ┌──[root@liruilongs.github.io]-[~] └─$grep -i listen /sys/kernel/debug/tracing/available_filter_functions add_del_listener bpf_lsm_socket_listen security_socket_listen selinux_socket_listen kernel_listen __sys_listen __x64_sys_listen __ia32_sys_listen sock_no_listen bpf_get_listener_sock reuseport_stop_listen_sock netlink_update_listeners netlink_has_listeners __inet_lookup_listener inet_ehash_nolisten inet_csk_listen_start inet_csk_listen_stop listening_get_first listening_get_next __inet_listen_sk inet_listen unix_listen inet6_lookup_listener ...............
对于静态跟踪点,可以通过下面的方式获取内核静态跟踪点,以及对应的跟踪点对应的入参和返回值
sys_enter_listen 是调用内核函数时的事件
sys_exit_listen 是函数返回时的事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ┌──[root@liruilongs.github.io]-[~] └─$sudo perf list | grep -i tracepoint | grep -i listen syscalls:sys_enter_listen [Tracepoint event] syscalls:sys_exit_listen [Tracepoint event] ┌──[root@liruilongs.github.io]-[~] └─$cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_listen/format name: sys_enter_listen ID: 1312 format: field:unsigned short common_type; offset:0; size:2; signed:0; field:unsigned char common_flags; offset:2; size:1; signed:0; field:unsigned char common_preempt_count; offset:3; size:1; signed:0; field:int common_pid; offset:4; size:4; signed:1; field:unsigned char common_preempt_lazy_count; offset:8; size:1; signed:0; field:int __syscall_nr; offset:12; size:4; signed:1; field:int fd; offset:16; size:8; signed:0; field:int backlog; offset:24; size:8; signed:0; print fmt: "fd: 0x%08lx, backlog: 0x%08lx" , ((unsigned long)(REC->fd)), ((unsigned long)(REC->backlog))┌──[root@liruilongs.github.io]-[~] └─$
下面是静态跟踪点 t:syscalls:sys_enter_listen 的 funccount 使用方法
1 2 3 4 5 6 7 8 9 ┌──[root@liruilongs.github.io]-[~] └─$funccount t:syscalls:sys_enter_listen Tracing 1 functions for "b't:syscalls:sys_enter_listen'" ... Hit Ctrl-C to end. ^C FUNC COUNT syscalls:sys_enter_listen 26 Detaching... ┌──[root@liruilongs.github.io]-[~] └─$
也可以尝试其他的 BCC 工具进行跟踪,下面是通过 trace 来跟踪 __sys_listen 内核函数,以及对应的请求参数打印
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [root@developer ~] PID TID COMM FUNC - 41241 41241 socat __sys_listen fd=5 backlog=5 41245 41245 socat __sys_listen fd=5 backlog=5 41249 41249 socat __sys_listen fd=5 backlog=5 41253 41253 socat __sys_listen fd=5 backlog=5 41257 41257 socat __sys_listen fd=5 backlog=5 41261 41261 socat __sys_listen fd=5 backlog=5 41410 41410 socat __sys_listen fd=5 backlog=5 41549 41549 socat __sys_listen fd=5 backlog=5 41597 41597 socat __sys_listen fd=5 backlog=5 41601 41601 socat __sys_listen fd=5 backlog=5 41605 41605 socat __sys_listen fd=5 backlog=5
fd=5 是进程内的文件描述符编号,backlog=5 是调用时传递的队列大小参数
通过 argdist 工具统计了 __sys_listen 系统调用的 backlog 参数分布
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 [root@developer tools] [16:38:15] [16:38:16] [16:38:17] [16:38:18] [16:38:19] [16:38:20] [16:38:21] [16:38:22] backlog : count distribution 0 -> 1 : 0 | | 2 -> 3 : 0 | | 4 -> 7 : 7 |****************************************| [16:38:23] backlog : count distribution 0 -> 1 : 0 | | 2 -> 3 : 0 | | 4 -> 7 : 9 |****************************************| [16:38:24] backlog : count distribution 0 -> 1 : 0 | | 2 -> 3 : 0 | | 4 -> 7 : 4 |****************************************| [16:38:25] [16:38:26]
所有调用的 backlog 值都落在 4~7 区间(实际对应之前看到的 backlog=5)
除了上面讲到的BCC工具,也可以使用 ebpf bpftrace 工具进行简单观察
通过 eBPF 追踪 listen() 内核静态跟踪点的进入事件,实时输出调用进程的名称、PID 和 backlog 参数
1 2 3 4 5 6 7 8 9 10 ┌──[root@liruilongs.github.io]-[~] └─$bpftrace -e 'tracepoint:syscalls:sys_enter_listen { printf("%s (PID:%d) fd=%d, backlog=%d\n", comm, pid, args->fd, args->backlog); }' Attaching 1 probe... nc (PID:35479) fd=3, backlog=10 nc (PID:35479) fd=4, backlog=10 nc (PID:35483) fd=3, backlog=10 nc (PID:35483) fd=4, backlog=10
同事跟踪请求参数和返回值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ┌──[root@liruilongs.github.io]-[~] └─$bpftrace -e ' tracepoint:syscalls:sys_enter_listen { printf(">>> %s (PID:%d) listen(fd=%d, backlog=%d)\n", comm, pid, args->fd, args->backlog); } tracepoint:syscalls:sys_exit_listen { printf("<<< %s (PID:%d) listen returned: %d\n", comm, pid, args->ret); } ' Attaching 2 probes... >>> nc (PID:35573) listen(fd=3, backlog=10) <<< nc (PID:35573) listen returned: 0 >>> nc (PID:35573) listen(fd=4, backlog=10)<<< nc (PID:35573) listen returned: 0 >>> nc (PID:35577) listen(fd=3, backlog=10)<<< nc (PID:35577) listen returned: 0 >>> nc (PID:35577) listen(fd=4, backlog=10)<<< nc (PID:35577) listen returned: 0 ...............
统计进程执行次数
1 2 3 4 5 6 7 [root@developer ~] Attaching 3 probes... @[socat]: 20 @[socat]: 20 [root@developer ~]
也可以使用 bpftrace 进行内核态动态跟踪,跟踪 __sys_listen 内核函数调用
1 2 3 4 5 6 7 8 9 10 11 12 13 ┌──[root@liruilongs.github.io]-[~] └─$bpftrace -e 'kprobe:__sys_listen { printf("%s backlog=%d\n", comm, arg1); }' Attaching 1 probe... nc backlog=10 nc backlog=10 nc backlog=10 nc backlog=10 nc backlog=10 nc backlog=10 nc backlog=10 nc backlog=10
排查异常的 backlog 的进程,可以在 bpftrace 脚本中编写逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ┌──[root@liruilongs.github.io]-[~] └─$# sudo bpftrace -e 'tracepoint:syscalls:sys_enter_listen { if (args->backlog != 511 && args->backlog != 128) { printf("异常 backlog: %s (PID:%d) 使用了 backlog=%d\n", comm, pid, args->backlog); } }' Attaching 1 probe... 异常 backlog: nc (PID:35840) 使用了 backlog=10 异常 backlog: nc (PID:35840) 使用了 backlog=10 异常 backlog: nc (PID:35844) 使用了 backlog=10 异常 backlog: nc (PID:35844) 使用了 backlog=10 异常 backlog: nc (PID:35852) 使用了 backlog=10 异常 backlog: nc (PID:35852) 使用了 backlog=10 异常 backlog: nc (PID:35856) 使用了 backlog=10 异常 backlog: nc (PID:35856) 使用了 backlog=10
调用返回值的统计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [root@developer tools] Attaching 2 probes... 返回值统计: 返回值统计: @[0]: 12 返回值统计: @[0]: 8 返回值统计: 返回值统计:
bpftrace 请求参数的统计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [root@developer tools] Attaching 2 probes... backlog分布: backlog分布: @backlog[5]: 1 backlog分布: @backlog[5]: 18 backlog分布: @backlog[5]: 1
除了 bpf/ebpf 也可以使用 perf工具进行观测
1 2 3 4 5 6 7 8 [root@developer ~] 0.000 socat/6623 syscalls:sys_enter_listen(fd: 5<socket:[32346]>, backlog: 5) 111.289 socat/6627 syscalls:sys_enter_listen(fd: 5<socket:[34011]>, backlog: 5) 222.509 socat/6631 syscalls:sys_enter_listen(fd: 5<socket:[34014]>, backlog: 5) 333.694 socat/6635 syscalls:sys_enter_listen(fd: 5<socket:[34017]>, backlog: 5) 444.908 socat/6639 syscalls:sys_enter_listen(fd: 5<socket:[34020]>, backlog: 5) ...................
在服务端准备好之后,客户端就可以进行发起第一次握手了
第一次握手 客户端发送 SYN(TCP connect 系统调用)
客户端通过调用 connect 系统调用 来发起连接。尽管在应用层这一操作看似简单,但在内核层面却涉及复杂的状态转换、资源分配和网络协议交互。
客户端在执行 connect 函数时,会把本地 socket 状态设置为 TCP_SYN_SENT,选择一个可用端口,随后发出 SYN 握手请求并启动重传定时器。
下面是涉及到的内核参数,后面我们会详细说明
1 2 3 4 5 6 7 ┌──(root㉿liruilongs)-[~] └─ net.ipv4.ip_local_port_range = 32768 60999 net.ipv4.ip_local_reserved_ports = ┌──(root㉿liruilongs)-[~] └─
其中调用涉及多个层次的函数调用,最终由 tcp_v4_connect 完成核心工作, 客户端端口选择采用了基于 随机起点的循环查找策略,端口复用基于连接四元组唯一性,而非简单的端口独占,单客户端可以建立远超65535的连接数,只要目标服务器不同,下面为 connect 系统调用的调用链分析
TCP connect 系统调用认知 当应用程序调用 connect 函数时,会触发内核中的 SYSCALL_DEFINE3(connect) 系统调用
1 2 3 4 5 6 7 8 9 10 11 SYSCALL_DEFINE3(connect, int , fd, struct sockaddr __user *, uservaddr, int , addrlen) { struct socket *sock ; sock = sockfd_lookup_light(fd, &err, &fput_needed); err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen, sock->file->f_flags); }
这段代码首先根据用户传入的文件描述符(fd)查找对应的socket内核对象,和上面的 listen 系统调用一样,然后调用该socket对象的connect操作函数。
这里会发生 socket 状态转换,对于 AF_INET Ipv4 类型的socket,sock->ops->connect 指向的是 inet_stream_connect 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 int inet_stream_connect (struct socket *sock, struct sockaddr *uaddr, int addr_len, int flags) { 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 状态时,会调用 sk->sk_prot->connect 函数,并将socket状态设置为SS_CONNECTING。对于TCP socket,这个函数指针指向的是tcp_v4_connect,tcp_v4_connect函数是TCP连接建立的核心实现:
1 2 3 4 5 6 7 8 9 10 11 12 int tcp_v4_connect (struct sock *sk, struct sockaddr *uaddr, int addr_len) { tcp_set_state(sk, TCP_SYN_SENT); err = inet_hash_connect(&tcp_death_row, sk); err = tcp_connect(sk); }
这个函数完成了三个关键步骤:
将 socket状态设置为TCP_SYN_SENT
动态选择一个可用的端口
构建并发送SYN报文
这里客户端端口选择是通过 inet_hash_connect 函数实现的:
1 2 3 4 5 6 7 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:检查连接四元组是否冲突的函数
在 __inet_hash_connect 函数中,首先获取系统配置的本地端口范围:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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; } } }
这里的 low 和 high 来自内核参数 net.ipv4.ip_local_port_range,默认值为32768-60999,提供了 28231 个可用端口。
1 2 3 4 5 6 7 ┌──(root㉿liruilongs)-[~] └─ net.ipv4.ip_local_port_range = 32768 60999 net.ipv4.ip_local_reserved_ports = ┌──(root㉿liruilongs)-[~] └─
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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) { if (!check_established(death_row, sk, port, &tw)) goto ok; } } tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, ...); goto ok; } return -EADDRNOTAVAIL; }
这段代码展示了端口选择的核心逻辑:
跳过保留端口(在net.ipv4.ip_local_reserved_ports中配置)
检查端口是否已被使用
如果已被使用,通过 check_established 检查是否可以复用
这里会进行连接唯一性检查机制,四元组唯一性检查
当端口已被使用时,__inet_check_established 函数会检查连接四元组是否冲突:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 static int __inet_check_established(struct inet_timewait_death_row *death_row, struct sock *sk, u16 lport, struct inet_timewait_sock **twp) { 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; } return 0 ; not_unique: return -EADDRNOTAVAIL; }
INET_MATCH宏定义 INET_MATCH宏用于比较两个连接的四元组是否相同:
1 2 3 4 5 6 7 #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)))
这个宏比较了:
端口对(源端口和目标端口)
目标IP地址
源IP地址
绑定的网络设备
网络命名空间
只有当所有这些都匹配时,才认为两个连接的四元组相同。
下面我们看一个 Demo,测试同一个服务端,连接两个IP相同的客户端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 import socketimport threadingimport timedef client_connect (target_host, target_port, client_port, conn_id ): """客户端连接函数:使用指定的客户端端口连接目标服务器""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try : sock.bind(('127.0.0.1' , client_port)) print (f"连接 {conn_id} 绑定端口 {client_port} 成功" ) sock.connect((target_host, target_port)) print (f"连接 {conn_id} 成功:{sock.getsockname()} -> {sock.getpeername()} " ) sock.sendall(f"这是连接 {conn_id} " .encode()) time.sleep(10 ) except Exception as e: print (f"连接 {conn_id} 出错:{e} " ) finally : sock.close() print (f"连接 {conn_id} 关闭" ) def simple_server (port ): """简单的服务器:接收连接并打印客户端信息""" server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_sock.bind(('127.0.0.1' , port)) server_sock.listen(5 ) print (f"服务器启动,监听端口 {port} " ) try : while True : client_sock, addr = server_sock.accept() print (f"服务器收到新连接:{addr} " ) data = client_sock.recv(1024 ) print (f"收到数据:{data.decode()} " ) client_sock.close() except KeyboardInterrupt: print ("服务器关闭" ) finally : server_sock.close() if __name__ == "__main__" : client_port = 12345 server1_port = 8000 server2_port = 9000 threading.Thread(target=simple_server, args=(server1_port,), daemon=True ).start() threading.Thread(target=simple_server, args=(server2_port,), daemon=True ).start() time.sleep(1 ) threading.Thread( target=client_connect, args=('127.0.0.1' , server1_port, client_port, 1 ) ).start() time.sleep(2 ) threading.Thread( target=client_connect, args=('127.0.0.1' , server2_port, client_port, 2 ) ).start() time.sleep(15 )
1 2 3 4 5 6 7 8 9 10 服务器启动,监听端口 8000服务器启动,监听端口 9000 连接 1 绑定端口 12345 成功 连接 1 成功:('127.0.0.1' , 12345) -> ('127.0.0.1' , 8000) 服务器收到新连接:('127.0.0.1' , 12345) 收到数据:这是连接 1 连接 2 出错:[WinError 10048] 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 连接 2 关闭 连接 1 关闭 PS C:\Users\liruilong\Documents\GitHub\test
按照上面的方法直接写,是不允许多进程端口复用的,需要配置一个参数
1 2 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 )
下面是多端口复用的日志
1 2 3 4 5 6 7 8 9 10 11 12 13 14 服务器启动,监听端口 8000服务器启动,监听端口 9000 连接 1 绑定端口 12345 成功 连接 1 成功:('127.0.0.1' , 12345) -> ('127.0.0.1' , 8000) 服务器收到新连接:('127.0.0.1' , 12345) 收到数据:这是连接 1 连接 2 绑定端口 12345 成功 连接 2 成功:('127.0.0.1' , 12345) -> ('127.0.0.1' , 9000) 服务器收到新连接:('127.0.0.1' , 12345) 收到数据:这是连接 2 连接 1 关闭 连接 2 关闭 PS C:\Users\liruilong\Documents\GitHub\test
端口复用的条件
从上述代码可以看出,对于客户端来说,端口复用的关键条件是:只要新连接的四元组与现有连接的四元组不完全相同,就可以复用同一个端口 。这意味着以下两个连接可以同时存在:
这证明了一个重要结论:客户端最大能建立的连接数远不止65535,只要目标服务器足够多,单机发出百万条连接是可行的 。如果在考虑命名空间和设备会更多。
SYN包的构建与发送
在成功选择端口后,tcp_connect 函数负责构建和发送SYN包:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int tcp_connect (struct sock *sk) { buff = alloc_skb_fclone(MAX_TCP_HEADER + 15 , sk->sk_allocation); tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN); tcp_connect_queue_skb(sk, buff); 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连接初始化时,重传定时器被设置为 TCP_TIMEOUT_INIT:
1 2 3 4 5 6 void tcp_connect_init (struct sock *sk) { inet_csk(sk)->icsk_rto = TCP_TIMEOUT_INIT; }
在Linux >= 3.10版本中,TCP_TIMEOUT_INIT定义为1秒:
1 2 3 4 5 6 7 8 ┌──[root@liruilongs.github.io]-[~] └─$cat /usr/src/kernels/$(uname -r)/include/net/tcp.h | grep "TCP_TIMEOUT_INIT" timeout = TCP_TIMEOUT_INIT; ┌──[root@liruilongs.github.io]-[~] └─$ // file: include/net/tcp.h
而在较早的版本(如v2.6.30)中,这个值是3秒:
1 2 #define TCP_TIMEOUT_INIT ((unsigned)(3*HZ))
客户端在调用 connect 时,会把本地 socket 状态设置为 TCP_SYN_SENT,选择一个可用端口,随后发出 SYN 握手请求并启动重传定时器(TCP_TIMEOUT_INIT)。
这里如果没有收到服务端的回包,客户端会在重传定时器超时后按指数退避重发 SYN,直到达到 net.ipv4.tcp_syn_retries 上限。
观测性能影响与优化建议 如何优化? 这里的性能分析是对客户端而言的
主要是端口选择的性能影响,每次端口选择,内核都需要遍历所有可用端口,当可用端口接近耗尽时,内核需要遍历更多的端口才能找到可用端口,这里会涉及自旋式排他锁等待和哈希查找,这会导致connect系统调用的CPU开销增加。这种情况在高并发连接场景下尤为明显。一般的优化手段
系统角度:
增加可用端口范围 :适当调整 net.ipv4.ip_local_port_range 参数,扩大可用端口范围
合理设置保留端口 :仅将必要的端口添加到 net.ipv4.ip_local_reserved_ports , 填了会少一次四元组判定。
监控端口使用情况 :在高并发场景下,同步监控端口使用情况,及时发现潜在问题
应急的处理办法 : 开启 tcp_tw_reuse = 1, 依赖 tcp_timestamps = 1,不要关闭这个参数,TCP 连接关闭后(四次挥手完成),连接不会立即销毁,而是进入 TIME_WAIT 状态,Linux 下默认持续 60 秒(由 tcp_fin_timeout 控制),TIME_WAIT 状态 的连接会占用本地端口,端口一旦被占,就无法分配给新的连接, tcp_tw_reuse = 1 在保证协议安全的前提下,提前复用 TIME_WAIT 端口,缓解端口不足
代码角度:
避免绑定固定端口 : 客户端程序应避免调用 bind 绑定固定端口,让内核自动选择
尽量复用连接 :使用长连接来削减频繁握手处理。
其次是客户端 SYN 包的重试次数,当客户端进行第一次握手发送 SYN 包的时候,会启动重传定时器,如果超时会重新发包,这里的重试次数是一个内核参数控制,包括三次交互的重试都是通过内核控制
tcp_syn_retries:客户端的 SYN 重传计数器,控制客户端发送 SYN 包(第一次握手)后,没收到服务端 SYN+ACK(第二次握手)时,重试发送 SYN 包的次数。比如服务端半连接队列满 tcp_syncookies=0,丢弃了客户端的 SYN 包,客户端没收到 SYN+ACK,就会按这个参数重传。
超时计算:采用指数退避策略(每次重传的超时时间翻倍),默认 5 次的总超时≈1+2+4+8+16=31 秒(不同内核略有差异)。如果参数设为 3,客户端只会重传 3 次 SYN,总超时≈1+2+4=7 秒,7 秒后就判定 “连接超时”;设为 0 则不重传,发一次 SYN 没回应就直接失败。
tcp_syn_retries 的优化一般是 “场景驱动”:
低延迟场景 :减小参数值,优先保证快速失败;
弱网场景 :保留默认值或适当增大,优先保证连接成功率;
1 2 3 4 5 6 7 8 客户端 服务端 | | |--- [1] SYN(第一次握手)---->| # 客户端发SYN,触发 `tcp_syn_retries` 计时 | |--- 发SYN+ACK(第二次握手),触发 `tcp_synack_retries` 计时 |<-----------------------------| | | |--- [3] ACK(第三次握手)---->| # 客户端发ACK,触发 `tcp_ack_retries` 计时 | |--- 完成握手,清空两个重传计数器
如何观测? 同样我们先需要一个 Demo,发起客户端的第一次握手
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 [root@developer ~] 使用 nc 测试 connect... 连接 127.0.0.1:80 ... 失败 连接 127.0.0.1:443 ... 失败 连接 127.0.0.1:8080 ... 失败 连接 127.0.0.1:8888 ... 失败 连接 127.0.0.1:9999 ... 失败 [root@developer ~] echo "使用 nc 测试 connect..." PORTS="80 443 8080 8888 9999" SERVER="127.0.0.1" TIMEOUT=2 for port in $PORTS ; do echo -n "连接 $SERVER :$port ... " timeout $TIMEOUT nc -zv $SERVER $port 2>&1 | \ grep -q "succeeded" && echo "成功" || echo "失败" sleep 0.5 done [root@developer ~]
上面我们简单介绍了静态/动态跟踪,以及对应的观测的 bpf/ebpf 工具,这里我们直接看一下
使用 bcc 工具 funccount 统计 tcp_connect 内核函数的调用次数,这个函数主要负责构建和发送 SYN 包,我们可以间接统计发送的 SYN 次数
1 2 3 4 5 6 7 ┌──[root@liruilongs.github.io]-[~] └─$/usr/share/bcc/tools/funccount 'tcp_connect' Tracing 1 functions for "b'tcp_connect'" ... Hit Ctrl-C to end. ^C FUNC COUNT tcp_connect 5 Detaching...
__inet_check_established 会检查连接四元组是否冲突,当前的跟踪次数为0,说明在我们的测试 demo 中,客户端端口选择没有走到这里,即选择的端口都没有被使用
1 2 3 4 5 6 7 8 ┌──[root@liruilongs.github.io]-[~] └─$funccount __inet_check_established Tracing 1 functions for "b'__inet_check_established'" ... Hit Ctrl-C to end. ^C FUNC COUNT Detaching... ┌──[root@liruilongs.github.io]-[~] └─$
简单过滤一下 connect 涉及到的内核实现函数,以及不同架构下的实现
1 2 3 4 5 6 ┌──[root@liruilongs.github.io]-[~] └─$grep -i sys_connect /sys/kernel/debug/tracing/available_filter_functions ...... __sys_connect __x64_sys_connect __ia32_sys_connect
内核静态跟踪点的过滤,前两个是 connect 系统调用开始的跟踪和结束的跟踪
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ┌──[root@liruilongs.github.io]-[~] └─$sudo perf list | grep -i tracepoint | grep -i connect syscalls:sys_enter_connect [Tracepoint event] syscalls:sys_exit_connect [Tracepoint event] ....................... ┌──[root@liruilongs.github.io]-[~] └─$ ┌──[root@liruilongs.github.io]-[~] └─$grep -i conn /sys/kernel/debug/tracing/available_events ................................ syscalls:sys_exit_connect syscalls:sys_enter_connect ┌──[root@liruilongs.github.io]-[~] └─$
对应跟踪点的入参和返回值说明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ┌──[root@liruilongs.github.io]-[~] └─$cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_connect/format name: sys_enter_connect ID: 1306 format: field:unsigned short common_type; offset:0; size:2; signed:0; field:unsigned char common_flags; offset:2; size:1; signed:0; field:unsigned char common_preempt_count; offset:3; size:1; signed:0; field:int common_pid; offset:4; size:4; signed:1; ..... (REC->fd)), ((unsigned long)(REC->uservaddr)), ((unsigned long)(REC->addrlen)) ┌──[root@liruilongs.github.io]-[~] └─$cat /sys/kernel/debug/tracing/events/syscalls/sys_exit_connect/format name: sys_exit_connect ID: 1305 format: field:unsigned short common_type; offset:0; size:2; signed:0; field:unsigned char common_flags; offset:2; size:1; signed:0; ....... ┌──[root@liruilongs.github.io]-[~] └─$
支持内核态动态跟踪的一些函数
tcp_connect_init TCP 连接参数初始化(如序列号、窗口大小)时候调用的接口,这里会设置重传定时器
1 2 3 ┌──[root@liruilongs.github.io]-[~] └─$grep -i tcp_connect_init /sys/kernel/debug/tracing/available_filter_functions tcp_connect_init
tcp_connect_queue_skb 是数据包添加到发送队列 sk_write_queue 时的调用,之后就是发生SYN 数据包
1 2 3 4 5 6 ┌──[root@liruilongs.github.io]-[~] └─$grep -i tcp_connect /sys/kernel/debug/tracing/available_filter_functions tcp_connect_queue_skb tcp_connect_init tcp_connect mptcp_connect
__inet_hash_connect 是选择端口时的函数调用
1 2 3 ┌──[root@liruilongs.github.io]-[~] └─$grep -i __inet_hash_connect /sys/kernel/debug/tracing/available_filter_functions __inet_hash_connect
inet_bind_bucket_create 用于在端口选择之后进行端口绑定,跟踪这个函数可以获取当前绑定的端口数据
1 2 3 ┌──[root@liruilongs.github.io]-[~] └─$grep -i inet_bind_bucket_create /sys/kernel/debug/tracing/available_filter_functions inet_bind_bucket_create
tcp_v4_connect 是 TCP 协议 IPV4的入口函数
1 2 3 4 ┌──[root@liruilongs.github.io]-[~] └─$grep -i v4_connect /sys/kernel/debug/tracing/available_filter_functions tcp_v4_connect
inet_stream_connect 是 connect 系统调用之后的第一个内核函数,处理连接状态(如 SYN 发送、超时)。
1 2 3 4 ┌──[root@liruilongs.github.io]-[~] └─$grep -i inet_stream_connect /sys/kernel/debug/tracing/available_filter_functions __inet_stream_connect inet_stream_connect
tcpconnect 是一个内置的bcc 工具,用于跟踪 connect 的系统调用
1 2 3 4 5 6 7 8 [root@developer tools] Tracing connect ... Hit Ctrl-C to end PID COMM IP SADDR DADDR DPORT 31168 nc 4 127.0.0.1 127.0.0.1 80 31172 nc 4 127.0.0.1 127.0.0.1 443 31176 nc 4 127.0.0.1 127.0.0.1 8080 31180 nc 4 127.0.0.1 127.0.0.1 8888 31184 nc 4 127.0.0.1 127.0.0.1 9999
使用方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 examples = "" "examples: ./tcpconnect # trace all TCP connect()s ./tcpconnect -t # include timestamps ./tcpconnect -d # include DNS queries associated with connects ./tcpconnect -p 181 # only trace PID 181 ./tcpconnect -P 80 # only trace port 80 ./tcpconnect -P 80,81 # only trace port 80 and 81 ./tcpconnect -4 # only trace IPv4 family ./tcpconnect -6 # only trace IPv6 family ./tcpconnect -U # include UID ./tcpconnect -u 1000 # only trace UID 1000 ./tcpconnect -c # count connects per src ip and dest ip/port ./tcpconnect -L # include LPORT while printing outputs ./tcpconnect --cgroupmap mappath # only trace cgroups in this BPF map ./tcpconnect --mntnsmap mappath # only trace mount namespaces in the map
简单看一下源码,通过 kprobe 和 kretprobe 在内核函数上挂载了一些钩子采集数据,kprobe 获取 “连接发起时的信息”,kretprobe 获取 “连接执行后的结果”;
1 2 3 4 5 6 7 8 9 10 11 12 ┌──[root@liruilongs.github.io]-[/usr/share/bcc/tools] └─$cat tcpconnect | grep -E "kprobe|kretprobe" b.attach_kprobe(event="tcp_v4_connect" , fn_name="trace_connect_entry" ) b.attach_kprobe(event="tcp_v6_connect" , fn_name="trace_connect_entry" ) b.attach_kretprobe(event="tcp_v4_connect" , fn_name="trace_connect_v4_return" ) b.attach_kretprobe(event="tcp_v6_connect" , fn_name="trace_connect_v6_return" ) b.attach_kprobe(event="udp_recvmsg" , fn_name="trace_udp_recvmsg" ) b.attach_kretprobe(event="udp_recvmsg" , fn_name="trace_udp_ret_recvmsg" ) b.attach_kprobe(event="udpv6_queue_rcv_one_skb" , fn_name="trace_udpv6_recvmsg" ) ┌──[root@liruilongs.github.io]-[/usr/share/bcc/tools] └─$
funccount 工具统计调用次数
1 2 3 4 5 6 7 8 [root@developer tools] Tracing 2 functions for "b'*sys_connect'" ... Hit Ctrl-C to end. ^[c^C FUNC COUNT b'__arm64_sys_connect' 6 b'__sys_connect' 6 Detaching... [root@developer tools]
1 2 3 4 5 6 7 [root@developer tools] Tracing 1 functions for "b'__sys_connect'" ... Hit Ctrl-C to end. ^C FUNC COUNT b'__sys_connect' 5 Detaching... [root@developer tools]
通过 bpftrace 添加 kprobe 探针,跟踪所有连接,__sys_connect 是 “所有连接请求的入口”,tcp_v4_connect 是 “TCP IPv4 连接的具体实现”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 [root@developer tools] Attaching 2 probes... @[nc]: 4 @[nc]: 1 @[ps]: 9 @[whoami]: 15 @[id]: 30 ^C [root@developer tools]
跟踪端口选择函数 __inet_hash_connect ,统计调用情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ┌──[root@liruilongs.github.io]-[~] └─$/usr/share/bcc/tools/funccount '__inet_hash_connect' Tracing 1 functions for "b'__inet_hash_connect'" ... Hit Ctrl-C to end. ^C FUNC COUNT __inet_hash_connect 5 Detaching... ┌──[root@liruilongs.github.io]-[~] └─$grep __inet_hash_connect /sys/kernel/debug/tracing/available_filter_functions __inet_hash_connect ┌──[root@liruilongs.github.io]-[~] └─$sudo cat /proc/kallsyms | grep __inet_hash_connect ffffffffa18b6350 T __pfx___inet_hash_connect ffffffffa18b6360 T __inet_hash_connect
可以使用 bpftrace 嵌入脚本,添加一些自定义的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ┌──[root@liruilongs.github.io]-[~] └─$bpftrace -e ' kprobe:__inet_hash_connect { printf("[%s] %s (PID:%d) 调用 __inet_hash_connect\n", strftime("%H:%M:%S", nsecs), comm, pid); } ' Attaching 1 probe... [22:52:01] nc (PID:36537) 调用 __inet_hash_connect [22:52:01] nc (PID:36541) 调用 __inet_hash_connect [22:52:02] nc (PID:36545) 调用 __inet_hash_connect [22:52:02] nc (PID:36550) 调用 __inet_hash_connect [22:52:03] nc (PID:36554) 调用 __inet_hash_connect ^C
通过 tracepoint 捕获 connect 系统调用的入参和返回值,先获取对应系统调用的 tracepoint
1 2 3 4 [root@developer tools] tracepoint:syscalls:sys_enter_connect tracepoint:syscalls:sys_exit_connect [root@developer tools]
通过 bpftrace 进行挂载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ┌──[root@liruilongs.github.io]-[~] └─$sudo bpftrace -e ' tracepoint:syscalls:sys_enter_connect { printf("→ %s (PID:%d) fd=%d, addrlen=%d\n", comm, pid, args->fd, args->addrlen); } tracepoint:syscalls:sys_exit_connect { printf("← %s (PID:%d) ret=%d\n", comm, pid, args->ret); } ' Attaching 2 probes... → nc (PID:36396) fd=3, addrlen=16 ← nc (PID:36396) ret=-115 → nc (PID:36400) fd=3, addrlen=16 ← nc (PID:36400) ret=-115 → nc (PID:36404) fd=3, addrlen=16 ← nc (PID:36404) ret=-115 → nc (PID:36409) fd=3, addrlen=16 ← nc (PID:36409) ret=-115 → nc (PID:36413) fd=3, addrlen=16 ← nc (PID:36413) ret=-115
第二次握手 服务端响应SYN、发送SYN-ACK
服务端处理 SYN报文也是TCP 第二次握手,服务端在监听状态(TCP_LISTEN)下收到客户端的 SYN 报文后,内核处理 SYN 包的核心逻辑集中在 tcp_v4_do_rcv 和 tcp_v4_conn_request 两个函数中,前者负责 定位监听 Socket,后者负责 实际响应 SYN+ACK 并管理连接队列
1 2 3 4 5 6 7 8 客户端 服务端(LISTEN状态) | | |--- [1] SYN (seq=x) --------->| | |--- 1. tcp_v4_do_rcv:定位监听Socket | |--- 2. tcp_rcv_state_process:触发SYN处理分支 | |--- 3. tcp_v4_conn_request:核心处理(队列检查→资源分配→发SYN+ACK) |<-- [2] SYN+ACK (seq=y, ack=x+1) --| | |--- 4. 半连接队列新增条目,启动SYN+ACK重传定时器
tcp_v4_do_rcv 函数区分处于 Listen 状态的 Socket,即在服务端调用 listen 之后,第一次发生客户端握手
1 2 3 4 5 6 7 8 9 10 11 12 13 int tcp_v4_do_rcv (struct sock *sk, struct sk_buff *skb) { if (sk->sk_state == TCP_LISTEN) { struct sock *nsk = tcp_v4_hnd_req(sk, skb); } if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) { rsk = sk; goto reset; } }
如果是 TCP_LISTEN 状态(服务端监听端口的 Socket),先调用 tcp_v4_hnd_req 查找半连接队列(首次处理 SYN 时队列空,直接返回);
无论是否是 Listen 状态,最终都会调用 tcp_rcv_state_process 进入 TCP 状态机 处理。
状态机:tcp_rcv_state_process 触发 SYN 处理分支,TCP 状态机函数 tcp_rcv_state_process 会根据 Socket 的不同状态执行不同逻辑,对于服务端监听 Socket(TCP_LISTEN状态),核心逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int tcp_rcv_state_process (struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) { switch (sk->sk_state) { case TCP_LISTEN: if (th->syn) { if (icsk->icsk_af_ops->conn_request(sk, skb)) { return 1 ; } } break ; } }
检测到 SYN标志位后,调用 tcp_v4_conn_request 这是服务端响应 SYN 的核心函数,主要做了下面一些工作
检查半连接和全连接的队列是否合适
分配 request_sock 内核对象
构造 SYN+ACK 数据包,发送SYN+ACK响应包
分配的 request_sock 内核对象加入半连接队列,并启动定时器(超时重传SYN+ACK)
队列状态检查 tcp_v4_conn_request 前两步会进行队列状态检查
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int tcp_v4_conn_request (struct sock *sk, struct sk_buff *skb) { if (inet_csk_reqsk_queue_is_full(sk) && !isn) { want_cookie = tcp_syn_flood_action(sk, skb, "TCP" ); if (!want_cookie) goto drop; } if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1 ) { NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } ............. }
半连接队列检查(SYN Flood 防护机制) 半连接队列检查 :通过inet_csk_reqsk_queue_is_full(sk)判断半连接队列(存储未完成三次握手的连接请求)是否已满。若队列满且未开启tcp_syncookies(SYN Flood 防护机制),则直接丢弃报文(goto drop)。
SYN Flood防护机制 : 当半连接队列满时,tcp_syn_flood_action 函数会根据 tcp_syncookies 内核参数决定是否启用SYN Cookie:
不启用 SYN Cookie(tcp_syncookies=0):服务器直接丢弃新的SYN报文,此时正常请求也会被拒绝,无法区分攻击流量和合法流量,防护效果差。
启用,服务器不再为SYN请求创建request_sock(不占用半连接队列资源),避免存储request_sock,从而抵御SYN Flood攻击。这个时候会通过SYN Cookie 算法生成一个特殊的ISN(初始序列号)回复给客户端,生成基于客户端IP、端口及时间戳的加密Cookie作为 ISN ,将其嵌入到 SYN-ACK报文中发送给客户端。
若客户端是合法的,会回复ACK报文,其中的确认号应为ISN+1。服务器收到ACK后,重新计算 Cookie 并验证:若与ACK中的确认号匹配,说明是合法连接,此时才为其创建request_sock并完成三次握手。
SYN Cookie 无需存储半连接信息(request_sock),半连接队列被攻击占满时也能处理合法请求,从根本上避免队列溢出,Cookie 基于客户端信息和服务器密钥生成,攻击者无法伪造合法的ACK报文(无法生成正确的 Cookie),只能被服务器识别并拒绝。
全连接队列检查 全连接队列检查 :即使半连接队列未满,仍需通过sk_acceptq_is_full(sk)函数检查全连接队列(存储已完成三次握手的连接)是否已满。
因为需提前预判全连接队列的承载能力,若全连接队列已饱和,后续完成三次握手的连接无法被存储,此时接收新的SYN请求已无意义,反而会加剧资源消耗。
若全连接队列满且young_ack(半连接队列中未重传过SYN-ACK的连接计数)大于1,同样丢弃报文,避免资源耗尽。即刚进入半连接队列、首次等待客户端ACK的连接)。若young_ack > 1,说明半连接队列中已有较多 “待完成握手” 的连接,这些连接后续会尝试进入全连接队列。
连接请求对象request_sock分配与SYN-ACK报文构造 上面的队列检查校验通过之后
会分配request_sock内核对象,调用 inet_reqsk_alloc,用于存储半连接状态信息。
调用tcp_make_synack构造SYN-ACK报文,包含确认号(客户端SYN序号+1)和服务端初始序号(ISN)。
通过ip_build_and_send_pkt发送SYN-ACK报文,并将request_sock添加到半连接队列,同时启动定时器(TCP_TIMEOUT_INIT,默认 75 秒),若超时未收到客户端ACK,则重传SYN-ACK包(默认重传 5 次),仍未收到则删除该半连接。SYN+ACK 重传次数通过内核参数 net.ipv4.tcp_synack_retries 控制,每次重传的超时时间是 “指数退避”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int tcp_v4_conn_request (struct sock *sk, struct sk_buff *skb) { ......................... req = inet_reqsk_alloc(&tcp_request_sock_ops); skb_synack = tcp_make_synack(sk, dst, req, fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL ); err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr, ireq->srmt_addr, ireq->sopt); inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT); }
性能观测优化建议 如何优化 第二次握手的调优主要针对服务端。在 TCP 第二次握手阶段,服务端在如下两种情况下可能会丢包:
半连接队列满,且 tcp_syncookies=0
全连接队列满,且有未完成的半连接请求
发生在上面讲的队列检查的阶段,半连接队列溢出场景
1 2 3 4 5 6 7 8 9 10 客户端 服务端 | | |--- [1] SYN (seq=x) --------->| | |--- 检查半连接队列:已满;检查tcp_syncookies=0(关闭) | |--- 直接丢弃SYN包,不发任何响应(无SYN+ACK,也无RST) | | |<-- 超时重传 SYN ------------->| | |--- 服务端仍丢弃,直到客户端重试次数用尽 | | |--- 连接超时失败 ------------->|
对应半连接队列的溢出,优化方式可以从下面三个方面入手:
调整半连接队列的大小,前面我们有讲到半连接队列的内核参数 tcp_max_syn_backlog。
上面讲的SYN Flood防护机制,开启内核参数 tcp_syncookies,延迟请求对象的创建。
加快结束异常连接,减少无效半连接堆积,调整超时重传次数的阈值 tcp_syn_retries,这里可以配合上面客户端调优的进行,调整对应的客户端超时重传的次数
下面为全连接队列的溢出情况
1 2 3 4 5 6 7 8 9 10 客户端 服务端 | | |--- [1] SYN (seq=x) --------->| | |--- 检查全连接队列:已满,且有未完成的半连接(young_ack>1) | |--- 直接丢弃ACK包,无任何响应 | | |<-- 超时重传 SYN ------------->| | |--- 服务端仍丢弃,直到客户端重试次数用尽 | | |--- 连接超时失败 ------------->|
全连接队列的溢出场景,实际的全连接队列在三次握手阶段同样会触发。和上面的半连接队列一样,全连接队列溢出的原因要么是队列太小,要么是消费小于生产,因此优化手段同样是 扩容队列上限、加快消费速度、减少无效堆积。
调整全连接队列的核心容量参数 net.core.somaxconn
减少无效连接堆积,降低队列压力:
tcp_fin_timeout(TIME_WAIT 状态的连接超时时间(默认 60s)), 调整小,释放结束连接
tcp_tw_reuse(允许复用 TIME_WAIT 连接(仅用于出站连接)) 减少端口耗尽,降低新连接创建压力
tcp_keepalive_*( TCP 协议的三个核心保活(Keepalive)参数) ,检测空闲连接的参数
tcp_keepalive_time:首次探测的 “空闲等待时间”
tcp_keepalive_intvl:探测包的 “重试间隔时间”
tcp_keepalive_probes:探测失败的 “最大重试次数”
内核默认全连接队列满时会静默丢弃 ACK 包,导致客户端持续重传 SYN,加重服务端压力。可调整 tcp_abort_on_overflow 参数让内核更「友好」处理溢出,默认值0(队列满时丢弃 ACK,客户端超时重传);设为1(队列满时主动给客户端发RST包,客户端快速失败,避免无意义的 SYN 重传)
关于 TIME_WAIT 的解释我们可能需要看一下四次挥手
步骤
客户端状态变化
服务端状态变化
核心动作
第1次挥手
ESTABLISHED → FIN_WAIT_1
保持 ESTABLISHED
客户端发送 FIN 包,请求关闭连接(表示无数据要发)
第2次挥手
FIN_WAIT_1 → FIN_WAIT_2
ESTABLISHED → CLOSE_WAIT
服务端收到 FIN,回复 ACK 包;客户端进入「等待 FIN 确认」,服务端进入「关闭等待」(可继续发剩余数据)
第3次挥手
保持 FIN_WAIT_2
CLOSE_WAIT → LAST_ACK
服务端数据发送完毕,发送 FIN 包给客户端
第4次挥手
FIN_WAIT_2 → TIME_WAIT → CLOSED
LAST_ACK → CLOSED
客户端收到 FIN,回复 ACK 包(进入 TIME_WAIT,等待 2MSL 确保服务端收到 ACK);服务端收到 ACK 后直接关闭(CLOSED);客户端等待 2MSL 后也关闭
状态流转 :
1 2 客户端(主动断开):ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED 服务端(被动断开):ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED
下面为对应内核参数的默认值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ┌──[root@liruilongs.github.io]-[~] └─$sysctl -a | grep tcp_fin_timeout net.ipv4.tcp_fin_timeout = 60 ┌──[root@liruilongs.github.io]-[~] └─$sysctl -a | grep tcp_tw_reuse net.ipv4.tcp_tw_reuse = 2 ┌──[root@liruilongs.github.io]-[~] └─$sysctl -a | grep tcp_keepalive_ net.ipv4.tcp_keepalive_intvl = 75 net.ipv4.tcp_keepalive_probes = 9 net.ipv4.tcp_keepalive_time = 7200 ┌──[root@liruilongs.github.io]-[~] └─$sysctl -a | grep tcp_abort_on_overflow net.ipv4.tcp_abort_on_overflow = 0
如何观测 第二次握手观测我们按照之前的简单分为 :函数可观测性、正常路径函数计数、状态流转、队列溢出指标、调用栈。
确认第二次握手关键函数可被挂载
1 2 3 4 5 6 7 [root@liruilongs.github.io ~] inet_csk_reqsk_queue_hash_add tcp_conn_request tcp_make_synack tcp_syn_flood_action tcp_v4_conn_request tcp_v4_send_synack
用于确认后续 kprobe/BCC 能挂到目标函数。上面 6 个函数都存在,说明当前内核支持对第二次握手核心路径做函数级观测。
正常路径函数计数(listen+accept + 150 次连接)
1 2 3 4 5 6 7 8 9 10 [root@liruilongs.github.io ~] Tracing 6 functions for "b'tcp_v4_conn_request|tcp_conn_request|tcp_make_synack|tcp_v4_send_synack|inet_csk_reqsk_queue_hash_add|tcp_syn_flood_action'" ... Hit Ctrl-C to end. FUNC COUNT tcp_v4_send_synack 105 tcp_conn_request 106 tcp_v4_conn_request 107 tcp_make_synack 107 inet_csk_reqsk_queue_hash_add 108 Detaching...
下面为观测函数的基本解释:
tcp_v4_conn_request / tcp_conn_request ,说明服务端确实在处理 SYN,这两个函数是响应 SYN 的核心函数。
tcp_make_synack / tcp_v4_send_synack ,说明 SYN-ACK 确实发出,用于构造和发生第二次握手的 syn+ack 数据包。
inet_csk_reqsk_queue_hash_add 用于启动重传定时器,保存创建的socket 对象到半连接队列
使用 bcc 工具观测 TCP 状态流转观测(tcpstates)
1 2 3 4 5 6 7 [root@liruilongs.github.io ~] SKADDR C-PID C-COMM LADDR LPORT RADDR RPORT OLDSTATE -> NEWSTATE MS ffff8abb868a7500 20985 nc 127.0.0.1 0 127.0.0.1 10142 CLOSE -> SYN_SENT 0.000 ffff8abb868a7500 20985 nc 127.0.0.1 35918 127.0.0.1 10142 SYN_SENT -> ESTABLISHED 0.063 ffff8abb868a6180 20985 nc 127.0.0.1 10142 0.0.0.0 0 LISTEN -> SYN_RECV 0.000 ffff8abb868a6180 20985 nc 127.0.0.1 10142 127.0.0.1 35918 SYN_RECV -> ESTABLISHED 0.002 ...
MS 列可以用来粗看握手链路时延
服务端侧(LISTEN → SYN_RECV → ESTABLISHED)
1 2 ffff8abb868a6180 20985 nc 127.0.0.1 10142 0.0.0.0 0 LISTEN → SYN_RECV 0.000 ffff8abb868a6180 20985 nc 127.0.0.1 10142 127.0.0.1 35918 SYN_RECV → ESTABLISHED 0.002
nc 进程(PID 20985)在本地 10142 端口监听(LISTEN):
收到 127.0.0.1:35918 的 SYN 包,状态变为 SYN_RECV,是第二次握手发生的直接证据。
完成三次握手,状态变为 ESTABLISHED(已建立连接),耗时 0.002ms。
客户端侧(CLOSE → SYN_SENT → ESTABLISHED):
1 2 ffff8abb868a7500 20985 nc 127.0.0.1 0 127.0.0.1 10142 CLOSE → SYN_SENT 0.000 ffff8abb868a7500 20985 nc 127.0.0.1 35918 127.0.0.1 10142 SYN_SENT → ESTABLISHED 0.063
客户端 nc 发起连接
初始状态 CLOSE → 发送 SYN 包后变为 SYN_SENT;
收到服务端的 SYN+ACK 后,状态变为 ESTABLISHED,耗时 0.063ms。
步骤
客户端状态变化
服务端状态变化
核心动作
初始
CLOSED(关闭)
CLOSED → LISTEN(监听)
服务端调用 listen() 绑定端口,进入监听状态
第1次握手
CLOSED → SYN_SENT
保持 LISTEN
客户端发送 SYN 包,请求建立连接
第2次握手
保持 SYN_SENT
LISTEN → SYN_RECV
服务端收到 SYN,回复 SYN+ACK 包
第3次握手
SYN_SENT → ESTABLISHED
SYN_RECV → ESTABLISHED
客户端收到 SYN+ACK,回复 ACK 包;服务端收到 ACK 后,双方进入「已建立」状态
队列溢出观测(backlog=2,并发 120)
正常状态,为了测试,我看可能需要调整一些内核参数
1 2 3 [root@liruilongs.github.io ~] State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 2 127.0.0.1:10113 0.0.0.0:*
溢出的状态
Recv-Q=3, Send-Q=2 表示监听队列已超上限并积压。
1 2 3 4 [root@liruilongs.github.io ~] [root@liruilongs.github.io ~] State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 3 2 127.0.0.1:10113 0.0.0.0:*
异常的指标数据
ListenOverflows/ListenDrops 增量明确,说明服务端在第二次握手阶段出现丢弃。
1 2 3 4 5 6 [root@liruilongs.github.io ~] 847 times the listen queue of a socket overflowed 847 SYNs to LISTEN sockets dropped [root@liruilongs.github.io ~] CLIENT_OK=3 CLIENT_FAIL=117
利用 bcc 工具 stackcount 实现调用栈定位
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 [root@liruilongs.github.io ~] Tracing 1 functions for "tcp_v4_conn_request" ... Hit Ctrl-C to end. tcp_v4_conn_request tcp_rcv_state_process tcp_v4_do_rcv tcp_v4_rcv ip_protocol_deliver_rcu ip_local_deliver_finish __netif_receive_skb_one_core process_backlog __napi_poll net_rx_action __do_softirq do_softirq __local_bh_enable_ip ip_finish_output2 __ip_queue_xmit __tcp_transmit_skb tcp_connect tcp_v4_connect __inet_stream_connect inet_stream_connect __sys_connect __x64_sys_connect do_syscall_64 entry_SYSCALL_64_after_hwframe [unknown] [unknown] 1 ... Detaching...
输出从 connect 到 tcp_v4_conn_request 的完整调用链
第三次握手 客户端处理 SYN-ACK、发送 ACK,服务端处理 ACK(accept 系统调用)
客户端在除 ESTABLISHED 和 TIME_WAIT 外,其他状态的 TCP 包处理均走此函数,客户端在 TCP_SYN_SENT 状态会精准进入 tcp_rcv_synsent_state_process 分支。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int tcp_rcv_state_process (struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) { switch (sk->sk_state) { case TCP_LISTEN: break ; case TCP_SYN_SENT: queued = tcp_rcv_synsent_state_process(sk, skb, th, len); return 0 ; default : break ; } }
tcp_rcv_synsent_state_process 是客户端响应 SYN-ACK 的核心函数,负责 ACK 确认、定时器清理、连接完成等关键动作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static int tcp_rcv_synsent_state_process (struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) { tcp_ack(sk, skb, FLAG_SLOWPATH); tcp_finish_connect(sk, skb); if (sk->sk_write_pending || icsk->icsk_accept_queue.rskq_defer_accept || icsk->icsk_ack.pingpong) { } else { tcp_clean_rtx_queue(sk, 0 , 0 ); tcp_send_ack(sk); } return 0 ; }
状态转换与连接确认
tcp_ack:校验 SYN-ACK 包的 ACK 序号是否匹配客户端发送的 SYN 包序号,确保包的合法性;
执行tcp_finish_connect将客户端socket状态转换为TCP_ESTABLISHED,完成连接建立。
分支判断:仅当有未发送数据、开启延迟确认等场景时才延迟发 ACK,普通连接会立即调用tcp_send_ack。
tcp_clean_rtx_queue:删除客户端发送 SYN 包时设置的重传定时器(避免重复发送 SYN),并清理发送队列中已确认的报文;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void tcp_finish_connect (struct sock *sk, struct sk_buff *skb) { tcp_set_state(sk, TCP_ESTABLISHED); tcp_init_congestion_control(sk); if (sock_flag(sk, SOCK_KEEPOPEN)) { inet_csk_reset_keepalive_timer(sk, keepalive_time_when(tp)); } }
初始化拥塞控制算法(tcp_init_congestion_control),并根据sock_flag(sk, SOCK_KEEPOPEN)开启保活计时器,用于检测连接活性。sock_flag(sk, SOCK_KEEPOPEN) 是内核态的标志位,应用层需通过 setsockopt 开启该选项
这里简单介绍一下拥塞控制算法和保活机制,
拥塞控制算法:TCP 避免网络拥塞的核心机制(如 cubic/reno),通过控制发送速率、重传策略等,平衡传输效率和网络稳定性;
SO_KEEPALIVE(保活):TCP 连接的「心跳检测」机制 —— 若连接长时间无数据传输,内核会主动发送保活探测包,检测对端是否在线(避免「僵尸连接」)。
tcp_init_congestion_control 会根据内核参数初始化当前连接的拥塞控制算法,核心参数:
内核参数
路径
含义
取值示例
tcp_congestion_control
/proc/sys/net/ipv4/tcp_congestion_control
系统默认的 TCP 拥塞控制算法
cubic(默认)、reno、bbr、westwood
tcp_available_congestion_control
/proc/sys/net/ipv4/tcp_available_congestion_control
系统支持的所有拥塞控制算法(只读)
reno cubic bbr(显示当前内核编译支持的算法)
tcp_allowed_congestion_control
/proc/sys/net/ipv4/tcp_allowed_congestion_control
允许用户切换的拥塞控制算法(管理员可配置)
cubic bbr(限制用户只能在这些算法间切换)
1 2 3 4 5 6 7 8 9 cat /proc/sys/net/ipv4/tcp_congestion_control echo "bbr" > /proc/sys/net/ipv4/tcp_congestion_controlecho "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.confsysctl -p
保活机制相关的内核参数我们上面已经介绍了,不多说,这里看一下流程
连接建立后,若开启 SO_KEEPALIVE,内核会在连接空闲 tcp_keepalive_time 秒后,发送第一个保活探测包;
若对端无响应,每隔 tcp_keepalive_intvl 秒再发一个探测包;
连续发送 tcp_keepalive_probes 个探测包仍无响应,内核判定连接失效,触发断开逻辑。
发送第三次握手ACK tcp_send_ack 负责构造 ACK 包并发送,完成三次握手的最后一步:
调用tcp_send_ack构造ACK报文(确认号为服务端SYN序号+1),通过tcp_transmit_skb发送。
清除连接建立阶段的重传定时器(tcp_clean_rtx_queue),避免不必要的SYN重传。
1 2 3 4 5 6 7 8 9 10 11 12 void tcp_send_ack (struct sock *sk) { struct sk_buff *buff = alloc_skb(MAX_TCP_HEADER, sk_gfp_atomic(sk, GFP_ATOMIC)); tcp_build_skb(sk, buff, 0 , 0 , 0 ); tcp_transmit_skb(sk, buff, 0 , sk_gfp_atomic(sk, GFP_ATOMIC)); }
第三次握手的 ACK 包无数据载荷,仅包含 TCP 头部(ACK 标志位、确认序号、窗口大小等),服务端收到后会将自身 socket 状态切换为TCP_ESTABLISHED,至此双向连接完全建立,连接建立时会初始化拥塞控制、保活定时器等,为后续数据传输做准备;
状态机是核心:客户端处理 SYN-ACK 的核心逻辑由 TCP 状态机驱动,TCP_SYN_SENT到TCP_ESTABLISHED的切换是连接建立的标志;
定时器管理:客户端发送 SYN 后会设置重传定时器,收到 SYN-ACK 后通过 tcp_clean_rtx_queue 清除,避免重复发 SYN;
服务端处理ACK报文 服务端收到客户端的第三次握手ACK后,通过tcp_v4_do_rcv进入处理流程,需要完成以下关键动作:
从半连接队列(syn_queue) 中找到该连接的半连接对象(request_sock);
创建新的子 socket(用于与客户端通信);
清理半连接队列,将新 socket 加入全连接队列(accept_queue);
将子 socket 状态切换为TCP_ESTABLISHED,完成连接建立;
应用层调用 accept() 时,从全连接队列中取出已建立的 socket 返回给用户进程。
服务端所有 TCP 包的处理入口是tcp_v4_do_rcv,对于处于TCP_LISTEN状态的监听 socket,会进入连接请求处理分支:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int tcp_v4_do_rcv (struct sock *sk, struct sk_buff *skb) { if (sk->sk_state == TCP_LISTEN) { struct sock *nsk = tcp_v4_hnd_req(sk, skb); if (nsk != sk) { if (tcp_child_process(sk, nsk, skb)) { return 0 ; } } } return tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len); }
TCP_LISTEN状态的 socket 收到 ACK 包后,通过tcp_v4_hnd_req查找半连接队列中的连接对象,为后续创建子 socket 做准备。
半连接队列查找与验证 调用tcp_v4_hnd_req在半连接队列中搜索匹配的request_sock(基于客户端IP、端口等信息)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 static struct sock *tcp_v4_hnd_req (struct sock *sk, struct sk_buff *skb) { struct request_sock *req ; struct request_sock *prev = NULL ; const struct tcphdr *th = tcp_hdr(skb); const struct iphdr *iph = ip_hdr(skb); req = inet_csk_search_req(sk, &prev, th->source, iph->saddr, iph->daddr); if (req) { return tcp_check_req(sk, skb, req, prev, false ); } return sk; }
找到匹配项后,进入tcp_check_req函数处理,验证ACK的合法性(确认号是否为服务端SYN序号+1)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct sock *tcp_check_req (struct sock *sk, struct sk_buff *skb, struct request_sock *req, struct request_sock **prev, bool fastopen) { struct sock *child ; child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL ); if (child) { inet_csk_reqsk_queue_unlink(sk, req, prev); inet_csk_reqsk_queue_removed(sk, req); inet_csk_reqsk_queue_add(sk, req, child); } return child; }
子socket创建与队列迁移 调用tcp_v4_syn_recv_sock创建子socket(newsk),用于处理已建立的连接。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct sock *tcp_v4_syn_recv_sock (struct sock *sk, struct sk_buff *skb, struct request_sock *req, struct dst_entry *dst) { struct sock *newsk ; if (sk_acceptq_is_full(sk)) { goto exit_overflow; } newsk = tcp_create_openreq_child(sk, req, skb); return newsk; exit_overflow: NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPACCEPTQOVF); return NULL ; }
检查全连接队列是否已满(sk_acceptq_is_full),若满则丢弃连接并更新溢出计数器;否则将request_sock从半连接队列移除(inet_csk_reqsk_queue_unlink),并将子socket添加到全连接队列(inet_csk_reqsk_queue_add)。
k_acceptq_is_full(sk) 是全连接队列溢出的核心判断条件,若队列已满,服务端会丢弃该连接,客户端表现为 “连接建立但服务端无响应”,这也是生产环境中 accept 队列溢出 问题的根本原因。
半连接队列(syn_queue) 用于存放 已收到 SYN 但未完成三次握手 的连接,第三次握手完成后需清理:
1 2 3 4 5 6 7 8 static inline void inet_csk_reqsk_queue_unlink (struct sock *sk, struct request_sock *req, struct request_sock **prev) { reqsk_queue_unlink(&inet_csk(sk)->icsk_accept_queue, req, prev); }
全连接队列(accept_queue) 存放 已完成三次握手但未被应用层 accept() 取走 的连接,核心是链表尾插:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static inline void inet_csk_reqsk_queue_add (struct sock *sk, struct request_sock *req, struct sock *child) { reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue, req, sk, child); } static inline void reqsk_queue_add (...) { req->sk = child; sk_acceptq_added(parent); if (queue ->rskq_accept_head == NULL ) { queue ->rskq_accept_head = req; } else { queue ->rskq_accept_tail->dl_next = req; } queue ->rskq_accept_tail = req; req->dl_next = NULL ; }
子socket状态从TCP_SYN_RECV转换为TCP_ESTABLISHED,标志连接正式建立。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int tcp_rcv_state_process (struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) { switch (sk->sk_state) { case TCP_SYN_RECV: tcp_set_state(sk, TCP_ESTABLISHED); break ; default : break ; } return 0 ; }
应用程序通过accept()系统调用从全连接队列中获取该子socket开始数据传输。应用层调用accept()时,内核实际执行inet_csk_accept,核心是从全连接队列中取出头部元素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 struct sock *inet_csk_accept (struct sock *sk, int flags, int *err) { struct request_sock_queue *queue = &inet_csk(sk)->icsk_accept_queue; struct request_sock *req ; struct sock *newsk ; req = reqsk_queue_remove(queue ); if (!req) { *err = -EAGAIN; return NULL ; } newsk = req->sk; return newsk; } static inline struct request_sock *reqsk_queue_remove (struct request_sock_queue *queue ) { struct request_sock *req = queue ->rskq_accept_head; if (!req) return NULL ; queue ->rskq_accept_head = req->dl_next; if (queue ->rskq_accept_head == NULL ) { queue ->rskq_accept_tail = NULL ; } return req; }
若全连接队列为空,阻塞模式下accept()会等待直到有新连接;非阻塞模式返回-EAGAIN;,reqsk_queue_remove 是简单的链表头删操作,这也是 accept() “先进先出” 的底层原因。
性能观测与优化建议 服务端通过 半连接队列(syn_queue)+ 全连接队列(accept_queue) 管理连接,第三次握手完成后完成 半连接→全连接 的迁移;
服务端在第三次握手阶段仍可能出现连接失败,典型场景是全连接队列已经接近或达到上限。此时客户端往往会误判为“连接已建立”,而服务端并未成功把连接迁移到可被 accept() 消费的状态。服务端会基于半连接对象重发 SYN-ACK,重试次数受 net.ipv4.tcp_synack_retries 控制。
如何优化 先保证应用层 accept() 消费速度,避免“生产大于消费”导致全连接队列长期堆积。合理设置监听队列参数:应用 listen(backlog)、net.core.somaxconn 和 net.ipv4.tcp_max_syn_backlog 要联动调整。accept() 的本质不是 建立连接,而是 从全连接队列中取出已建立的连接,队列为空时阻塞 / 返回 EAGAIN;
根据链路质量评估 net.ipv4.tcp_synack_retries,内网低丢包环境可适度降低以加快失败连接回收。公网服务建议保持 net.ipv4.tcp_syncookies=1,并结合 ACL/限速策略降低异常流量影响。
Recv-Q 反映当前监听 socket 的队列占用水位,Send-Q 对应队列上限。若 Recv-Q 持续逼近 Send-Q,即使尚未出现明显丢包,也应提前评估扩容 somaxconn/backlog 和优化 accept() 消费能力。对公网业务,建议把 队列水位 + 重传趋势 + 握手时延分布 作为同一组指标联动分析。
如何观测 第三次握手的函数可观测性,确认第三次握手关键函数是否可以挂载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [root@liruilongs.github.io ~] __sys_accept4 __sys_accept4_file __tcp_send_ack __tcp_send_ack.part.0 bpf_tcp_send_ack inet_csk_accept inet_csk_reqsk_queue_add mptcp_finish_connect mptcp_send_ack tcp_check_req tcp_finish_connect tcp_rcv_synsent_state_process tcp_send_ack tcp_v4_syn_recv_sock
这些函数上面我们都介绍过,这里不多讲,覆盖了“客户端 ACK 处理 + 服务端 request_sock 校验 + accept 消费”整条路径,函数存在即可继续做 funccount/stackcount/tracepoint。
funccount 进行关键函数计数
1 2 3 4 5 6 7 8 9 10 11 12 [root@liruilongs.github.io ~] Tracing 8 functions for "b'tcp_rcv_synsent_state_process|tcp_finish_connect|tcp_send_ack|tcp_check_req|tcp_v4_syn_recv_sock|inet_csk_reqsk_queue_add|inet_csk_accept|__sys_accept4'" ... Hit Ctrl-C to end. FUNC COUNT inet_csk_accept 78 tcp_v4_syn_recv_sock 79 tcp_finish_connect 79 tcp_check_req 80 __sys_accept4 80 tcp_rcv_synsent_state_process 81 tcp_send_ack 168 Detaching...
tcp_check_req/tcp_v4_syn_recv_sock/inet_csk_accept ,说明第三次握手后半连接向全连接迁移在发生。tcp_send_ack 计数通常高于连接数,包含握手 ACK 之外的 ACK 行为。
系统调用 accept4 静态 tracepoint 计数
1 2 3 4 5 6 [root@liruilongs.github.io ~] Tracing 1 functions for "b't:syscalls:sys_enter_accept4'" ... Hit Ctrl-C to end. FUNC COUNT syscalls:sys_enter_accept4 108 Detaching...
系统调用层面的入点计数,直接反映应用层 accept4 消费频率。可以用它和 ss -lnt 的 Recv-Q 结合判断“消费是否跟得上”。
accept4 参数分布(flags)跟踪
1 2 3 4 5 [root@liruilongs.github.io ~] Attaching 2 probes... @flags[524288]: 35 @flags[524288]: 60 @flags[524288]: 25
524288 即 0x80000,对应 SOCK_CLOEXEC,分布稳定说明当前服务进程主要走 accept4(..., SOCK_CLOEXEC) 路径。
accept 调用栈(定位消费路径)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [root@liruilongs.github.io ~] Tracing 1 functions for "inet_csk_accept" ... Hit Ctrl-C to end. inet_csk_accept inet_accept do_accept __sys_accept4_file __sys_accept4 __x64_sys_accept4 do_syscall_64 entry_SYSCALL_64_after_hwframe [unknown] 34 Detaching...
这条栈清晰表明应用层 accept4 经由 inet_accept 进入 inet_csk_accept,对排查“为什么 accept 消费慢”很实用:后续可以按这条栈继续做热点分析。
syscall 实时流(perf)
1 2 3 4 5 6 [root@liruilongs.github.io ~] 0.000 python3/21054 syscalls:sys_enter_accept4(fd: 0</dev/null>, upeer_sockaddr: 0x798491cc00007ffc, upeer_addrlen: 0x8000000007ffc) 58.261 python3/21054 syscalls:sys_enter_accept4(fd: 0</dev/null>, upeer_sockaddr: 0x798491cc00007ffc, upeer_addrlen: 0x8000000007ffc) 123.781 python3/21054 syscalls:sys_enter_accept4(fd: 0</dev/null>, upeer_sockaddr: 0x798491cc00007ffc, upeer_addrlen: 0x8000000007ffc) 310.942 python3/21054 syscalls:sys_enter_accept4(fd: 0</dev/null>, upeer_sockaddr: 0x798491cc00007ffc, upeer_addrlen: 0x8000000007ffc) ...
perf trace 能从 syscall 角度看到 accept4 的实时进入事件,适合做“行为级”观测,和 funccount 的区别是:funccount 给总量,perf trace 给时序。
博文部分内容参考 © 文中涉及参考链接内容版权归原作者所有,如有侵权请告知 :)
https://www.brendangregg.com/ebpf.html
https://github.com/iovisor/bcc/blob/master/TOOLS.md
https://github.com/iovisor/bcc/blob/master/tools/tcpstates.py
https://elixir.bootlin.com/linux/latest/source/net/ipv4/tcp_input.c
https://elixir.bootlin.com/linux/latest/source/net/ipv4/tcp_ipv4.c
© 2018-至今 liruilonger@gmail.com , 保持署名-非商用-相同方式共享(CC BY-NC-SA 4.0)