时间戳格式

时间戳(TimeStamp—TSOPT)选项格式如下:

1
2
3
4
5
6
//           Kind: 8
// Length: 10 bytes
// +-------+-------+---------------------+---------------------+
// |Kind=8 | 10 | TS Value (TSval) |TS Echo Reply (TSecr)|
// +-------+-------+---------------------+---------------------+
// 1 1 4 4

TSval 表示发送端发出该报文时的本地时间戳, 而 TSecr 则负责回放 (Echo) 最近一次收到的对端报文中的 TSval 的值。下面是一组典型的时间戳交互过程:

1
2
3
4
5
6
7
8
9
//                   TCP  A                               TCP B
//
// <A,TSval=1,TSecr=120> ----->
//
// <---- <ACK(A),TSval=127,TSecr=1>
//
// <B,TSval=5,TSecr=127> ----->
//
// <---- <ACK(B),TSval=131,TSecr=5>

启用 Timestamp 选项需要经过双方的协商,协商在三次握手时完成如果协商成功,则在后续的报文中, 除了 RST 之外的所有报文均必须包含 Timestamp 选项,若未协商成功,即使后续报文有 Timestamp 选项也一并忽略。

时间戳作用

在RFC1323中,TSOPT主要有两个用途一个是 RTTM (round-trip time measurement)即根据 ACK 报文中的这个选项测量往返时延,另外一个用途是 PAWS (protect against wrapped sequence numbers),即防止同一个连接的系列号重叠。另外还有一些其他的用途,如 SYN-cookie、 Eifel Detection Algorithm 等等。下面详述前两个用途。

RTTM

对此,时间戳不能走得太慢,这是为了能更准确地测量报文的 RTT。假设这个时钟 10s 才 tick 一下,那么对于 往返时间为 1s 的 TCP 连接,一端发送报文之后,很有可能会发现收到对端的 ACK 报文中的 TSecr 和当前时钟 的值是一样的,这说明 RTT 为 0 ! 显然,这是十分荒谬的。需要说明的是,RTT 的测量不需要时间戳也能进行,只是使用时间戳可以解决 RTTM 中某些棘手的问题(区分重传报),参见另一篇文章:RTT的测量

TSOPT如何测量RTT?

一般而言,客户端和服务端会互发数据和确认,但此处为了更清楚地说明问题,我们只考虑客户端发送数据,服务端回应 ACK 报文。于是有以下过程:

1
2
3
4
5
6
7
8
9
//           客户端 A                               服务端 B
//
// <A,TSval=1> ----->
//
// <---- <ACK(A),TSecr=1>
//
// <B,TSval=5> ----->
//
// <---- <ACK(B),TSecr=5>

客户端如此测量 RTT :将本端此刻时间戳填入 TSOPT 选项中的 TSval 部分,并发送报文 A ;服务端接收到报文 A 后,将 TSval 部分的值放入 ACK 报中的 TSecr 部分并发送;客户端接收到 ACK 报文后取出 TSecr ,并将此刻时间戳减去 TSecr ,即得到 RTT 。通常情况下,发送 ACK 报文时只需要无脑填入对端上一个报文的 TSval 就行了,但有几种特殊场景需要注意:

  1. Delay ACK(延迟确认):

    延迟确认有两大好处:1.减少网络中 ACK 包的数量(最多延迟一个包 ),以减小网络拥塞;2.如果接收端刚好也有数据发送,则可以将数据和 ACK一起发送(数据捎带确认),减少了 pure ACK 的数量。

    如果启用了 Delay ACK, 并且接收端收到了多个报文,这些报文的 TSval 不同,那么应该 Echo 哪一个报文的 TSval 呢?答案是:需要 Echo 最早收到的那个报文的 TSval , 因为只有这样,发送端测量的 RTT 才更加准确(更保守)。本地会维护一个 TS.recent 变量,其用来保存下一个填入 TSecr 的时间戳,当需要发送报文时,报文的 TSecr 始终从当前 TS.Recent 获得。

    1
    2
    3
    4
    5
    6
    7
    //延迟确认下:                                       TS.Recent   
    // <A, TSval=1> -------------------> 1
    //
    // <B, TSval=2> -------------------> 1
    //
    // <---- <ACK(B), TSecr=1> 1
    //可见,echo的是最早发送的报文
  2. 乱序或丢失:

    发送端发送了多个报文,但中间有报文出现了丢失或者乱序,这会使得接收端的窗口产生空洞 (即在未收到序号较小的报文时,先收到序号较大的报文)。这种情况可能预示着链路发生了拥塞,因此,此时也会让接收方 Echo 稍早时候的 TSval ,而不是序号最大报文的 TSval , 这样使得发送端估算的 RTT 能偏大,也就是发送报文更保守,有利于减小拥塞。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /*
    发送顺序:A->B->C->D->E
    接受顺序:
    TS.Recent
    <A, TSval=1> -------------------> 1

    <---- <ACK(A), TSecr=1> 1

    <C, TSval=3> -------------------> 1

    <---- <ACK(A), TSecr=1> 1

    <B, TSval=2> -------------------> 1->2

    <---- <ACK(C), TSecr=2> 2

    <E, TSval=5> -------------------> 2

    <---- <ACK(C), TSecr=2> 2

    <D, TSval=4> -------------------> 2->4

    <---- <ACK(E), TSecr=4> 4
    */

    注意第 11 行,ACK 报文中的 TSecr = 1 而不是 3 ,原因是:1.使 RTT 偏大,保守计算;2.如果发送 3 ,则 TS.Recent 改为 3 ,那么接收到 B 包时,会计算TSecr = 2 < TS.Recent = 3 ,从而判断 B 是历史报文而导致错误丢弃。

PAWS

由于序号占 32 位,范围为 00 ~ 23212^{32}-1 ,即最大 4 个 GB 。在一般网络下(假定 1 MB /S),那么循环一圈序列号所花费的时间约为 1.19 小时,而 MSL (报文最大生存时间)为 30 秒(Linux),所以一般情况下即使是发生序列号回绕也不会接收到历史报文。但在高速网络(千兆网)下,可能循环一圈只需要十几秒甚至几秒,此时网络中的历史报文就有较大概率落入新连接的窗口(窗口最大 1 GB),从而造成数据错乱。由此,还需要引入 时间戳 来解决此问题。

举个例子,假设发送端的序号从 1 开始计数,此时已经发送了 232+49992^{32}+4999 字节的数据,当前接收端的 RCV.NXT = 5000, TS.Recent = 100 , 如果收到一个报文 Seq = 5000 , LEN = 1000 , TSval=70 如果不看时间戳,那么接收端会认为这个报文正好是预期的报文,但是这个报文实际上却是第 5000 ~ 5999 字节的报文;而有了时间戳,之间根据 TSval < TS.Resent 就可判断此报文已过期。

时间戳的时间部分占32位,不也会用完并回绕吗?

是的,时间戳也会回绕,不过需要时间戳是以时间为驱动进行增长(1ms,实现不同),而序列号是以字节为驱动进行增长。按照 1ms 的时间戳时钟计算,32-bit 的时间戳回绕一次的周期是 24.8 天, 而实际上连接实际不会这么长,所以远不用担心时间戳回绕。

时间戳还有什么作用?

  1. 当开启时间戳后,即使在 TIME-WAIT 状态下也允许建立连接。参考 此处
  2. 时间戳可以用于 SYN Cookie 中。参考 此处
  3. RACK 重传
  4. Eifel 探测算法

文章参考:TCP 08时间戳那点事SYN CookieRTT序列号回环