4. 系统调用与套接字API
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 这些枚举值来标记。我画了一张流程图,你看一眼就明白了:
我在调优一个高并发网关时发现,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);
}
窗口更新时机:
- 用户从 socket 读取数据后,
tcp_rcv_space_adjust()会扩大窗口 - 接收缓冲区满时,窗口缩到 0,发送方停止发送
- 窗口为 0 时,发送方启动「零窗口探测」定时器,定期发 1 字节数据
你想想看,如果接收方处理慢,窗口会越来越小。我曾经在视频流服务器上遇到窗口频繁为 0 的问题,后来调大了 tcp_rmem 的默认值,吞吐量直接翻倍。
4.5 拥塞控制:内核里的四大算法
拥塞控制是 TCP 最「智能」的部分。内核里实现了四个经典算法:慢启动、拥塞避免、快速重传、快速恢复。我画了一张流程图:
内核里 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 的实现远不止这些,但掌握了控制块、状态机、窗口机制这三块,你就能看懂 80% 的代码路径。剩下的 20%,无非是各种边界条件和优化技巧。嗯,下次遇到 TCP 问题,先想想这三个模块,思路就清晰了。