【动手实验】TCP半连接队列、全连接队列实战分析
- 电脑硬件
- 2025-09-21 11:06:02

本文是对 从一次线上问题说起,详解 TCP 半连接队列、全连接队列 这篇文章的实验复现和总结,借此加深对 TCP 半连接队列、全连接队列的理解。
实验环境两台腾讯云服务器 node2(172.19.0.12) 和 node3(172.19.0.15)配置为 2C4G,Ubuntu 系统,内核版本 5.15.0-130-generic 。
全连接半连接队列简介在 TCP 三次握手过程中,Linux 会维护两个队列分别是:
SYN Queue 半连接队列Accept Queue 全连接队列创建连接时,两个队列作用如下:
客户端向服务端发送 SYN 包,客户端进入 SYN_SENT 状态服务端收到 SYN 包后,进入 SYN_RECV 状态,内核将连接信息放入 SYN Queue 队列,然后向客户端发送 SYN+ACK 包客户端收到 SYN+ACK 包后,发送 ACK 包,客户端进入 ESTABLISHED 状态服务端收到 ACK 包后,将连接从 SYN Queue 队列中取出移到 Accept Queue 队列,Server 端进入 ESTABLISHED。服务端应用程序调用 accept 函数处理数据,连接从 Accept Queue 队列移除。图片来自:从一次线上问题说起,详解 TCP 半连接队列、全连接队列
图片来自Cloudflare Blog: SYN Packet Handling in the Wild
两个队列的长度都是有限的,当队列满了之后,新建连接时内核会将 SYN 包丢弃或者直接返回 RST 包。
全连接队列实战 全连接队列长度控制TCP 全连接队列的长度计算公式为:
min(somaxconn, backlog)
somaxconn Linux 内核参数 net.core.somaxconn 的值,默认为 4096。可以通过修改该参数来控制全连接队列的长度。backlog 是系统调用 listen 函数 int listen(int sockfd, int backlog) 的 backlog 参数, Golang 中默认使用系统 somaxconn 的值。下面是 Linux 5.15.130 内核源码中计算全连接队列长度的代码:
源码地址: elixir.bootlin /linux/v5.15.130/source/net/socket.c#L1716
我们修改 somaxconn 的值,然后运行实验代码查看全连接队列的长度变化。
服务端实验代码 package main import ( "log" "net" "time" ) func main() { l, err := net.Listen("tcp", ":8888") if err != nil { log.Printf("failed to listen due to %v", err) } defer l.Close() log.Println("listen :8888 success") for { time.Sleep(time.Second * 100) } }首先我们修改 somaxconn 为 128:
sudo sysctl -w net.core.somaxconn=128启动服务后查看全连接队列的长度:
$ go run server.go 2025/02/13 09:53:01 listen :8888 success $ ss -lnt State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 128 *:8888 *:* ...这里简单解释下 ss 命令输出的含义:
对于 Listen 状态的 socket,Recv-Q 表示当前全连接队列的长度,也就是已经完成三次握手,等待应用层调用 accept 的 TCP 连接数;Send-Q 表示全连接队列的最大长度。
对于非 Listen 状态的 socket,Recv-Q 表示已经收到但尚未被应用读取的字节数;Send-Q 表示已发送但尚未收到确认的字节数。
再次修改 somaxconn 为 1024 重启服务后,查看全连接队列的长度已经变成了 1024。
$ sudo sysctl -w net.core.somaxconn=1024 $ go run server.go 2025/02/13 09:53:01 listen :8888 success $ ss -lnt State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 1024 *:8888 *:* ... 全连接队列溢出下面我们让服务端只 Listen 端口但不执行 accept() 处理数据,模拟全连接队列溢出的情况。
服务端代码 // server 端监听 8888 tcp 端口 package main import ( "log" "net" "time" ) func main() { l, err := net.Listen("tcp", ":8888") if err != nil { log.Printf("failed to listen due to %v", err) } defer l.Close() log.Println("listen :8888 success") for { time.Sleep(time.Second * 100) } } 客户端代码和原实验相比加了 time.Sleep(500 * time.Millisecond) 一行代码,让连接一个个建立,可以更精准的复现全连接队列已满的情况。
package main import ( "context" "log" "net" "os" "os/signal" "sync" "syscall" "time" ) var wg sync.WaitGroup func establishConn(ctx context.Context, i int) { defer wg.Done() conn, err := net.DialTimeout("tcp", ":8888", time.Second*5) if err != nil { log.Printf("%d, dial error: %v", i, err) return } log.Printf("%d, dial success", i) _, err = conn.Write([]byte("hello world")) if err != nil { log.Printf("%d, send error: %v", i, err) return } select { case <-ctx.Done(): log.Printf("%d, dail close", i) } } func main() { ctx, cancel := context.WithCancel(context.Background()) // 并发请求 10 次服务端,连接建立成功后发送数据 for i := 0; i < 10; i++ { wg.Add(1) time.Sleep(500 * time.Millisecond) go establishConn(ctx, i) } go func() { sc := make(chan os.Signal, 1) signal.Notify(sc, syscall.SIGINT) select { case <-sc: cancel() } }() wg.Wait() log.Printf("client exit") }我们先将全连接队列的最大长度设置为 5:
$ sudo sysctl -w net.core.somaxconn=5 $ cat /proc/sys/net/core/somaxconn 5运行服务端和客户端后,查看全连接队列情况:
服务端 socket 情况 $ ss -ant | grep -E "Recv|8888" State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 6 5 *:8888 *:* ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40148 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40162 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40128 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40132 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40110 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40112 客户端 socket 情况 $ ss -ant | grep -E "Recv|8888" State Recv-Q Send-Q Local Address:Port Peer Address:Port Process ESTAB 0 0 172.19.0.15:40132 172.19.0.12:8888 ESTAB 0 0 172.19.0.15:40162 172.19.0.12:8888 ESTAB 0 0 172.19.0.15:40148 172.19.0.12:8888 SYN-SENT 0 1 172.19.0.15:51906 172.19.0.12:8888 ESTAB 0 0 172.19.0.15:40112 172.19.0.12:8888 ESTAB 0 0 172.19.0.15:40128 172.19.0.12:8888 SYN-SENT 0 1 172.19.0.15:51912 172.19.0.12:8888 SYN-SENT 0 1 172.19.0.15:40176 172.19.0.12:8888 ESTAB 0 0 172.19.0.15:40110 172.19.0.12:8888 SYN-SENT 0 1 172.19.0.15:51926 172.19.0.12:8888 客户端日志输出 $ go run client.go 2025/02/19 11:14:22 0, dial success 2025/02/19 11:14:22 1, dial success 2025/02/19 11:14:23 2, dial success 2025/02/19 11:14:23 3, dial success 2025/02/19 11:14:24 4, dial success 2025/02/19 11:14:24 5, dial success 2025/02/19 11:14:30 6, dial error: dial tcp 172.19.0.12:8888: i/o timeout 2025/02/19 11:14:30 7, dial error: dial tcp 172.19.0.12:8888: i/o timeout 2025/02/19 11:14:31 8, dial error: dial tcp 172.19.0.12:8888: i/o timeout 2025/02/19 11:14:31 9, dial error: dial tcp 172.19.0.12:8888: i/o timeout我们来分析下上述结果:
1. 全连接队列是否已满服务端 Listen 状态的 socket 显示 Send-Q 为 5,表示该 socket 的全连接队列最大值为 5;Recv-Q 为 6,表示当前 Accept queue 中数量为 6,我们看有 6 条 ESTAB 状态的连接,符合观察结果。Linux 内核的判断依据是 > 而不是 >=,所以实际的连接数为比队列的最大值多 1 个。5.15.0-130-generic 内核代码如下:
// 源码地址 // elixir.bootlin /linux/v5.15.130/source/include/net/sock.h#L980 /* Note: If you think the test should be: * return READ_ONCE(sk->sk_ack_backlog) >= READ_ONCE(sk->sk_max_ack_backlog); * Then please take a look at commit 64a146513f8f ("[NET]: Revert incorrect accept queue backlog changes.") */ static inline bool sk_acceptq_is_full(const struct sock *sk) { return READ_ONCE(sk->sk_ack_backlog) > READ_ONCE(sk->sk_max_ack_backlog); }之所以这样做,是为了保证在 backlog 设置为 0 时,依然可以有一个连接进入全连接队列,具体可以查看以下 commit 信息:
github /torvalds/linux/commit/64a146513f8f12ba204b7bf5cb7e9505594ead42 [NET]: Revert incorrect accept queue backlog changes. This reverts two changes: 8488df8 248f067 A backlog value of N really does mean allow "N + 1" connections to queue to a listening socket. This allows one to specify "0" as the backlog and still get 1 connection. Noticed by Gerrit Renker and Rick Jones. Signed-off-by: David S. Miller <davem@davemloft.net> 2. 内核 drop 包处理逻辑客户端有 6 个 ESTAB 状态的 socket,另外还有 4 个 SYN-SENT 状态的 socket,对应着 4 条 timeout 报错信息。我们只改了全连接队列大小为 5,半连接队列大小依然为默认的 net.ipv4.tcp_max_syn_backlog=256,所以第 6 个请连接建立后 Accept Queue 满了但 SYN Queue 还没有满。按理说从第 7 个请求开始服务端可以接收 SYN 但不能在处理客户端的 ACK 进入 Accept Queue,服务端会有 4 条 SYN-RECV 状态的连接,而实际情况是服务端不存在 SYN_RECV 状态的连接,这是因为当 Accept Queue 被占满时,即使 SYN Queue 没有满,Linux 内核也会将新来的 SYN 请求丢弃掉。 5.15.0-130-generic 内核处理这部分逻辑的代码如下::
// 源码地址: elixir.bootlin /linux/v5.15.130/source/net/ipv4/tcp_input.c#L6848 int tcp_conn_request(struct request_sock_ops *rsk_ops, const struct tcp_request_sock_ops *af_ops, struct sock *sk, struct sk_buff *skb) { // ... 代码省略 syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies); /* TW buckets are converted to open requests without * limitations, they conserve resources and peer is * evidently real one. */ // 强制启用 SYN cookie 或者半连接队列已满 // !isn 表示是一个新的请求连接建立的 SYN if ((syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) && !isn) { // 这里表示是否启用 SYN cookie 机制;如果不开启,则直接 drop,如果开启,则继续执行。 want_cookie = tcp_syn_flood_action(sk, rsk_ops->slab_name); if (!want_cookie) goto drop; } // 如果 accept queue 满了则 drop if (sk_acceptq_is_full(sk)) { NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } static bool tcp_syn_flood_action(const struct sock *sk, const char *proto) { struct request_sock_queue *queue = &inet_csk(sk)->icsk_accept_queue; const char *msg = "Dropping request"; struct net *net = sock_net(sk); bool want_cookie = false; u8 syncookies; syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies); // 开启 SYN Cookie 机制 #ifdef CONFIG_SYN_COOKIES if (syncookies) { msg = "Sending cookies"; want_cookie = true; __NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPREQQFULLDOCOOKIES); } else #endif // 没有启用 syncookies,统计丢弃包的数量 __NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPREQQFULLDROP); // 如果启用了 SYN cookie 机制,发送警告 if (!queue->synflood_warned && syncookies != 2 && xchg(&queue->synflood_warned, 1) == 0) net_info_ratelimited("%s: Possible SYN flooding on port %d. %s. Check SNMP counters.\n", proto, sk->sk_num, msg); return want_cookie; } // 判断半连接队列是否满,用的是半连接队列的长度是否大于等于全连接队列的最大长度 static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk) { return inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog; }从代码中可以推测出 net.ipv4.tcp_syncookies 参数值的含义和 Linux 的处理机制:
2:强制开启 SYN Cookie 机制,发送警告1:当半连接队列满时,开启 SYN Cookie 机制,发送警告0:不开启 SYN Cookie 机制,并统计丢弃包的数量这里判断半连接队列是否满的依据是 inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog,也就是说当半连接队列长度不小于全连接队列的最大长度时,如果不开启 SYN Cookie 机制,就会将 SYN 包丢弃。
回到我们的实验环境,net.ipv4.tcp_syncookies 设置为 1 并且半连接队列没满,因此不会开启 SYN Cookie 机制,继续往后执行时会因为 Accept Queue 满了将包丢弃。可以通过 netstat -s 命令查看丢弃包的数量。
$ date;netstat -s | grep -i "SYNs to LISTEN" Wed Feb 19 12:05:51 PM CST 2025 1289 SYNs to LISTEN sockets dropped $ date;netstat -s | grep -i "SYNs to LISTEN" Wed Feb 19 12:06:05 PM CST 2025 1301 SYNs to LISTEN sockets dropped可以看到有 12 个 SYN 包被 DROP 了,查看抓包情况可以看到,我们有 4 个请求连接超时,每个请求传了 3 次 SYN(一次发起 + 两次重传)。
查看客户端 socket 状态能够看到重传计时器在工作,这里重传了两次和默认的 net.ipv4.tcp_syn_retries = 6 有出入,是因为代码 conn, err := net.DialTimeout("tcp", "172.19.0.12:8888", time.Second*5)设置了 5s 超时,操作系统的默认重传间隔大约为 1s、2s、4s、8s、16s、32s,第 3 次重传会发生在 7s 以后,客户端已经主动断开连接了。
$ sudo netstat -anpo | grep -E "Recv-Q|8888" Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 0 0 172.19.0.15:57384 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (7.57/0/0) tcp 0 0 172.19.0.15:57388 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (8.07/0/0) tcp 0 0 172.19.0.15:60276 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (9.58/0/0) tcp 0 1 172.19.0.15:60304 172.19.0.12:8888 SYN_SENT 3123924/client on (0.08/1/0) tcp 0 1 172.19.0.15:60286 172.19.0.12:8888 SYN_SENT 3123924/client on (2.60/2/0) tcp 0 0 172.19.0.15:60270 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (9.08/0/0) tcp 0 0 172.19.0.15:60280 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (10.08/0/0) tcp 0 1 172.19.0.15:60292 172.19.0.12:8888 SYN_SENT 3123924/client on (3.11/2/0) tcp 0 0 172.19.0.15:57398 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (8.57/0/0) tcp 0 1 172.19.0.15:60294 172.19.0.12:8888 SYN_SENT 3123924/client on (3.62/2/0) 3. overflow 参数控制当全连接队列满时,Linux 默认会 drop 掉包,这个受 net.ipv4.tcp_abort_on_overflow 参数控制,默认为 0 表示直接 drop,为 1 则表示中断连接,服务端会返回 RST 包。可以通过如下方式修改
$ sudo sysctl -w net.ipv4.tcp_abort_on_overflow=1 或者 echo 1 > /proc/sys/net/ipv4/tcp_abort_on_overflow我们修改参数后再次执行客户端请求,会出现 connection reset by peer 错误,抓包能看到 RST 包。(在实验时,如果客户端不加时间间隔,会出现返回 RST 包的情况,如果加了则不会出现这种情况,应该是和两者的生效机制有关,SYN Cookie 和全连接队列满 drop 发生在 tcp_conn_request 函数,而 abort_on_overflow 发生在 tcp_check_req 函数, 先挖个坑,等后续梳理整个网络传输流程时在做进一步分析)。
$ go run client.go 2025/03/01 13:36:55 2, dial success 2025/03/01 13:36:55 5, dial success 2025/03/01 13:36:55 4, dial success 2025/03/01 13:36:55 1, dial success 2025/03/01 13:36:55 3, dial success 2025/03/01 13:36:55 0, dial success 2025/03/01 13:36:55 7, dial error: dial tcp 172.19.0.12:8888: connect: connection reset by peer 2025/03/01 13:36:55 6, dial error: dial tcp 172.19.0.12:8888: connect: connection reset by peer 4. ss 命令展示含义服务端有 6 条 ESTAB 状态的 socket,RECV_Q 的值为 11,与客户端发送的数据 []byte("hello world") 数据长度一致,因为我们的没有执行 accept 接收数据,所以 RECV_Q 会展示这部分数据的大小;
客户端 6 条 ESTAB 状态的 socket,其 RECV_Q 和 SEND_Q 均为 0;而 4 条 SYN-SENT 状态的 SEND-Q 为 1,这是因为 6 条已建立连接的 socket 包可以被正常 ACK,而 4 条建立连接失败的 socket,其 SYN 包没有收到 ACK 包,因为 SEND-Q 显示为 1。由此我们可以再次总结下 ss 的展示含义:
对于 LISTEN 状态的 socket
Recv-Q:表示当前全连接队列的大小,即已完成三次握手等待应用程序 accept() 的 TCP 连接数。Send-Q:全连接队列的最大长度,即全连接队列所能容纳的 socket 数量。对于非 LISTEN 状态的 socket
Recv-Q:表示已被接收但尚未执行 accept 被应用程序读取的数据字节数,通常在服务端能观察到。Send-Q:表示已经发送但尚未收到 ACK 确认的字节数。内核代码如下:
// elixir.bootlin /linux/v5.15.130/source/net/ipv4/tcp_diag.c#L18 static void tcp_diag_get_info(struct sock *sk, struct inet_diag_msg *r, void *_info) { struct tcp_info *info = _info; if (inet_sk_state_load(sk) == TCP_LISTEN) { // LISTEN 状态的连接 // 当前已完成三次握手但未被 accept 的连接数 r->idiag_rqueue = READ_ONCE(sk->sk_ack_backlog); // 最大队列长度 r->idiag_wqueue = READ_ONCE(sk->sk_max_ack_backlog); } else if (sk->sk_type == SOCK_STREAM) { // 非 LISTEN 状态的普通连接 const struct tcp_sock *tp = tcp_sk(sk); // TCP 读队列,即接收缓冲区中未被应用层读取的数据量,单位是字节 r->idiag_rqueue = max_t(int, READ_ONCE(tp->rcv_nxt) - READ_ONCE(tp->copied_seq), 0); // TCP 写队列,即已经发送但尚未被对方 ACK 确认的数据量,单位是字节 r->idiag_wqueue = READ_ONCE(tp->write_seq) - tp->snd_una; } if (info) tcp_get_info(sk, info); } 5. SYN+ACK 重传原实验有三种情况:
三次握手成功,数据正常发送客户端认为连接建立成功,但服务端一直处于 SYN-RECV 状态,不断重传 SYN + ACK客户端发送 SYN 未得到响应一直在重传我们复现了第 1 中和第 3 种,之所以没有第二种情况是因为每次请求加了 500ms 的间隔,这样下一个请求发起 SYN 时,上一个请求已经完成三次握手,服务端的 socket 已经进入全连接队列了。如果我们去掉时间间隔,请求可能会一下子发出去全部进入半连接队列,等到服务端在接收到客户端的 ACK 包时,全连接队列已经满了,从而导致服务端的 socket 无法进入全连接队列,从而 DROP 掉 ACK 包出现第二种情况。这里我们去掉时间间隔尝试复现,此时可以看到服务端有 SYN-RECV 状态的连接,
$ ss -ant | grep -E "Recv|8888" State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 6 5 *:8888 *:* ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33430 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33458 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33482 SYN-RECV 0 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33512 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33442 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33428 ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33472 SYN-RECV 0 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33496查看抓包结果可以看到 SYN-ACK 包重传。
全连接队列的实验就到这里,下面我们来看半连接队列的实验。
半连接队列实战半连接队列的最大长度计算有些麻烦,网络上资料也很繁杂,本着 talk is cheap, show me the code 的原则,这里还是直接看 Linux 的源码来分析,还是 tcp_conn_request 函数。
// 源码地址: elixir.bootlin /linux/v5.15.130/source/net/ipv4/tcp_input.c#L6848 int tcp_conn_request(struct request_sock_ops *rsk_ops, const struct tcp_request_sock_ops *af_ops, struct sock *sk, struct sk_buff *skb) { // ... 代码省略 u8 syncookies; // 第一部分,基于 syncookies 和半连接队列是否超过全连接队列长度、半连接队列是否已满来判断是否 drop syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies); if ((syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) && !isn) { want_cookie = tcp_syn_flood_action(sk, rsk_ops->slab_name); if (!want_cookie) goto drop; } // 第二部分,判断全连接队列是否已满 if (sk_acceptq_is_full(sk)) { NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie); if (!req) goto drop; // ... 代码省略 if (!want_cookie && !isn) { // 获取系统参数 ``net.ipv4.tcp_max_syn_backlog`` 的值 int max_syn_backlog = READ_ONCE(net->ipv4.sysctl_max_syn_backlog); /* Kill the following clause, if you dislike this way. */ // 第三部分:判断半连接队列是否超过长度限制 if (!syncookies && (max_syn_backlog - inet_csk_reqsk_queue_len(sk) < (max_syn_backlog >> 2)) && !tcp_peer_is_proven(req, dst)) { /* Without syncookies last quarter of * backlog is filled with destinations, * proven to be alive. * It means that we continue to communicate * to destinations, already remembered * to the moment of synflood. */ pr_drop_req(req, ntohs(tcp_hdr(skb)->source), rsk_ops->family); goto drop_and_release; } isn = af_ops->init_seq(skb); } tcp_ecn_create_request(req, skb, sk, dst); if (want_cookie) { isn = cookie_init_sequence(af_ops, sk, skb, &req->mss); if (!tmp_opt.tstamp_ok) inet_rsk(req)->ecn_ok = 0; } return 0; }核心计算逻辑是 (max_syn_backlog - inet_csk_reqsk_queue_len(sk) < (max_syn_backlog >> 2)),即 max_syn_backlog 的值减去当前半连接队列的长度的值小于 max_syn_backlog 的 1/4 时,就会将 SYN 包丢弃。简单来说就是半连接队列长度不能超过 max_syn_backlog 的 3/4。因为比较条件是 > 而不是 >=,所以在不开启 syncookies 的情况下,实际的半连接队列长度应该是 max_syn_backlog 的 3/4 + 1。大致计算如下:
max_syn_backlog 为 128,则半连接队列长度最大为 97max_syn_backlog 为 256,则半连接队列长度最大为 193max_syn_backlog 为 512,则半连接队列长度最大为 385max_syn_backlog 为 1024,则半连接队列长度最大为 769结合上面全连接实验中的代码分析,我们可以总结下 Linux 5.15.30 内核下 SYN 包的 Drop 机制:
我们修改参数验证下上述三种情况。
实验一:关闭 syncookies,半连接长度超过全连接最大长度客户端我们使用 iptables 将服务端的包拦截,模拟 SYN Flood 攻击,这样服务端不会收到 ACK 包,也就不会进入全连接队列。系统参数 syn_cookies=0,max_syn_backlog=128,somaxconn=64,理论上会有 64 个 SYN-RECV 状态连接,其余的包被丢弃。
# 拦截服务端 8888 端口的包 $ sudo iptables -A INPUT -p tcp --sport 8888 -j DROP # 发送 SYN 包 $ sudo hping3 -S 172.19.0.12 -p 8888 --flood查看服务端情况
$ ss -ant | grep -E "Recv|8888" State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 64 *:8888 *:* # ubuntu @ node2 in ~ [11:58:11] $ sudo netstat -nat | grep :8888 | grep SYN_RECV | wc -l 64结果符合预期。这里可以用 go 客户端做更精确的验证,我们使用 Go 程序发送 100 个请求,然后查看服务端连接数和 DROP 数
$ date;netstat -s | grep -i "SYNs to LISTEN" Fri Feb 21 12:01:58 PM CST 2025 3030591019 SYNs to LISTEN sockets dropped $ sudo netstat -nat | grep :8888 | grep SYN_RECV | wc -l 64 $ date;netstat -s | grep -i "SYNs to LISTEN" Fri Feb 21 12:02:14 PM CST 2025 3030591127 SYNs to LISTEN sockets dropped可以看到服务端只有 64 个 SYN-RECV 状态连接,程序执行有有 3030591127-3030591019=108 个 SYN 包被丢弃。上面我们分析过,因为客户端设置了超时时间为 5s,所以 SYN 只会重传 2 次,也就是每个被 DROP 的连接都会发送 3 次 SYN。100 - 64 = 36,36 * 3 = 108,符合我们预期。
实验二:关闭 syncookies,全连接队列已满修改服务端系统参数 syn_cookies=0,max_syn_backlog=128,somaxconn=64,这样全连接队列最大长度为 64,当有 65 个连接建立时,全连接队列就会满,此时再有 SYN 包建立连接时就会被丢弃。
首先我们清理掉客户端机器的 iptables 规则,是的三次握手能够正常进程。
$ sudo iptables -F设置系统参数
$ sudo sysctl -w net.ipv4.tcp_syncookies=0 $ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=128 $ sudo sysctl -w net.core.somaxconn=64我们再次用 Go 客户端发送 100 个请求,然后查看服务端状态,可以看到有 65 个 ESTAB 状态连接,没有 SYN-RECV 状态连接,因为全连接队列已满,所有 SYN 包都会被丢弃。
$ ss -ant | grep -E "Recv|8888" State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 65 64 *:8888 $ sudo netstat -nat | grep :8888 | grep ESTAB | wc -l 65 # ubuntu @ node2 in ~ [12:18:27] C:130 $ sudo netstat -nat | grep :8888 | grep SYN_RECV | wc -l 0按照以上逻辑,会有 35 个连接被拒绝,一共有 35 * 3 = 105 个 SYN 包被丢弃。我们查看统计信息可以验证,3030591766 - 3030591661 = 105,符合预期。
$ date;netstat -s | grep -i "SYNs to LISTEN" Fri Feb 21 12:18:19 PM CST 2025 3030591661 SYNs to LISTEN sockets dropped # ubuntu @ node2 in ~ [12:18:19] $ date;netstat -s | grep -i "SYNs to LISTEN" Fri Feb 21 12:18:34 PM CST 2025 3030591766 SYNs to LISTEN sockets dropped 实验三:关闭 syncookies,半连接队列长度超过 max_syn_backlog 的 3/4现在我们将全连接队列长度调大 net.core.somaxconn 设置为 4096,使用 iptables 拦截服务端 8888 端口的包,这样全连接队列始终不会填满,然后 max_syn_backlog 分别设置为:
128,预期有 97 个 SYN-RECV 状态连接256,预期有 193 个 SYN-RECV 状态连接512,预期有 385 个 SYN-RECV 状态连接1024,预期有 769 个 SYN-RECV 状态连接分别设置并发送请求后,服务端显示结果如下,基本符合预期。
# 客户端设置 iptables 拦截服务端 sudo iptables -A INPUT -p tcp --sport 8888 -j DROP # 服务端查看 SYN-RECV 状态连接数 $ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=128 $ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l 97 $ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=256 $ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l 193 $ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=512 $ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l 385 $ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=1024 $ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l 769执行过程数值会有变化,但最大半连接队列长度符合预期。
实验四:开启 syncookies,半连接队列长度取决于 max(somaxconn, backlog)当开启 syncookies 时,半连接队列不在保留 1/4 的限制,而是取决于 max(somaxconn, backlog)。这里源码判断是 >=,因此最大长度应该会等于 max(somaxconn, backlog)
// 源码地址: elixir.bootlin /linux/v5.15.130/source/include/net/inet_connection_sock.h#L280 static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk) { return inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog; }我们分别设置 net.core.somaxconn 为 512,1024,4096并设置 net.ipv4.tcp_syncookies=1 开启 syncookies,每次设置完重启服务端,然后在发起请求,理论上会有 512,1024,4096 个 SYN-RECV 状态连接。
修改服务端 somaxconn 并重启后,使用 watch 命令查看 SYN-RECV 状态连接数,结果如下,符合预期。
$ watch -n 1 "netstat -nat | grep :8888 | grep SYN_RECV | wc -l" Every 1.0s: netstat -nat | grep :8888 | grep SYN_RECV | wc -l node2: Sat Mar 1 15:07:22 2025 512 $ watch -n 1 "netstat -nat | grep :8888 | grep SYN_RECV | wc -l" Every 1.0s: netstat -nat | grep :8888 | grep SYN_RECV | wc -l node2: Sat Mar 1 15:08:15 2025 1024 $ watch -n 1 "netstat -nat | grep :8888 | grep SYN_RECV | wc -l" Every 1.0s: netstat -nat | grep :8888 | grep SYN_RECV | wc -l node2: Sat Mar 1 15:09:11 2025 4096 简要总结 半连接队列受限于全连接队列长度,而全连接队列会受应用的影响,尽量不要将 somaxconn 设置的过小,否则会影响服务器的性能。尽量开启 syncookies,可以有效防止 SYN Flood 攻击,同时可以避免半连接队列被大量占用。ss、netstat 的熟练使用对探查网络状态非常重要,要熟练掌握。代码之下无秘密,一定要结合源码去理解 Linux 的网络工作机制,不要只是死记硬背协议。动手!动手!动手!实践出真知。【动手实验】TCP半连接队列、全连接队列实战分析由讯客互联电脑硬件栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“【动手实验】TCP半连接队列、全连接队列实战分析”