文章目录
  1. 1. 浅层分析
  2. 2. 继续分析
  3. 3. 猜想与验证
  4. 4. 新的问题
  5. 5. 新的猜想和验证
  6. 6. 解决

引言:昨天群友为 mmp-go 加入了 TFO,使得其理论上可以降低一个 RTT,但在使用过程中发现时延并没有减少。使用 clash 的延时测试观察到 mmp-go 的首包单独有一个 50 字节的 TCP REQ 和 ACK,导致浪费一个 RTT,且呈现明显特征。而关闭 TFO 时表现正常,不会出现和正常实现相比时延增加的现象。那么到底为什么会出现这种情况呢?


浅层分析

在代码中 mmp-go 会单独读 50 字节的长度并 write,随后再进行 io.Copy,那么就是这个单独的 write 导致了一个 RTT。

但如何修复呢?

一个浅显的办法是从 50 字节里读出这个 chunk 的大小,然后使用 read 继续读够这个长度并一起发送出去就行了。

但事实真的如此吗,这样就可以保证特征一致了吗?这个问题的关键在于这个单独的 RTT 是怎么形成的,也就是一个 TCP 报文段究竟是如何形成的。

继续分析

一个最直观的方法就是,如果我能知道一个 TCP 报文段的长度,我就可以刚刚巧巧读取这个报文段,并且进行转发,就可以在抓包层面上保持特征一致。

首先使用 wireshark 观察 TCP 报文段,可以看到 wireshark 是可以清楚给出每个 TCP 报文段的长度的。

回头看看 TCP 报文头,发现报文头并没有任何的长度信息,那么 wireshark 是如何得到一个报文段的长度的呢?

根据网上查找的资料,一个 TCP 报文段的长度可以由前后两个报文段的 sequence number 相减得到。但仔细想想,这个方法并不能解决我们的问题,如果我能知道下一个报文段从哪开始,我还需要得到这个报文段的长度做什么。

那么究竟 wireshark 是如何得到这个报文段的长度的呢?

猜想与验证

一个猜想是,如果一个 IP 数据报最多只包含一个 TCP 报文段,那么我们拿到一个 IP 数据报就可以知道 TCP 报文段的长度了,因为 IP 数据报的首部包含了总长度。

那么我们必须弄清楚以下几个问题:

  1. 一个 IP 数据报中会不会包含多个 TCP 报文段?
  2. TCP 报文段的粘包现象是怎么回事?

这两个问题是相互联系的。TCP 使用 Nagle 算法会导致粘包,也就是两个或多个 TCP 报文段可能会合并。那么粘包究竟是将两个 TCP 报文段放在一个 IP 数据报中,还是两个 TCP 报文段合并后变成一个,只有一个 TCP 报文头呢?

为了解决这个问题,首先观察 TCP 报文头,发现它没有任何定界方法(例如链路层所学到的几种帧定界方法)以分离两个连续的 TCP 报文段,再辅佐网上的资料可以确定,一个 IP 数据报最多包含一个 TCP 报文段。

也即是说,一个 IP 数据报要么包含一个 TCP 报文段(有可能是多个 TCP 报文段粘包合成的,但没关系,它在抓包层面上仍然是一个 TCP 报文段),要么包含不到一个 TCP 报文段(多个 IP 数据报拆包传输一个 TCP 报文段)。 这说明我们离解决问题更近了一步。

新的问题

我们知道了一个 IP 数据报包含小于等于一个 TCP 报文段,我们就可以有办法知道一个 TCP 报文段的长度,从而刚巧读取一个 TCP 报文段(一个 sequence number),避免抓包层面上的特征。

如何读取一个 IP 数据报?我们需要这么复杂吗?

那我们需要清楚下面这个问题:

golang 中 net.Conn.Read 是怎么工作的,如果不遇到 io.EOF,它到底读多少算结束?

我们知道,Read 有两个返回值,一个是 n,代表读出长度,一个是 err,代表读取错误。我们如果调用 Read,有可能读出 err == nil 的情况就结束了,而不是读到 err == io.EOF 再结束。

那么读到 err == nil 的情况是以什么为结束呢?难道是 50ms 内没有数据传输就算结束吗?

Conn.Read 的代码非常凝练,到最后关键的是一个 syscall.Read,它执行一个 SYS_READ 的系统调用,对一个文件描述符读出一行。

到这里问题似乎已经到了尾声了,我们知道,我们监听的是一个 TCP 的套接字,根据网络栈下层对上层透明的原则,我们可以大胆猜测它的文件描述符中的一行代表的就是一个 TCP 报文段。

MACv2(以太网v2):有效帧长64-1518字节,首部18字节。

IP数据报:MTU(最大传输单元,Maximum Transmission Unit)65535字节。这是理论值,通常受限于链路层有效帧长。

TCP报文段:MSS(最大报文段大小,Max Segment Size)一般取1460字节,用以太网最大数据长度1500字节减去IP数据报首部20字节,再减去TCP首部20字节。

为了读取一个 TCP 报文段,Read 的缓存应该开的尽可能大。那么究竟需要大到什么程度呢?显然,直觉上讲,大到一个 MSS 即可。但问题真的就要结束了吗?

实践上我们通过 Read 是肯定可以读到超过 MSS 的数据的,这究竟是怎么回事呢,难道我们读的不是一个报文段吗?下面通过抓包来观察。

MSS 一般在 TCP 三次握手阶段确定。下面是通过 wireshark 抓到的一条 TCP 连接的首包和某个数据包:

1
2
3
192.168.0.2	xxx.xxx.xxx.xxx	TCP	74	59664 → 5201 [SYN] Seq=0 Win=64240 Len=0 MSS=1460 SACK_PERM=1 TSval=1600104048 TSecr=0 WS=128

xxx.xxx.xxx.xxx 192.168.0.2 TCP 74 5201 → 59664 [SYN, ACK] Seq=0 Ack=1 Win=65160 Len=0 MSS=1332 SACK_PERM=1 TSval=2066368794 TSecr=1600104048 WS=128

可以看到 MSS=1460 和 MSS=1332。

1
2
3
4
5
6
7
8
Frame 58636: 7306 bytes on wire (58448 bits), 7306 bytes captured (58448 bits) on interface enp7s0, id 0

Ethernet II, Src: IETF-VRRP-VRID_03 (xxx), Dst: Giga-Byt_xxx (xxx)

Internet Protocol Version 4, Src: xxx.xxx.xxx.xxx, Dst: 192.168.0.2

Transmission Control Protocol, Src Port: 5201, Dst Port: 59664, Seq: 78773305, Ack: 38, Len: 7240
Data (7240 bytes)

但后续的一个 TCP 报文段的数据仍然有 7240 个长度。是什么导致了这个问题呢?是我们的猜想错了吗?

新的猜想和验证

新的问题是,MSS 究竟起到作用了吗?不考虑缓冲区长度,Read 一次读到的真的是一个报文段吗?

根据 StackOverflow 上的讨论 [1][2](有一些令人迷惑的解释,注意甄别),以及 rtoodtoo 的博客中对此问题的研究,这是 GSO/TSO 的问题。

Normally TCP segmentation is handled by the host CPU with which wireshark displays reasonable lengths. However if segmentation is handed over to network adapter, host machine instead of doing segmentation itself, it sends chunk of segment to network adapter for segmentation at which wireshark captures this transmission and displays a header length which you don’t expect. TSO is definitely an improvement on host resource usage but for demonstration and testing it really makes things difficult.

这里有篇文章详细介绍了它的原理 Segmentation and Checksum Offloading: Turning Off with ethtool

也就是说,不仅 MSS 起到作用了,Read 读到的也是报文段(拼合之后的)的长度。两者却一点都不矛盾。

那既然如此,在开启 GSO/TSO 之后,网络传输中的每个 TCP segments 在接收方又是如何拼合回来的呢?

通过 wireshark 抓到的 TCP segments 具有不稳定长度的特征,不妨大胆猜测,由于 TCP 具有流的特征,它并不需要保证界限,合就完事了!滑动窗口满足交付条件时,直接把 buf 取出来合就是了。不需要复原成发送方发送时的样子。但我懒得验证了。

需要注意的是,GSO/TSO 操作并不会对发送方流出网口的数据包造成什么影响,因为只是把拆分操作从 CPU 侧变到了 NIC 设备侧进行罢了。

这种拆分合并操作并不会带来接收方 ACK 上的问题。由于 TCP 采用滑动窗口协议,接收方不需要挨个回复发送方 TCP 报文段形成时的每一个序号,只需要回复最后的一个序号即可。

关闭 TFO 之后不会出现新增的一个 RTT 的现象也解释得通了: 因为开启 TFO 之后首包单独放在第一次握手里,想合并也没办法合并。所以本来该一次发送走的应用层握手(并携带了数据)被硬生生拆成了两个。 和没开 TFO 的时候一样,服务端都是拿到了完整的应用层握手包时才开始响应(也就是 TCP 三次握手结束时)。

那么造成这个问题的究竟是 GSO/TSO,还是 TFO 呢?答案是 TFO。

当 TFO 启用时,无论 GSO/TSO 是否开启,第一个 write 的数据(该实现下的 50 字节)都会放在 SYN 包中,从而导致上述问题。

那么 TFO 关闭时,该实现会不会有风险呢?答案是特定情况下是可能的。

当 TFO 关闭时,无论 GSO/TSO 是否开启,数据包都是第三次握手结束后才开始发送的,但是由于握手的这段时间剩下的 TCP 报文段也积累起来了,得益于 Nagle 算法,TCP 报文段发生了粘包合并,从而并不会有这 50 字节的单独报文段。而 Nagle 算法 Linux 中是默认开启的。只有当 Nagle 算法关闭时才会有较大风险。

解决

所以我们兜兜转转绕了这么一圈,得出以下结论:

  1. 为了避免小的 TCP 报文段的形成,每次进行 write 的时候尽量完整,这样也可以更好地利用 TFO 等降低 RTT 的算法。
  2. 底层的拆包和合包较为灵活和复杂,我们可以认为它们是“透明的”,只考虑应用层之间的逻辑一致性即可。不需要关心是否要读一个 MSS 之类的问题,因为我们所拿到的 TCP 报文段的长度可能已经是 Nagle/GSO/TSO 等算法处理之后的 TCP 报文段了,MSS 没有意义。当然,为了更好利用 TFO,首包越大越好。不过受限于应用层协议和 TFO 首包大小限制,这个首包想大也大不了多少。
  3. 所以最终解决方案就在“浅层分析”一节,事实也确实就是如此。
文章目录
  1. 1. 浅层分析
  2. 2. 继续分析
  3. 3. 猜想与验证
  4. 4. 新的问题
  5. 5. 新的猜想和验证
  6. 6. 解决