4、传输层之TCP实现:TCP控制块(tcp_sock)结构,三次握手与四次挥手的内核实现,滑动窗口与拥塞控制

TCP 协议栈的实现,说白了就是内核里那一大坨代码。我刚开始啃这部分源码时,感觉像在迷宫里转悠。但后来我发现,只要抓住三个核心——控制块、状态机、窗口机制——整个脉络就清晰了。

4.1 TCP控制块:tcp_sock 结构体

每个 TCP 连接在内核里都对应一个 tcp_sock 结构体。你可以把它想象成连接的「身份证+档案袋」。我习惯先看它的继承关系:

struct tcp_sock {
    /* 继承自 inet_connection_sock */
    struct inet_connection_sock inet_conn;
    /* TCP 特有字段 */
    u32 rcv_nxt;          /* 下一个期望接收的序号 */
    u32 snd_nxt;          /* 下一个待发送的序号 */
    u32 snd_una;          /* 第一个未确认的序号 */
    u32 write_seq;        /* 写指针,用于 PUSH 标志 */
    u32 copied_seq;       /* 用户已读取的序号 */
    /* 滑动窗口相关 */
    u32 rcv_wnd;          /* 接收窗口大小 */
    u32 snd_wnd;          /* 发送窗口大小 */
    u32 window_clamp;     /* 窗口上限 */
    /* 拥塞控制 */
    u32 snd_cwnd;         /* 拥塞窗口 */
    u32 snd_ssthresh;     /* 慢启动阈值 */
    /* 定时器 */
    struct timer_list retransmit_timer;  /* 重传定时器 */
    struct timer_list delack_timer;      /* 延迟确认定时器 */
    /* 状态机 */
    u8 tcp_header_len;    /* TCP 头部长度 */
    /* ... 还有几十个字段 */
};

核心字段速查表

字段含义我踩过的坑
rcv_nxt期望收到的下一个序号乱序包到达时,这个值不会变
snd_una最早未确认的序号重传时就是拿它和 snd_nxt 算范围
snd_cwnd拥塞窗口千万别和 snd_wnd 搞混,前者是网络状态,后者是接收方能力
window_clamp窗口上限我曾经调大这个值导致内存暴涨,嗯,教训深刻

你想想看,一个连接从建立到关闭,所有状态都记录在这个结构体里。内核每收到一个包,第一件事就是找到对应的 tcp_sock,然后更新这些字段。

4.2 三次握手的内核实现

三次握手,说白了就是三次状态跳转。内核里用 TCP_ESTABLISHED、TCP_SYN_SENT 这些枚举值来标记。我画了一张流程图,你看一眼就明白了:

TCP 三次握手内核状态机 CLOSED SYN_SENT ESTABLISHED LISTEN SYN_RCVD ESTABLISHED ① SYN (seq=x) ② SYN+ACK (seq=y, ack=x+1) ③ ACK (seq=x+1, ack=y+1) 关键内核函数调用链 客户端:connect() → tcp_v4_connect() → tcp_transmit_skb() 服务端:tcp_v4_rcv() → tcp_rcv_state_process() → tcp_send_synack() 第三次握手到达后:tcp_rcv_state_process() → tcp_set_state(TCP_ESTABLISHED) 然后创建子 socket,放入 accept 队列

我在调优一个高并发网关时发现,SYN_RCVD 状态如果积压太多,说明半连接队列满了。这时候要么调 tcp_max_syn_backlog,要么开 SYN Cookies。嗯,后者更安全。

避坑指南:我曾经在生产环境遇到「三次握手成功但业务超时」的问题。查了半天发现是 tcp_tw_reuse 和 tcp_tw_recycle 在 NAT 环境下冲突。后来我统一关掉了 tcp_tw_recycle(内核 4.12 之后已经移除),问题解决。

4.3 四次挥手的内核实现

挥手比握手复杂,因为它是「半关闭」的。主动关闭方发 FIN,被动方可能还有数据要发。内核里用 FIN_WAIT1、CLOSE_WAIT 这些状态来管理。

/* 主动关闭方调用 close() 后的路径 */
int tcp_close(struct sock *sk, long timeout) {
    /* 发送 FIN 包 */
    tcp_send_fin(sk);
    /* 状态迁移:ESTABLISHED → FIN_WAIT1 */
    tcp_set_state(sk, TCP_FIN_WAIT1);
    /* 启动 FIN_WAIT2 定时器,防止对方不响应 */
    inet_csk_reset_keepalive_timer(sk, ...);
}

/* 被动关闭方收到 FIN 后的路径 */
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb) {
    if (sk->sk_state == TCP_ESTABLISHED && tcp_sk(sk)->fin_seq) {
        /* 状态迁移:ESTABLISHED → CLOSE_WAIT */
        tcp_set_state(sk, TCP_CLOSE_WAIT);
        /* 通知用户进程:对端关闭了 */
        sk->sk_shutdown |= RCV_SHUTDOWN;
        /* 唤醒等待队列 */
        sk_data_ready(sk);
    }
}

这里有个经典问题:TIME_WAIT 为什么是 2MSL?说白了,就是为了防止最后一个 ACK 丢失。我见过有人把 tcp_fin_timeout 调成 1 秒,结果服务端重传 FIN 时客户端已经彻底忘了这个连接,导致 RST 乱飞。

注意:CLOSE_WAIT 状态如果大量堆积,说明应用程序没有正确调用 close()。我曾经排查过一个 Java 应用,发现是连接池泄漏,导致几千个 CLOSE_WAIT 把文件描述符耗光了。

4.4 滑动窗口:流量控制的基石

滑动窗口,说白了就是接收方告诉发送方:「我还能收多少,你别撑死我」。内核里用 rcv_wnd 和 snd_wnd 来管理。

我习惯把窗口理解成一个「滑动门」:

  • 发送窗口 = min(snd_cwnd, snd_wnd) —— 取拥塞窗口和接收窗口的较小值
  • 接收窗口 = rcv_wnd = 接收缓冲区剩余空间
  • 窗口滑动:收到 ACK 后,snd_una 前移,窗口右移

内核里有个关键函数 tcp_receive_window(),它计算当前还能收多少数据:

static inline u32 tcp_receive_window(const struct tcp_sock *tp) {
    s32 win = tp->rcv_wup + tp->rcv_wnd - tp->rcv_nxt;
    /* rcv_wup 是上次通告窗口时的序号 */
    /* 如果窗口为负,说明接收方已经溢出了 */
    return max(win, 0);
}

窗口更新时机:

  1. 用户从 socket 读取数据后,tcp_rcv_space_adjust() 会扩大窗口
  2. 接收缓冲区满时,窗口缩到 0,发送方停止发送
  3. 窗口为 0 时,发送方启动「零窗口探测」定时器,定期发 1 字节数据

你想想看,如果接收方处理慢,窗口会越来越小。我曾经在视频流服务器上遇到窗口频繁为 0 的问题,后来调大了 tcp_rmem 的默认值,吞吐量直接翻倍。

4.5 拥塞控制:内核里的四大算法

拥塞控制是 TCP 最「智能」的部分。内核里实现了四个经典算法:慢启动、拥塞避免、快速重传、快速恢复。我画了一张流程图:

TCP 拥塞控制状态机 慢启动 cwnd 指数增长 (×2/RTT) 拥塞避免 cwnd 线性增长 (+1/RTT) 快速恢复 cwnd = ssthresh + 3 cwnd ≥ ssthresh 收到 3 个重复 ACK 收到新 ACK 恢复为拥塞避免 超时重传:cwnd = 1,重新慢启动 RTO 超时 RTO 超时 内核实现:tcp_cong.c 中的 tcp_reno_cong_avoid() 和 tcp_reno_fast_recovery() 现代内核支持 BBR、CUBIC 等算法,通过 sysctl net.ipv4.tcp_congestion_control 切换

内核里 tcp_reno_cong_avoid() 是核心函数。我摘一段关键逻辑:

/* 拥塞避免阶段:每个 RTT 增加 1 个 MSS */
void tcp_reno_cong_avoid(struct sock *sk, u32 ack, u32 acked) {
    struct tcp_sock *tp = tcp_sk(sk);
    
    if (!tcp_is_cwnd_limited(sk))
        return;  /* 窗口没受限,不增长 */
    
    if (tp->snd_cwnd <= tp->snd_ssthresh) {
        /* 慢启动:每个 ACK 增加 1 个 MSS */
        tp->snd_cwnd += min(acked, tp->snd_cwnd);
    } else {
        /* 拥塞避免:每个 RTT 增加 1 个 MSS */
        tp->snd_cwnd_cnt += acked;
        if (tp->snd_cwnd_cnt >= tp->snd_cwnd) {
            tp->snd_cwnd_cnt -= tp->snd_cwnd;
            tp->snd_cwnd++;
        }
    }
}

调优建议:我习惯在数据中心内部使用 tcp_congestion_control = bbr,因为它不丢包也能探测带宽。但在公网上,CUBIC 更稳定。你可以用 ss -ti 查看当前连接的拥塞算法和 cwnd 大小。

4.6 总结:一张图看懂 TCP 内核实现

我把整个 TCP 内核实现的核心模块画成了一张总览图:

TCP 内核实现核心模块总览 tcp_sock 控制块 存储连接状态、序号、窗口 TCP 状态机 握手/挥手/数据传输 滑动窗口 流量控制、窗口通告 拥塞控制 慢启动/拥塞避免/快速恢复 定时器管理 重传/延迟确认/保活/零窗口 输入处理 tcp_v4_rcv() → tcp_rcv_established() 输出处理 tcp_write_xmit() → tcp_transmit_skb() 内存管理 sk_buff 分配、缓冲区管理 学习路径建议 1. 先读 tcp.h 中的 tcp_sock 结构体定义 2. 再跟 tcp_v4_rcv() 的输入路径,理解状态机跳转 3. 最后看 tcp_cong.c 的拥塞控制算法 4. 用 ftrace 或 bpftrace 跟踪实际调用

TCP 的实现远不止这些,但掌握了控制块、状态机、窗口机制这三块,你就能看懂 80% 的代码路径。剩下的 20%,无非是各种边界条件和优化技巧。嗯,下次遇到 TCP 问题,先想想这三个模块,思路就清晰了。