Linux 网络调优之TCP握手认知与观测

我通过我的灵魂与肉体得知,我之堕落乃为必需,我必然经历贪欲,我必然去追逐财富,体验恶心,陷于绝望的深渊,并由此学会去抵御它们。学会热爱这个世界,不再以某种欲愿与臆想出来的世界、某种想象的完美去衡量世界。–黑塞《悉达多》

写在前面


  • 博文内容涉及 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))
# 开始监听(进入 LISTEN 状态)
self.server_socket.listen(self.backlog)

listen 最主要的工作就是申请和初始化接收队列,包括全连接队列和半连接队列。其中全连接队列是一个链表,而半连接队列由于需要快速地查找,所以使用的是一个哈希表

全/半两个队列是三次握手中很重要的两个数据结构

  1. 全连接队列:已经完成三次握手的连接
  2. 半连接队列:正在进行三次握手的连接

有了它们服务端才能正常响应来自客户端的三次握手。所以服务端都需要调用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
// file: net/socket.c
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
// 根据fd查找socket内核对象
struct socket *sock = sockfd_lookup_light(fd, &err, &fput_needed);
if (sock) {
// 获取内核参数net.core.somaxconn
int somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
if ((unsigned int)backlog > somaxconn)
backlog = somaxconn;
// 调用协议栈注册的listen函数
err = sock->ops->listen(sock, backlog);
}
// ...
}

这段代码揭示了 listen 系统调用的几个关键步骤:

  1. 查找 socket 对象:用户态的文件描述符只是一个整数,内核需要通过它查找对应的 socket 内核对象
  2. 参数检查:获取内核参数 net.core.somaxconn,传入的 backlog 超过该值时会被截断为 somaxconn
  3. 调用协议栈实现:通过sock->ops->listen进入具体协议栈的 listen 函数

对于AF_INET(ipv4)类型的 socketTCP 协议栈的 listen 实现 sock->ops->listen 指向的是 inet_listen 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file: net/ipv4/af_inet.c
int inet_listen(struct socket *sock, int backlog)
{
struct sock *sk = sock->sk;

// 如果还不是LISTEN状态
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

它的取值为 backlognet.core.somaxconn之间较小的那个值,上面的

if ((unsigned int)backlog > somaxconn)backlog = somaxconn

这里的 inet_csk_listen_start 函数是真正创建和初始化连接队列的地方

1
2
3
4
5
6
7
8
9
// file: net/ipv4/inet_connection_sock.c
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
// file: include/net/request_sock.h
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];
// ...
};

从这些定义可以看出:

  1. 全连接队列是一个简单的链表结构,通过rskq_accept_headrskq_accept_tail维护
  2. 半连接队列实际上是一个哈希表(syn_table),用于快速查找第一次握手中创建的连接请求

上面讲了全连接的长度计算,看一下半连接队列长度如何计算的,reqsk_queue_alloc 内计算了半连接队列长度,半连接队列长度的计算相对复杂,涉及多个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: net/core/request_sock.c
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);

// 为了效率,记录2的N次幂
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)其中:

  • backloglisten 函数传入的参数
  • 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 系统调用的核心作用:

  1. 创建和初始化接收队列:包括全连接队列和半连接队列
  2. 设置队列长度:根据用户参数和系统限制确定队列的最大长度
  3. 将socket状态设置为LISTEN:表示服务器已准备好接受连接

listen 调用观测调优

如何优化?

listen 系统调用主要用于创建和初始化接收队列,设置队列长度,以及更改服务端的 socket 状态为 LISTEN 状态,如果遇到服务端连接队列无法正常初始化问题,首先考虑内存问题,其次考虑队列长度问题,考虑以下参数:

1 全连接队列长度

  • 调整应用程序中listen函数的backlog参数
  • 修改内核参数net.core.somaxconn

2 半连接队列长度

  • 同时调整backlognet.core.somaxconnnet.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 ~]# cat ./test_listen.sh
#!/bin/bash
# test_listen.sh

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))

# 使用 netcat 或 socat 创建监听
if command -v socat &>/dev/null; then
# 使用 socat
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
# 使用 netcat
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 ~]# ./test_listen.sh
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]# /usr/share/bcc/tools/funccount '*listen*' 
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]# sudo /usr/share/bcc/tools/funccount '__sys_listen' -i 2
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_startreqsk_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_listenfunccount 使用方法

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 ~]# sudo /usr/share/bcc/tools/trace '__sys_listen "fd=%d backlog=%d", arg1, arg2'
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]# sudo /usr/share/bcc/tools/argdist -H 'p::__sys_listen(int fd, int backlog):int:backlog'
[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 ~]# bpftrace -e 'tracepoint:syscalls:sys_enter_listen { @[comm] = count(); } interval:s:10 { exit(); } END { print(@); }'
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]# bpftrace -e 'kretprobe:__sys_listen { @[retval] = count(); } interval:s:3 { printf("\n返回值统计:\n"); print(@); clear(@); }'
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]# bpftrace -e 'kprobe:__sys_listen { @backlog[arg1] = count(); } interval:s:2 { printf("\nbacklog分布:\n"); print(@backlog); clear(@backlog); }'
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 ~]# sudo perf trace -e syscalls:sys_enter_listen --no-syscalls 2>&1

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)-[~]
└─# sysctl -a | grep net.ipv4.ip_lo
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
// 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内核对象,和上面的 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
// file: net/ipv4/af_inet.c
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
// 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);

// 构建并发送SYN报文
err = tcp_connect(sk);
}

这个函数完成了三个关键步骤:

  1. socket状态设置为TCP_SYN_SENT
  2. 动态选择一个可用的端口
  3. 构建并发送SYN报文

这里客户端端口选择是通过 inet_hash_connect 函数实现的:

1
2
3
4
5
6
7
// 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:检查连接四元组是否冲突的函数

__inet_hash_connect 函数中,首先获取系统配置的本地端口范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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;
// ...
}
}
}

这里的 lowhigh 来自内核参数 net.ipv4.ip_local_port_range,默认值为32768-60999,提供了 28231 个可用端口。

1
2
3
4
5
6
7
┌──(root㉿liruilongs)-[~]
└─# sysctl -a | grep net.ipv4.ip_lo
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
// 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;
}
}

// 未使用的话,创建新的绑定记录
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, ...);
goto ok;
}

return -EADDRNOTAVAIL;
}

这段代码展示了端口选择的核心逻辑:

  1. 跳过保留端口(在net.ipv4.ip_local_reserved_ports中配置)
  2. 检查端口是否已被使用
  3. 如果已被使用,通过 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
// 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)
{
// 找到哈希桶
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
// 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)))

这个宏比较了:

  • 端口对(源端口和目标端口)
  • 目标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 socket
import threading
import time

def client_connect(target_host, target_port, client_port, conn_id):
"""客户端连接函数:使用指定的客户端端口连接目标服务器"""
# 创建 TCP 套接字
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)

# 用同一个客户端端口连接两个不同服务器(四元组不同)
# 连接1:(127.0.0.1, 12345) -> (127.0.0.1, 8000)
threading.Thread(
target=client_connect,
args=('127.0.0.1', server1_port, client_port, 1)
).start()

# 稍微延迟,确保第一个连接绑定成功
time.sleep(2)

# 连接2:(127.0.0.1, 12345) -> (127.0.0.1, 9000)
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
# 关键:设置SO_REUSEADDR选项,允许端口复用(Windows必需)
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
// 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连接初始化时,重传定时器被设置为 TCP_TIMEOUT_INIT

1
2
3
4
5
6
// file: net/ipv4/tcp_output.c
void tcp_connect_init(struct sock *sk)
{
// 初始化为TCP_TIMEOUT_INIT
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"
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ)) /* RFC6298 2.1 initial RTO value */
timeout = TCP_TIMEOUT_INIT;
┌──[root@liruilongs.github.io]-[~]
└─$
// file: include/net/tcp.h
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))

而在较早的版本(如v2.6.30)中,这个值是3秒:

1
2
// file: include/net/tcp.h (v2.6.30)
#define TCP_TIMEOUT_INIT ((unsigned)(3*HZ))

客户端在调用 connect 时,会把本地 socket 状态设置为 TCP_SYN_SENT,选择一个可用端口,随后发出 SYN 握手请求并启动重传定时器(TCP_TIMEOUT_INIT)。

这里如果没有收到服务端的回包,客户端会在重传定时器超时后按指数退避重发 SYN,直到达到 net.ipv4.tcp_syn_retries 上限。

观测性能影响与优化建议

如何优化?

这里的性能分析是对客户端而言的

主要是端口选择的性能影响,每次端口选择,内核都需要遍历所有可用端口,当可用端口接近耗尽时,内核需要遍历更多的端口才能找到可用端口,这里会涉及自旋式排他锁等待哈希查找,这会导致connect系统调用的CPU开销增加。这种情况在高并发连接场景下尤为明显。一般的优化手段

系统角度:

  1. 增加可用端口范围:适当调整 net.ipv4.ip_local_port_range 参数,扩大可用端口范围
  2. 合理设置保留端口:仅将必要的端口添加到 net.ipv4.ip_local_reserved_ports , 填了会少一次四元组判定。
  3. 监控端口使用情况:在高并发场景下,同步监控端口使用情况,及时发现潜在问题
  4. 应急的处理办法: 开启 tcp_tw_reuse = 1, 依赖 tcp_timestamps = 1,不要关闭这个参数,TCP 连接关闭后(四次挥手完成),连接不会立即销毁,而是进入 TIME_WAIT 状态,Linux 下默认持续 60 秒(由 tcp_fin_timeout 控制),TIME_WAIT 状态 的连接会占用本地端口,端口一旦被占,就无法分配给新的连接, tcp_tw_reuse = 1 在保证协议安全的前提下,提前复用 TIME_WAIT 端口,缓解端口不足

代码角度:

  1. 避免绑定固定端口: 客户端程序应避免调用 bind 绑定固定端口,让内核自动选择
  2. 尽量复用连接:使用长连接来削减频繁握手处理。

其次是客户端 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 ~]# ./connect_test.sh 
使用 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 ~]# cat connect_test.sh
#!/bin/bash
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]# ./tcpconnect 
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]# /usr/share/bcc/tools/funccount '*sys_connect'
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]# /usr/share/bcc/tools/funccount '__sys_connect'
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]# bpftrace -e 'kprobe:__sys_connect { @[comm] = count(); } interval:s:2 { printf("\n"); print(@); clear(@); }'
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]# bpftrace -l 'tracepoint:syscalls:*connect*'
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_rcvtcp_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
// 源码路径:net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb) {
// 服务端收到SYN(第一次握手)或ACK(第三次握手)都会进入这里
if (sk->sk_state == TCP_LISTEN) {
// 查找半连接队列(首次响应SYN时队列为空,无实际操作)
struct sock *nsk = tcp_v4_hnd_req(sk, skb);
}
// 交给TCP状态机处理
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
// 源码路径:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) {
switch (sk->sk_state) {
// 处理第一次握手:LISTEN状态下收到SYN包
case TCP_LISTEN:
if (th->syn) { // 判断是否为SYN握手包
// conn_request是函数指针,指向tcp_v4_conn_request
if (icsk->icsk_af_ops->conn_request(sk, skb)) {
return 1;
}
}
break;
// 其他状态(如ESTABLISHED)处理略
}
}

检测到 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
// 源码路径:net/ipv4/tcp_ipv4.c
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; // 队列满且未开syncookies,丢弃SYN包
}

// 第二步:预判全连接队列是否满(提前规避后续异常)
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; // 全连接队列满且young_ack>1,丢弃SYN包
}

.............
}

半连接队列检查(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) {

.........................
// 第三步:分配request_sock内核对象(存储半连接信息)
req = inet_reqsk_alloc(&tcp_request_sock_ops);

// 第四步:构造SYN+ACK包
skb_synack = tcp_make_synack(sk, dst, req, fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);

// 第五步:发送SYN+ACK响应包
err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr, ireq->srmt_addr, ireq->sopt);

// 第六步:加入半连接队列,并启动定时器(超时重传SYN+ACK)
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) --------->| # 客户端发第一次握手的SYN包
| |--- 检查半连接队列:已满;检查tcp_syncookies=0(关闭)
| |--- 直接丢弃SYN包,不发任何响应(无SYN+ACK,也无RST)
| |
|<-- 超时重传 SYN ------------->| # 客户端没收到SYN+ACK,触发SYN重传,次数由`tcp_syn_retries`控制
| |--- 服务端仍丢弃,直到客户端重试次数用尽
| |
|--- 连接超时失败 ------------->| # 客户端最终报 connect timeout

对应半连接队列的溢出,优化方式可以从下面三个方面入手:

  1. 调整半连接队列的大小,前面我们有讲到半连接队列的内核参数 tcp_max_syn_backlog
  2. 上面讲的SYN Flood防护机制,开启内核参数 tcp_syncookies,延迟请求对象的创建。
  3. 加快结束异常连接,减少无效半连接堆积,调整超时重传次数的阈值 tcp_syn_retries,这里可以配合上面客户端调优的进行,调整对应的客户端超时重传的次数

下面为全连接队列的溢出情况

1
2
3
4
5
6
7
8
9
10
客户端                          服务端
| |
|--- [1] SYN (seq=x) --------->| # 客户端发第一次握手的SYN包
| |--- 检查全连接队列:已满,且有未完成的半连接(young_ack>1)
| |--- 直接丢弃ACK包,无任何响应
| |
|<-- 超时重传 SYN ------------->| # 客户端没收到SYN+ACK,触发SYN重传,次数由`tcp_syn_retries`控制
| |--- 服务端仍丢弃,直到客户端重试次数用尽
| |
|--- 连接超时失败 ------------->| # 客户端最终报 connect timeout

全连接队列的溢出场景,实际的全连接队列在三次握手阶段同样会触发。和上面的半连接队列一样,全连接队列溢出的原因要么是队列太小,要么是消费小于生产,因此优化手段同样是 扩容队列上限、加快消费速度、减少无效堆积

  • 调整全连接队列的核心容量参数 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次挥手 ESTABLISHEDFIN_WAIT_1 保持 ESTABLISHED 客户端发送 FIN 包,请求关闭连接(表示无数据要发)
第2次挥手 FIN_WAIT_1FIN_WAIT_2 ESTABLISHEDCLOSE_WAIT 服务端收到 FIN,回复 ACK 包;客户端进入「等待 FIN 确认」,服务端进入「关闭等待」(可继续发剩余数据)
第3次挥手 保持 FIN_WAIT_2 CLOSE_WAITLAST_ACK 服务端数据发送完毕,发送 FIN 包给客户端
第4次挥手 FIN_WAIT_2TIME_WAITCLOSED LAST_ACKCLOSED 客户端收到 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 ~]# grep -E 'tcp_v4_conn_request|tcp_conn_request|tcp_make_synack|tcp_v4_send_synack|inet_csk_reqsk_queue_hash_add|tcp_syn_flood_action' /sys/kernel/debug/tracing/available_filter_functions | sort -u
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 ~]# /usr/share/bcc/tools/funccount -d 8 -r 'tcp_v4_conn_request|tcp_conn_request|tcp_make_synack|tcp_v4_send_synack|inet_csk_reqsk_queue_hash_add|tcp_syn_flood_action'
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 ~]# timeout 8 /usr/share/bcc/tools/tcpstates -4
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(关闭) CLOSEDLISTEN(监听) 服务端调用 listen() 绑定端口,进入监听状态
第1次握手 CLOSEDSYN_SENT 保持 LISTEN 客户端发送 SYN 包,请求建立连接
第2次握手 保持 SYN_SENT LISTENSYN_RECV 服务端收到 SYN,回复 SYN+ACK
第3次握手 SYN_SENTESTABLISHED SYN_RECVESTABLISHED 客户端收到 SYN+ACK,回复 ACK 包;服务端收到 ACK 后,双方进入「已建立」状态

队列溢出观测(backlog=2,并发 120)

正常状态,为了测试,我看可能需要调整一些内核参数

1
2
3
[root@liruilongs.github.io ~]# ss -lnt sport = :10113
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 ~]# # 并发 120 个连接后再次查看
[root@liruilongs.github.io ~]# ss -lnt sport = :10113
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 ~]# netstat -s | grep -i -E 'listen queue of a socket overflowed|SYNs to LISTEN sockets dropped'
847 times the listen queue of a socket overflowed
847 SYNs to LISTEN sockets dropped

[root@liruilongs.github.io ~]# echo "CLIENT_OK=3 CLIENT_FAIL=117"
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 ~]# /usr/share/bcc/tools/stackcount -D 6 tcp_v4_conn_request
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...

输出从 connecttcp_v4_conn_request 的完整调用链

第三次握手

客户端处理 SYN-ACK、发送 ACK,服务端处理 ACK(accept 系统调用)

客户端在除 ESTABLISHEDTIME_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
// 文件路径:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
switch (sk->sk_state) {
// 服务端LISTEN状态处理(接收客户端SYN)
case TCP_LISTEN:
// 服务端逻辑,本文不展开
break;
// 客户端SYN_SENT状态(已发SYN,等待SYN-ACK)
case TCP_SYN_SENT:
// 核心:处理SYN-ACK包的主逻辑
queued = tcp_rcv_synsent_state_process(sk, skb, th, len);
return 0;
// 其他状态(如ESTABLISHED、TIME_WAIT等)
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
// 文件路径:net/ipv4/tcp_input.c
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
// 1. 确认SYN-ACK包的合法性(校验ACK序号、SYN标志位等)
tcp_ack(sk, skb, FLAG_SLOWPATH);

// 2. 完成连接初始化(状态切换、拥塞控制、保活定时器)
tcp_finish_connect(sk, skb);

// 3. 判断是否需要延迟发送ACK(本文场景下走else分支,立即发ACK)
if (sk->sk_write_pending ||
icsk->icsk_accept_queue.rskq_defer_accept ||
icsk->icsk_ack.pingpong) {
// 延迟确认逻辑(如有未发送数据时合并ACK)
} else {
// 4. 清除连接建立阶段的重传定时器(SYN包的重传定时器)
tcp_clean_rtx_queue(sk, 0, 0);
// 5. 构造并发送第三次握手的ACK包
tcp_send_ack(sk);
}
return 0;
}

状态转换与连接确认

  1. tcp_ack:校验 SYN-ACK 包的 ACK 序号是否匹配客户端发送的 SYN 包序号,确保包的合法性;
  2. 执行tcp_finish_connect将客户端socket状态转换为TCP_ESTABLISHED,完成连接建立。
  3. 分支判断:仅当有未发送数据、开启延迟确认等场景时才延迟发 ACK,普通连接会立即调用tcp_send_ack。
  4. tcp_clean_rtx_queue:删除客户端发送 SYN 包时设置的重传定时器(避免重复发送 SYN),并清理发送队列中已确认的报文;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 文件路径:net/ipv4/tcp_input.c
void tcp_finish_connect(struct sock *sk, struct sk_buff *skb)
{
// 1. 核心:将socket状态从TCP_SYN_SENT切换为TCP_ESTABLISHED
tcp_set_state(sk, TCP_ESTABLISHED);

// 2. 初始化TCP拥塞控制算法(如cubic、reno)
tcp_init_congestion_control(sk);

// 3. 若开启了SO_KEEPALIVE选项,启动保活定时器
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(默认)、renobbrwestwood
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 # 输出:cubic

# 临时切换为 BBR 算法(需内核支持)
echo "bbr" > /proc/sys/net/ipv4/tcp_congestion_control

# 永久生效(写入 /etc/sysctl.conf)
echo "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.conf
sysctl -p

保活机制相关的内核参数我们上面已经介绍了,不多说,这里看一下流程

  1. 连接建立后,若开启 SO_KEEPALIVE,内核会在连接空闲 tcp_keepalive_time 秒后,发送第一个保活探测包;
  2. 若对端无响应,每隔 tcp_keepalive_intvl 秒再发一个探测包;
  3. 连续发送 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
// 文件路径:net/ipv4/tcp_output.c
void tcp_send_ack(struct sock *sk)
{
// 1. 申请内存,构造ACK包(仅含TCP头部,无数据)
struct sk_buff *buff = alloc_skb(MAX_TCP_HEADER, sk_gfp_atomic(sk, GFP_ATOMIC));

// 2. 填充TCP头部(ACK标志位、确认序号等)
tcp_build_skb(sk, buff, 0, 0, 0);

// 3. 发送ACK包到网络层
tcp_transmit_skb(sk, buff, 0, sk_gfp_atomic(sk, GFP_ATOMIC));
}

第三次握手的 ACK 包无数据载荷,仅包含 TCP 头部(ACK 标志位、确认序号、窗口大小等),服务端收到后会将自身 socket 状态切换为TCP_ESTABLISHED,至此双向连接完全建立,连接建立时会初始化拥塞控制、保活定时器等,为后续数据传输做准备;

状态机是核心:客户端处理 SYN-ACK 的核心逻辑由 TCP 状态机驱动,TCP_SYN_SENTTCP_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
// 文件路径:net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
// 服务端监听状态(等待客户端连接)
if (sk->sk_state == TCP_LISTEN) {
// 核心:处理连接请求(第三次握手ACK包)
struct sock *nsk = tcp_v4_hnd_req(sk, skb);
if (nsk != sk) {
// 处理子socket的后续逻辑
if (tcp_child_process(sk, nsk, skb)) {
return 0;
}
}
}
// 其他状态(如ESTABLISHED)处理逻辑
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
// 文件路径:net/ipv4/tcp_ipv4.c
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);

// 核心:根据客户端IP/端口查找半连接队列中的request_sock
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
// 文件路径:net/ipv4/tcp_minisocks.c
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;
// 1. 创建子socket(核心:调用tcp_v4_syn_recv_sock)
child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);

if (child) {
// 2. 从半连接队列中删除该连接请求
inet_csk_reqsk_queue_unlink(sk, req, prev);
inet_csk_reqsk_queue_removed(sk, req);

// 3. 将子socket添加到全连接队列(accept_queue)
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
// 文件路径:net/ipv4/tcp_ipv4.c
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;

// 关键:检查全连接队列是否已满(accept_queue溢出核心判断)
if (sk_acceptq_is_full(sk)) {
goto exit_overflow; // 队列满则丢弃,累计溢出计数
}

// 申请并初始化新的子socket(用于与客户端通信)
newsk = tcp_create_openreq_child(sk, req, skb);
return newsk;

exit_overflow:
// 全连接队列溢出,更新内核统计(可通过netstat -s查看)
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
// 文件路径:include/net/inet_connection_sock.h
static inline void inet_csk_reqsk_queue_unlink(struct sock *sk,
struct request_sock *req,
struct request_sock **prev)
{
// 从半连接队列链表中移除该request_sock
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
// 文件路径:net/ipv4/syncookies.c
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);
}

// 文件路径:include/net/request_sock.h
static inline void reqsk_queue_add(...) {
req->sk = child; // 关联子socket
sk_acceptq_added(parent); // 更新队列计数

// 链表尾插:将req加入全连接队列尾部
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
// 文件路径:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
switch (sk->sk_state) {
// 服务端第三次握手:子socket处于SYN_RECV状态
case TCP_SYN_RECV:
// 核心:将连接状态改为ESTABLISHED
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
// 文件路径:net/ipv4/inet_connection_sock.c
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; // 队列为空,非阻塞模式返回EAGAIN
return NULL;
}

// 获取关联的子socket并返回
newsk = req->sk;
return newsk;
}

// 文件路径:include/net/request_sock.h
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.somaxconnnet.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 ~]# grep -E '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' /sys/kernel/debug/tracing/available_filter_functions | sort -u
__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 ~]# /usr/share/bcc/tools/funccount -d 8 -r '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'
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 ~]# /usr/share/bcc/tools/funccount -d 8 't:syscalls:sys_enter_accept4'
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 -lntRecv-Q 结合判断“消费是否跟得上”。

accept4 参数分布(flags)跟踪

1
2
3
4
5
[root@liruilongs.github.io ~]# timeout 8 bpftrace -e 'tracepoint:syscalls:sys_enter_accept4 { @flags[args->flags] = count(); } interval:s:2 { print(@flags); clear(@flags); }'
Attaching 2 probes...
@flags[524288]: 35
@flags[524288]: 60
@flags[524288]: 25

5242880x80000,对应 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 ~]# /usr/share/bcc/tools/stackcount -D 6 inet_csk_accept
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 ~]# timeout 8 perf trace -e syscalls:sys_enter_accept4 --no-syscalls
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)

发布于

2025-11-20

更新于

2026-03-10

许可协议

评论
加载中,最新评论有1分钟缓存...
Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×