Switch-Router

Linux内核协议栈中一些关于 TCP MSS 的细节

Published at 2020-03-11 | Last Update 2020-03-11

为什么会有 MSS (Maximum Segment Size) 这种东西呢?

我们知道网络报文在线缆中以是有长度限制的,比如标准的以太网接口能允许通过的以太帧长度上限是 1518 字节。超过了这个长度的数据必然会被分成多个报文发送,这个工作 IP 层可以做,也就是熟知的 IP 分片(Fragment)过程。

但如果是承载 TCP 数据的 IP 报文分片后,某个分片在传输路径上丢失了,则会引起整个 TCP 报文的重传。因此,TCP 会在自己这一层就将用户数据按一定长度’切割’好之后再递交给 IP 层,这样 IP 层就不用分片了,每一个 IP 报文承载一个独立的 TCP 报文,即使中途不幸丢了,也只需要重传这一个报文,不会牵连其他报文重传。

TCP 切割用户数据的依据正是 MSS,它是对端 TCP 层能接受的最大 TCP 报文载荷长度。

TCP 通信的双方在三次握手建立连接时, 会在 SYN 和 SYNACK 报文中携带 MSS 选项, 通过该选项向对端通告(advertise)本端 TCP 能接受的最大 TCP 载荷长度:”本端 TCP 只能接收载荷长度最大为 XXX 字节的报文”

需要强调的是,MSS 限制的是 TCP 载荷的长度,并不包含 TCP 首部长度和 IP 首部长度,正如 RFC 879 中的描述

The MSS counts only data octets in the segment, it does not count the TCP header or the IP header.

不过,也的确有一个类似的概念是限制整个 IP 报文的长度,这就是 MTU。MTU 与 MSS 的关系可以从下面这张图中看出

也就是

MSS = MTU - IP_Header - TCP_Header

这一点在 RFC 879 中也有描述, 即 TCP MSS 的值等于 MTU 减去 40 字节

THE TCP MAXIMUM SEGMENT SIZE IS THE IP MAXIMUM DATAGRAM SIZE MINUS FORTY.

为什么是减去 40 ?

因为 IP 首部(不含option)的长度是 20 字节,TCP 首部(不含option)的长度也是 20 字节,两个加起来正好 40 字节(当然这只是 IPv4 的情况,IPv6 首部的最小长度就有 40 字节了)

等等,这里怎么不考虑 IP 和 TCP 中的可能存在的 option ?!原来 RFC 规定通告的 MSS 就是不包含option

The maximum number of data octets that may be received by the sender of this TCP option in TCP segments with no TCP header options transmitted in IP datagrams with no IP header options.

我们常常会看到一个说法,TCP 两端在建立连接时,会将 MSS 协商为双方通告 MSS 的较小值

这个说法其实是不准确的,RFC 879 明确指出了,没有这样的协商(negotiation)过程

This Maximum Segment Size (MSS) announcement (often mistakenly called a negotiation) is sent from the data receiver to the data sender and says “I can accept TCP segments up to size X”.

并且还说了,通信双方完全可以使用不同的 MSS!

The MSS can be used completely independently in each direction of data flow. The result may be quite different maximum sizes in the two directions.

但在现实中,我们往往又能看到,采用 Linux 内核的主机,即使网卡的 MTU 不同,但当 TCP 连接建立后,双方发送的最大报文长度又是一样的,就好像真的协商了一样。

这是怎么一回事呢?我们可以从内核实现中找到答案。

Linux 内核关于 MSS 实现的细节

Linux 内核在tcp_sock这个数据结构中保存与 MSS 有关的信息。

struct tcp_sock{
    // code omitted
    struct tcp_options_received rx_opt;
    {
         // code omitted...
         u16 user_mss;    /* 用户通过TCP_MAXSEG设置的MSS */
         u16 mss_clamp;   /* 在连接建立阶段协商出来的 min(user_mss, SYN's mss) 即对端通告的MSS */
    }
    // code omitted...
    u32 mss_cache;  // 有效MSS, = rx_opt.mss_clamp - TCP附件选项(如 时间戳12字节)
    u16 advmss;    
}

各个字段的意义如下:

  • rx_opt.user_mss:用户设置的本端 MSS。用户可以通过 TCP_MAXSEG 这个 socket 选项对这个字段进行设置,这个字段在 rx_opt 中,说明用户设置该字段起到的作用就如同收到了对端通告的 MSS 值。
  • rx_opt.mss_clamp: 连接建立阶段本端计算出的 MSS。它取user_mss和 对端 SYN(SYNACK) 报文通告的 MSS 值中的较小值,如果用户没有设置 user_mss,则就为对端报文中的 MSS 值。clamp 中文翻译为”夹钳”,我们可以理解为生效 MSS的最大值。
  • mss_cache: 生效 MSS。它是这几个字段中最重要的,表示本端 TCP 发包实际的分段大小依据,它的值在连接过程中可能发生变化。
  • advmss:本端向对端通告的包含option的 MSS 值。举个例子,当网卡 MTU 为 1500 字节时,通信双方通告的 MSS 都应该为 1460 字节,但如果双方都开启了 TCP timestamp 选项(会占用 12 字节),则advmss的值会是 1448

mss_cache

mss_cache在 Linux 内核中表示 TCP 连接当前生效的 MSS,它是如此重要,我们来看下它的值是如何确定的。

首先,无论是主动端还是被动端,在创建tcp_sock时,就会对mss_cache进行初始化为 TCP_MSS_DEFAULT(536)

void tcp_init_sock(struct sock *sk)
{
    // code omitted 
    tp->mss_cache = TCP_MSS_DEFAULT;
}

在这之后,通过tcp_sync_mss()方法,内核可以对mss_cache进行修改。

unsigned int tcp_sync_mss(struct sock *sk, u32 pmtu)
{
	struct tcp_sock *tp = tcp_sk(sk);
	struct inet_connection_sock *icsk = inet_csk(sk);
	int mss_now;

    /* 用 pmtu 计算 mss_now */
	mss_now = tcp_mtu_to_mss(sk, pmtu);
        |
        |-- __tcp_mtu_to_mss(sk, pmtu) - (tcp_sk(sk)->tcp_header_len - sizeof(struct tcphdr));     
    /* 其他条件限制 mss_now */
    // code omitted
	tp->mss_cache = mss_now;
    // code omitted
}

在不考虑其他条件(如对端最大接收窗口大小)时,mss_cache的值由tcp_mtu_to_mss()计算而来,进而由__tcp_mtu_to_mss()减去 TCP 首部的 option 长度而来

__tcp_mtu_to_mss()呢?它也就是传入的 pmtu 减去IP首部长度(含option),在减去TCP首部长度(不含option)。噢,对了,它还不能超过rx_opt.mss_clamp

static inline int __tcp_mtu_to_mss(struct sock *sk, int pmtu)
{
	const struct tcp_sock *tp = tcp_sk(sk);
	const struct inet_connection_sock *icsk = inet_csk(sk);
	int mss_now;
    
	/* Calculate base mss without TCP options:
	   It is MMS_S - sizeof(tcphdr) of rfc1122
	 */
	mss_now = pmtu - icsk->icsk_af_ops->net_header_len - sizeof(struct tcphdr);
 
	// code omitted 
	/* Clamp it (mss_clamp does not include tcp options) */
	if (mss_now > tp->rx_opt.mss_clamp)
		mss_now = tp->rx_opt.mss_clamp;

	// code omitted
	return mss_now;
}

所以,如果tcp_sync_mss()传入的pmtu等于 1500,IP 不包含任何 option,则__tcp_mtu_to_mss会得到1500-20-20=1460,如果 TCP 使能了 timestamp,则tcp_mtu_to_mss()会返回1460-(32-20)=1448

那么,tcp_mtu_to_mss()在什么时候被调用呢?

  • 对 TCP 主动端,它在连接初始化时会根据自身 mtu 设置 mss_cache
    tcp_connect_init
      |
      |-- tcp_sync_mss(sk, dst_mtu(dst))  
    
  • 对 TCP 被动端,它则在三次握手完成的时候根据 mtu 设置 mss_cache
    tcp_v4_syn_recv_sock:
      |
      |-- tcp_sync_mss(newsk, dst_mtu(dst)); 
    
  • 而在连接建立之后,如果 TCP 报文超过了传输路径上某个网络设备的 mtu,且报文设置了 DF (Don’t Fragment) 标记,则该设备会反馈一个 ICMP_FRAG_NEEDED 报文,并携带支持的最大 mtu 值。原来的报文发送端收到该 ICMP 报文后,变会调整自己的 mss_cache
void tcp_v4_err(struct sk_buff *icmp_skb, u32 info)
    |
    |-- dst = inet_csk_update_pmtu(sk, mtu);
    |-- tcp_sync_mss(sk, mtu);

通信双方是如何将 mss_cache 设为一致的

有了前面的铺垫,再来看所谓的 MSS “协商过程”就容易多了。

这里我用两台虚拟机作为 TCP 连接的双方,虚拟机网卡的默认 mtu 是 1500,而我将主动端虚拟机网卡 mtu 设置为 1399. TCP 默认开启了 timestamp 选项。

最终主动端(vm-2)和被动端(vm-1)的mss_cache都被设置成了 1347

  • 主动端:mss_cache = 1399(mtu) - 20(IP首部) - 20(TCP首部) - 12(timestamp) = 1347
  • 被动端:mss_cache = 1359(mss_clamp) - 12(timestamp) = 1347