跳到主要内容

11.4 TCP (Transmission Control Protocol)

如果上一节我们聊的 UDP 是一个「发完即忘」的乐天派,那这一节要面对的 TCP 就是网络协议世界里最严重的强迫症患者。

TCP(Transmission Control Protocol)诞生于 1981 年的 RFC 793。在那之后的三十年里,它被修修补补、层层叠加——为了适应卫星链路、为了跑满千兆光纤、为了在拥挤的无线网络里求生。如今,它是互联网的基石。你用的 HTTP、SSH、FTP,甚至你现在正看着的这个页面,底层全是 TCP 在扛着。

比起 UDP 的简单粗暴,TCP 提供了可靠的、面向连接的字节流服务。它不希望你丢包,也不希望包乱序。为了做到这一点,它引入了序列号、确认应答、状态机、拥塞控制……这堆东西加在一起,让 TCP 成了内核里最复杂的协议之一。说实话,要把 TCP 的实现细节、各种优化算法和边缘情况全部讲透,这本书的厚度至少得翻三倍。

我们这里只抓最核心的那几根骨头:连接是怎么建立起来的,数据是怎么收发的,以及那一堆必不可少的定时器。 至于 TCP 那些让人眼花缭乱的拥塞控制算法(比如 Cubic、BBR 等),Linux 内核虽然支持热插拔,但我们要深入进去就需要单独开一章了。


TCP Header:比 UDP 重得多的行囊

在进入内核实现之前,我们要先认清 TCP 的脸。和 UDP 那个只有 8 字节、短小精悍的头部不同,TCP 的头部即使不含选项也有 20 字节,如果带上选项最多能到 60 字节。每一比特都有它的用武之地。

让我们看看内核里是怎么定义这个头部的:

struct tcphdr {
__be16 source;
__be16 dest;
__be32 seq;
__be32 ack_seq;
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u16 res1:4,
doff:4,
fin:1,
syn:1,
rst:1,
psh:1,
ack:1,
urg:1,
ece:1,
cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16 doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
__be16 window;
__sum16 check;
__be16 urg_ptr;
};

(include/uapi/linux/tcp.h)

这里面的一堆字段,我们可以像拆解钟表一样一个个看:

  • source / dest:源端口和目的端口(各 16 位)。这是传输层的 multiplexing key,决定数据属于哪个进程。
  • seq:序列号(32 位)。这是 TCP 可靠性的基石,标识了数据流的字节位置。
  • ack_seq:确认号(32 位)。注意,这个字段只有当 ACK 标志位为 1 时才有效。它告诉对方:「我已经收到了这之前的所有数据,接下来我期待这个序号的数据。」
  • res1:保留位(4 位),必须为 0。
  • doff:数据偏移(Data Offset,4 位)。这其实是指 TCP 头部的长度,单位是 4 字节。因为 TCP 头部是变长的(有选项),所以必须有这个字段来告诉内核「真正的数据从哪里开始」。最小值是 5(5×4=20 字节),最大是 15(60 字节)。

接下来是一排只有 1 比特的标志位,每一个都能改变 TCP 的状态机走向:

  • fin(Finish):「我发完了,准备关门。」
  • syn(Synchronize):用来在三次握手里同步序列号。
  • rst(Reset):「连接出错了,立刻重启。」这是 TCP 里的紧急刹车。
  • psh(Push):「别缓存了,立刻把数据推给应用层。」
  • ack:表示 ack_seq 字段是有效的。除了建立连接的第一个包,几乎所有的包都会带这个标志。
  • urg(Urgent):表示 urg_ptr 紧急指针字段有效。
  • ece(ECN-Echo)和 cwr(Congestion Window Reduced):这两个是显式拥塞通知(ECN,RFC 3168)相关的标志,用来在网络拥堵时不用丢包就能互相通知,比以前野蛮地丢包要文明得多。

最后是几个管理流控制和校验的字段:

  • window:接收窗口大小(16 位)。这是流量控制的阀门,告诉对方:「我的接收缓冲区还剩这么多空位,你发别超过这个数。」
  • check:校验和,覆盖头部和数据。
  • urg_ptr:紧急指针。只有 URG 标志置位时才有意义,是一个偏移量,指向紧急数据的最后一个字节。

图 11-3:IPv4 TCP 头部示意图 (此处展示头部布局图,包含 Source Port, Dest Port, Sequence Number, Acknowledgment Number, Data Offset, Flags, Window, Checksum, Urgent Pointer)

你看,这比 UDP 那四个字段要复杂得多。复杂性意味着开销,但也意味着控制力。 UDP 放弃了控制力换来了速度,而 TCP 紧紧抓住了每一个比特,以确保你的数据包不会迷失在网络的荒原里。

好了,看懂了头部,我们就可以潜入内核,看看 TCP 是怎么初始化这些复杂的机制的。


TCP Initialization:在内核里注册一个复杂的灵魂

既然 TCP 这么复杂,它在内核里的初始化流程自然也不能像 UDP 那样随便。

首先,我们得定义一个 net_protocol 对象 tcp_protocol,把它挂到内核的协议链表上去。这一步和 UDP 类似,还是调用 inet_add_protocol()

static const struct net_protocol tcp_protocol = {
.early_demux = tcp_v4_early_demux,
.handler = tcp_v4_rcv,
.err_handler = tcp_v4_err,
.no_policy = 1,
.netns_ok = 1,
};

(net/ipv4/af_inet.c)

然后在 inet_init() 里注册它:

static int __init inet_init(void)
{
. . .
if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
pr_crit("%s: Cannot add TCP protocol\n", __func__);
. . .
}

(net/ipv4/af_inet.c)

光注册协议还不够,TCP 还要处理 socket 层面的操作。我们定义一个 proto 对象 tcp_prot,同样用 proto_register() 注册:

struct proto tcp_prot = {
.name = "TCP",
.owner = THIS_MODULE,
.close = tcp_close,
.connect = tcp_v4_connect,
.disconnect = tcp_disconnect,
.accept = inet_csk_accept,
.ioctl = tcp_ioctl,
.init = tcp_v4_init_sock,
. . .
};

(net/ipv4/tcp_ipv4.c)

注意看 .init 这一行的回调函数:tcp_v4_init_sock。 在 UDP 那一节,你可能没看到类似的 .init 赋值,或者它被设为 NULL。为什么?因为 UDP 太简单了,创建 socket 时没什么特别的初始化非做不可。但 TCP 不一样。

当你在用户空间创建一个 TCP socket(socket(AF_INET, SOCK_STREAM, 0))时,内核最终会调用 tcp_v4_init_sock()。这个函数会调用 tcp_init_sock() 做一大堆脏活累活,比如:

  1. 把 socket 状态设为 TCP_CLOSE
  2. 初始化定时器(调用 tcp_init_xmit_timers())。TCP 极度依赖定时器,没有它们,TCP 就不知道该重传还是该放弃。
  3. 设置发送缓冲区(sk_sndbuf)和接收缓冲区(sk_rcvbuf)的大小。
    • 默认发送缓冲区是 16KB(sysctl_tcp_wmem[1])。
    • 默认接收缓冲区是 87KB(sysctl_tcp_rmem[1])。
    • 你可以通过 /proc/sys/net/ipv4/tcp_wmemtcp_rmem 去调优这些参数。
  4. 初始化乱序队列和 Prequeue。
  5. 初始化拥塞窗口,按 RFC 6928 的规定,初始拥塞窗口设为 10 个段(TCP_INIT_CWND)。

说到定时器,这是 TCP 心跳的动力源。我们专门来看看。


TCP Timers:时间的守护者

TCP 的可靠性很大一部分是建立在「等待」和「重试」之上的。这一切都由位于 net/ipv4/tcp_timer.c 里的定时器机制管理。TCP 主要使用了四种定时器,每一个都针对一种特定的焦虑症:

  1. 重传定时器: 这是最焦虑的一个。每发一个段,它就启动。如果在规定时间内没收到 ACK,它就会假设包丢了,重发一遍。当包真的丢了或者被链路层的噪声吃掉了时,它是最后的救命稻草。

  2. 延迟 ACK 定时器: 这个比较佛系。TCP 收到数据后不必立刻回 ACK,它可以稍微等一下(比如 200ms),看看有没有数据可以搭这趟车顺便发回去(捎带 ACK)。这能减少网络上的小包数量,提高效率。

  3. 保活定时器: 这是一个为了防止「僵尸连接」而存在的机制。有时候连接两端长时间没数据传输,中间的路由器可能断了一方,或者一方直接断电了。谁也不知道对方还活着没。KeepAlive 定时器会定期探测,如果发现对方没反应,就会调用 tcp_send_active_reset() 干掉这个连接。

  4. 零窗口探测定时器(Zero Window Probe,也叫持续定时器 Persistent Timer): 这是一个经典的死锁防止机制。如果接收方的接收缓冲区满了,它会告诉发送方:「窗口为 0,别发了。」发送方就会停下来等。 但这里有个巨大的坑:如果接收方腾出了空间,发了「窗口更新」包通知发送方,但这个不幸的窗口更新包在半路丢了,怎么办? 发送方以为窗口还是 0,继续等;接收方以为通知过了,继续等数据。这就是死锁。 解决办法就是零窗口探测:当发送方看到窗口为 0 时,不干等,而是启动这个定时器,时不时发一个小数据包去戳一下接收方:「喂,窗口开没开?」收到非零的窗口响应后,再继续传数据。


TCP Socket Initialization:一切从 tcp_v4_init_sock 开始

用户空间的程序想用 TCP,得先调用 socket() 创建一个 SOCK_STREAM 类型的 socket。内核在这一步会调用我们前面提到的 tcp_v4_init_sock() -> tcp_init_sock()

这个回调函数之所以重要,是因为它是通用初始化入口,无论是 IPv4 还是 IPv6,创建 TCP socket 时最终都会走到类似的逻辑(IPv6 走 tcp_v6_init_sock)。

它做的工作在上一节简单提过,这里再强调一下重点: 它把一个刚分配出来的 struct sock 对象从一个空壳变成了一个有状态的 TCP 实体。它设置了缓冲区,启动了定时器,计算了初始拥塞窗口。如果没有这一步,后续的 connect()listen() 都无从谈起。


TCP Connection Setup:三次握手的内核视角

TCP 的连接建立和断开,本质上是一个状态机的流转。任何一个时刻,socket 都处于一个特定的状态(比如 TCP_LISTENTCP_SYN_SENT 等)。这个状态保存在 struct socksk_state 成员里。

教科书里都讲过三次握手,但在内核里,这不仅仅是交换三个包,而是状态和内存结构的转换过程:

  1. 客户端发送 SYN: 客户端调用 connect(),发送一个 SYN 包。此时,客户端 socket 状态从 TCP_CLOSE 变为 TCP_SYN_SENT

  2. 服务端接收 SYN,发送 SYN-ACK: 服务端此时处于 TCP_LISTEN 状态(调用了 listen())。当它收到 SYN 包,内核会做一件很有意思的事:它不会直接把监听 socket 变成已连接状态,因为监听 socket 是用来服务所有客户端的。 相反,内核会创建一个新的 request_sock(请求 sock),代表这个正在建立的连接。这个新 sock 的状态被设为 TCP_SYN_RECV。 然后,服务端向客户端回送一个 SYN-ACK 包。

  3. 客户端接收 SYN-ACK,发送 ACK: 客户端收到 SYN-ACK,状态从 TCP_SYN_SENT 跃迁到 TCP_ESTABLISHED。连接在客户端这边算成了。它发送最后的 ACK。

  4. 服务端接收 ACK: 服务端收到那个最后的 ACK,request_sock 完成了它的历史使命。内核基于这个 request_sock 创建一个完整的子 socket(child socket),并将状态设为 TCP_ESTABLISHED。这个新 socket 会被放入 accept 队列,等待应用层调用 accept() 把它取走。

注意: 如果你想去源码里找这个状态机流转的「总控」,那就是 tcp_rcv_state_process() 方法(位于 net/ipv4/tcp_input.c)。无论是 IPv4 还是 IPv6,处理大部分状态变化(除了 ESTABLISHED 状态的快路径)都要经过它。


Receiving Packets:当网络层的包到达 TCP 层

连接建立好了,数据开始流动。作为内核工程师,我们要关心:当一个 IP 层的数据包(struct sk_buff)到达时,TCP 是怎么接住它的?

入口函数是 tcp_v4_rcv()net/ipv4/tcp_ipv4.c)。

让我们跟着代码走一遍:

int tcp_v4_rcv(struct sk_buff *skb)
{
struct sock *sk;
. . .

第一步:常规检查与查找 Socket 首先是一堆基本的 sanity checks:包是不是发给我们的?长度够不够一个 TCP 头? 然后,最关键的一步:找 Socket。我们得知道这个包是给谁服务的。调用 __inet_lookup_skb() 在 hash 表里找。

sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
. . .
if (!sk)
goto no_tcp_socket;

这里会先去 established hash 表找已连接的 socket;找不到再去 listening hash 表找监听 socket。如果都找不到,说明这包是瞎发的,丢弃它。

第二步:检查 Socket 是否被占用 找到 socket 之后,问题来了:用户态的进程现在正在用这个 socket 吗? 内核用 sock_owned_by_user() 宏来判断。如果返回 1,说明用户进程正拿着锁操作这个 socket(比如正在调用 read()write())。

if (!sock_owned_by_user(sk)) {
. . .
{

情况 A:Socket 未被占用 如果没人用,那太好了,内核可以直接处理。为了优化性能,内核会先尝试把数据包扔进 prequeue。这是一个特殊的队列,专门缓存数据包,等用户进程下次调用 socket 接口时再批量处理,减少上下文切换。

如果 prequeue 满了或者不适合进 prequeue,就调用 tcp_v4_do_rcv() 走正常流程。

if (!tcp_prequeue(sk, skb))
ret = tcp_v4_do_rcv(sk, skb);
}

情况 B:Socket 被占用 如果用户进程正在用这个 socket(锁住了),内核就不能乱动它的数据结构。为了避免丢包,内核只能把包暂时塞进 backlog 队列。

} else if (unlikely(sk_add_backlog(sk, skb,
sk->sk_rcvbuf + sk->sk_sndbuf))) {
bh_unlock_sock(sk);
NET_INC_STATS_BH(net, LINUX_MIB_TCPBACKLOGDROP);
goto discard_and_relse;
}
}

如果 backlog 都塞满了,那就只能丢包并统计一下 LINUX_MIB_TCPBACKLOGDROP

深入 tcp_v4_do_rcv() 不管是哪条路,最终数据包都要在这里分拣:

int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
  • 如果是 TCP_ESTABLISHED 状态(快路径): 调用 tcp_rcv_established()。这是最常用的路径,处理得非常高效。

  • 如果是 TCP_LISTEN 状态: 调用 tcp_v4_hnd_req(),这通常是在处理新连接的到达(收到 SYN 或最后的 ACK)。

  • 其他状态: 调用前面提到的大管家 tcp_rcv_state_process(),处理各种状态变迁(比如收到 FIN 进入关闭流程等)。


Sending Packets:把数据推出去

最后,我们来看看发送。用户空间调用 send()sendmsg(),内核最终都会走到 tcp_sendmsg()net/ipv4/tcp.c)。

这个函数比 UDP 的发送逻辑复杂得多。它不仅仅是把指针指过去就行了。

tcp_sendmsg() 的核心任务:

  1. 从用户空间拷贝数据到内核空间(skb)。
  2. 处理 Nagle 算法之类的逻辑(决定是立刻发还是攒一攒)。
  3. 组装 sk_buff
  4. 调用传输层的发送函数。

代码片段如下:

int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t size)
{
struct iovec *iov;
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;

int iovlen, flags, err, copied = 0;
int mss_now = 0, size_goal, copied_syn = 0, offset = 0;
bool sg;
long timeo;
. . .

这里面有很多关于 MSS(最大分段大小)、sk_sndbuf 检查的逻辑。

当数据最终被组装好放在 skb 里,准备出发时,会调用 tcp_push_one() -> tcp_write_xmit() -> tcp_transmit_skb()

tcp_transmit_skb() 里,真正把包交给网络层的最后一跃是这一行:

. . .
err = icsk->icsk_af_ops->queue_xmit(skb, &inet->cork.fl);
. . .
}

(net/ipv4/tcp_output.c)

这里用到了一个 icsk_af_ops(INET Connection Socket ops),这是一个面向地址族的操作对象。对于 IPv4 TCP,它指向 ipv4_specific,其 queue_xmit 回调就是通用的 ip_queue_xmit()

至此,TCP 层的处理完成。数据包正式移交给了 IP 层,也就是下一层(L3)的地盘。


TCP 的世界深不见底。我们在这里剥开了连接建立、定时器、收发包路径这几层表皮,但这仅仅是冰山一角。好消息是,有了这个基础,再去理解 SCTP 或者 DCCP 这种协议,你会发现它们其实是在 TCP 和 UDP 这两个极端之间,做着各种各样的权衡和混合。

下一节,我们就去看看这位「混血儿」——SCTP。