计算机网络——运输层②

传输控制协议

本章中我们关注传输控制协议的几个要点。

TCP连接

TCP是面向连接的,这意味着两个程序在发送数据之前必须进行握手并建立连接,并初始化一些状态和变量。 TCP的连接是一条逻辑连接,和电路交换网络中的物理连接不同,其连接状态仅对端系统可见,对更低级的系统则不可见。

TCP提供全双工服务,即两个建立连接的主机可以同时发送和接收数据。 同时其服务是点到点的,同时只能服务两个端系统,而不存在多播。

我们将在之后仔细介绍TCP的连接,这里给出一个概述。 首先,客户端调用connectAPI试图与服务器发送连接,其系统发送一个特殊的报文段(SYN)至服务端; 服务端收到报文段后回复一个特殊的报文段(SYNACK); 然后客户端再发送一个特殊报文段,标志连接建立。 第三个报文段不同于前两个,其可以带有有效载荷,即应用层的报文。 这个过程称为三次握手(Three-way handshake)。

建立起TCP连接之后,套接字负责将所有应用层报文先保存再发送缓存中,然后在适宜的时机把缓存中的报文封装成报文段,然后交给下一层进行发送。 一个报文段的长度受制于最大报文段长度(Maximum Segment Size,MSS),而MSS受制于链路层的最大传输单元(Maximum Transmission Unit,MTU)。 通常TCP(运输层)和IP(网络层)的首部总共为40字节,而MTU通常为1500字节,因此MSS通常为1460字节。 这里可以注意到MSS虽然名字中含有报文段,但实际上是应用层报文的最大长度。

TCP为每一块数据配上一个TCP首部,从而形成多个TCP报文段,然后发送给网络层,网络层接着把它封装到数据报中,然后发送到网络中。 接收端收到报文段后,也把它放入接收缓存中,然后应用程序就可以读取这些数据了。 TCP中的每一端都有自己的发送和接受缓存,相对地,网络中两端之间的其他元素均不保存关于TCP的任何信息。

TCP报文段结构

根据RFC 793,TCP的报文段结构如下所示:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

源端口(Source Port)、目的地端口(Destination Port)、预留(Reserved)、校验和(Checksum)和填充(Padding)的用途和内容都是显然的。 值得注意的是校验和和UDP的算法相同,也是使用反码加法。 和此前介绍的可靠协议不同,TCP报文段既具有序号(Sequence Number)又具有确认号(Acknowledgment Number),我们将在此后仔细介绍这两个字段的内容和用途。 选项(Options)是可选和变长的,因此需要数据偏移(Data Offset,也称首部长度)来指定首部结束的位置。 接收窗口(Window)是流量控制的一部分,我们将在之后仔细研究。

值得注意的是六个标志位:

  1. 紧急(URG)表明紧急指针(Urgent Pointer)所处的位置有带外数据(Out-Of-Band),需要直接通知应用层,几乎不使用;
  2. 确认(ACK)表明该报文段的确认序号确实确认了另外的报文段;
  3. 推送(PSH)指示接收端立刻将数据推送到用户层,几乎不使用;
  4. 重置(RST)、同步(SYN)和结束(FIN)用于TCP连接管理,之后将仔细介绍。

TCP序号

需要注意的是,TCP中的序号并非报文段的标号,而是字节的标号。 假定一个TCP端以1000字节报文每报文段的方式分割要发送的内容,且首字节的序号为零,那么第一个报文段的序号为0,而第二个报文段的序号为1000。

确认号也按照相同的原则标号,而且使用累计确认语义。 与之前讨论的可靠数据传输协议不同,其ACK号表示下一个准备接受的字节标号,而不是最后一个正确接受的字节标号。 因此,如果接收方发送一个ACK 100的报文段,那么其实际接受了从起始标号到100这个左闭右开区间中的所有字节。

TCP标准并未给出处理失序报文段的方法,实现TCP的程序员可以自行选择丢弃所有提前到达的报文段或缓存以待使用。 除此之外,TCP的首字节标号可以不是零,这个起始标号在三次握手时协商确定。 采用随机而非零的标号可以降低出现先前连接的重复标号的概率。

估计往返时间与超时

TCP使用定时器来发现丢失的报文段,因此必须为定时器设定一个时间。 关于设定TCP重传定时器的标准,参见RFC 6298。 这个时间可以由往返时间得出,因此我们需要估计往返时间(RTT)。

TCP通过采样报文段的RTT(记为$SampleRTT$)来估计往返时间。 采样的RTT等于该报文段发出(交给IP层)到收到针对其的确认之间的时间。 大多数TCP实现使用定时采样,而非测量每个报文段的RTT。 同时TCP不采样重传的报文段,而只采样传输一次的报文段,因为对重传的报文段,不能区别收到的ACK是针对重传前的还是重传后的。

标准使用指数加权移动平均(EWMA)估计RTT,其值由一个离散动力系统给出: \(\left\{ \begin{aligned} EstimatedRTT_0 &= SampleRTT_0 \\ EstimatedRTT_n &= (1-\alpha) EstimatedRTT_{n-1} + \alpha SampleRTT_n \end{aligned} \right.\) 标准推荐$\alpha = 0.125$。

除了估计RTT,我们还需要估计RTT的变化。 RTT的偏差由估计RTT和实际RTT的差的指数加权移动平均给出: \(\left\{ \begin{aligned} DevRTT_0 &= 0 \\ DevRTT_n &= (1-\beta) DevRTT_{n-1} + \beta \left| SampleRTT_n - EstimatedRTT_n \right| \end{aligned} \right.\) 标准推荐$\beta = 0.25$。

在设置超时(Retransmission TimeOut,RTO)时,既要考虑到RTT,又要考量它的变化,因此推荐使用以下公式计算: \(RTO = EstimatedRTT + 4 \cdot DevRTT\) 此外,标准还要求采样前设置RTO为一秒且不得少于一秒,同时每发生一次超时就把RTO翻倍,直到进行一次新的估计。

可靠数据传输

此前,实现可靠数据传输时假定每个分组都有单独的定时器,然而,管理多个定时器的开销巨大,因此TCP只使用单个重传定时器。 我们仍保持只有一方发送数据的假设,先给出一个简化版的TCP发送端,然后逐步向内添加内容。

简化的发送方

TCP使用流水线进行数据传输,因此其也必须维护一个滑窗。 除此之外,也要响应四个事件:

  1. 初始化;
  2. 发包调用;
  3. 超时;
  4. 确认。

其伪代码如下:

// 初始化:设置滑窗的初始位置
int NextSeqNum = InitialSequenceNumber;
int SendBase = InitialSequenceNumber;
Timer timer;
timer.stop();

while(true)
{
    Event event = get_event();
    switch(event)
    {
    // 从应用层接受新的报文段
    case NEW_MESSAGE:
        // 生成序号为 NextSeqNum 的报文段
        segments[NextSeqNum] = new Segment(NextSeqNum, event.message);
        // 启动定时器
        if(!timer.isRunning())
            timer.start();
        // 向网络层交付报文段
        ip.send_segment(segments[NextSeqNum]);
        // 移动滑窗
        NextSeqNum += event.message.length();
        break;
    // 定时器超时
    case TIMEDOUT:
        // 重传尚未应答的最小报文段
        ip.send_segment(segments[SendBase]);
        timer.restart();
        break;
    // 收到ACK
    case ACKNOWLEDGEMENT:
        // 移动滑动窗口
        if(event.segment.ackseq > SendBase)
        {
            SendBase = event.segment.ackseq;
            // 如果所有报文段都已应答,则停止定时器,否则重启定时器
            if(SendBase == NextSeqNum)
                timer.stop();
            else
                timer.restart();
        }
        break;
    }
}

不难发现只有定时器未启动时,收到应用层的发送指令才会启动定时器,因此这个定时器实际上是和最早的未确认报文段关联的。 我们在这个发送端实现中还没有纳入RTO的估计和流量控制、拥塞控制等内容。

基于累计确认的重传有一个好处,即使靠前的ACK包丢失,靠后的ACK包的到达也可以避免重传。

在讨论接收端如何发送ACK之前,我们先来考虑RTO的计算。 在大部分TCP实现中,如果标准所推荐的,每次发生定时器超时事件时,不仅要重发最靠后的报文段,还要将RTO翻倍。 这种实现会使得RTO指数增长,同时提供了一定的拥塞控制。 定时器过期很可能是由网络拥塞引起的,而此时如果反复发送报文段不仅难以到达接收端,而且可能加重网络的拥塞程度。 通过将RTO翻倍可以降低重传的密度,从而降低可能的网络拥塞。 实际上以太网的CSMA/CD协议也使用了类似的方法。

接收方如何产生ACK

标准RFC 5681给出了最新的ACK生成建议,归纳为以下四条:

  1. 报文段按序到达,且所有该报文段序号之前的数据都已确认:等待至多500ms,然后发送该报文段的ACK;
  2. 报文段按序到达,且上一个报文段正在等待中:放弃上一个ACK,立刻发送刚刚接受的报文段的ACK,确认两个报文段;
  3. 报文段失序到达,即比期望序号大的报文段到达:发送带有期望序号的ACK,称为冗余ACK(Duplicate ACK)。
  4. 报文段到达,且新的报文段可以填充之前的失序报文段导致的空隙:
    • 如果新的报文段恰好位于空隙的低端(即其序号正好为期望序号),则立刻发送ACK;
    • 否则,什么也不做。

这个生成方式允许接收端保留失序的报文段(当然,并不一定保存在接受缓存中),并用新的报文填充失序报文产生的空隙。 如果接收端直接丢弃失序报文段,那么显然第四条规则就不适用了。

这种规则为至多两个报文段生成一个ACK,且ACK的生成是延时的,因此称为延时ACK(Delayed ACK)。 延时ACK的生成实际上是由RCF 1122提出的。

由于发送方一个接一个地发送大量报文段,如果一个报文段丢失,会导致大量的冗余ACK。 为此,TCP标准要求发送方在收到三个冗余ACK后立刻重传ACK要求的报文段,这一机制称为快速重传(Fast retransmit)。 需要注意,产生冗余ACK并不一定意味着有报文段丢失,其可能只是因为乱序而导致有报文段后到达。 因此,如果一发现冗余ACK就重传,会导致带宽的大量浪费。 实际上,快速重传机制和拥塞控制机制有紧密的联系。

使用快速重传的代码如下:

case ACKNOWLEDGEMENT:
    // 移动滑动窗口
    if(event.segment.ackseq > SendBase)
    {
        SendBase = event.segment.ackseq;
        // 如果所有报文段都已应答,则停止定时器,否则重启定时器
        if(SendBase == NextSeqNum)
            timer.stop();
        else
            timer.restart();
    }
    else
    {
        DuplicateCounter[event.segment.ackseq]++;
        if(DuplicateCounter[event.segment.ackseq] == 3)
        {
            // 快速重传
            ip.send_segment(segments[event.segment.ackseq]);
            DuplicateCounter[event.segment.ackseq] = 0;
        }
    }
    break;

整个TCP的ARQ协议是GBN风格的: 其只在发送端维护滑窗,也不为每个报文段维护单独的定时器。 然而,和GBN协议不同,其并不在定时器超时时重发所有滑窗内的报文段,而是仅仅重发SendBase指向的那一个。 由于累计确认的特性,如果更后面的报文段的确认到达,其甚至连一个报文段都不重发。

RFC 2018中,提出了一种称为选择确认的改进,允许接收端选择性地确认失序的报文段,而不总是仅仅确认最后一个正常的报文段。 这个改进使得TCP更像SR协议。

流量控制

我们此前已经介绍过,TCP在实现时需要在两端维护发送和接收缓存。 然而缓存的大小是有限的,TCP通过流量控制服务(Flow-control service)来防止缓存区溢出。 这和拥塞控制不同,后者用来防止IP网络拥塞的。 虽然两者针对的问题不同,其都是通过制约发送方实现的,因此容易混淆。

TCP让发送方维护接收窗口(Receive WiNDow)来进行流量控制。 由于TCP是全双工的,实际上两端都要维护接收窗口。

假设接收端为接收缓存申请了$\mathit{RcvBuffer}$字节的空间,我们额外维护两个变量:

  1. $\mathit{LastByteRead}$:表示应用进程从缓存读出的最后一个字节的编号;
  2. $\mathit{LastByteRcvd}$:表示网络中已经到达并接受入缓存中的最后一个字节的编号。

那么缓存不溢出表示: \(\mathit{LastByteRcvd} - \mathit{LastByteRead} \le \mathit{RcvBuffer}\) 我们使用$rwnd$表示接受窗口,和RFC中保持一致,那么其值为: \(\mathit{rwnd} = \mathit{RcvBuffer} - \left( \mathit{LastByteRcvd} - \mathit{LastByteRead} \right)\) 显然,随着新的报文段被TCP接受,$\mathit{rwnd}$会随时间变化,而且除非清空缓存否则是随时间单减的。 这个值被填充到报文段首部的窗口字段中,然后随着其他报文段发送至发送端。

类似接收端,发送端也要维护两个变量:

  1. $\mathit{LastByteSent}$:表示发送端发送的最后一个字节的编号;
  2. $\mathit{LastByteAcked}$:表示发送端能确认接收端收到的的最后一个字节编号,这个编号实际上是最大的ACK编号减一。

显然,这两个变量的差值$\mathit{LastByteSent} - \mathit{LastByteAcked}$就是已发送到连接中而尚未接受的数据量,发送方应当确保这个值小于$\mathit{rwnd}$。 即,为了保证接收端的缓存不溢出,发送端必须保证: \(\mathit{LastByteSent} - \mathit{LastByteAcked} \le \mathit{rwnd}\) 通过发送方节流可以比较容易做到这一点。

然而,如果不进行缓存清空,则$\mathit{rwnd}$是不会增长的,当其值到达零时,由于上述不等式的要求,发送方不能发送任何数据。 而如果进行缓存清空,则在进行清空时不得不丢弃到达的数据。 为此,TCP协议要求$\mathit{rwnd} = 0$时发送方只能发送一个字节载荷的报文段,从而不需要缓存。 而最终会接收方进行缓存清空,从而腾出$\mathit{rwnd}$。

相对于TCP,UDP不提供流量控制,因此如果发送的速度过快,缓存会溢出而报文段将丢失。

更新时间: