计算机网络——运输层②
传输控制协议
本章中我们关注传输控制协议的几个要点。
TCP连接
TCP是面向连接的,这意味着两个程序在发送数据之前必须进行握手并建立连接,并初始化一些状态和变量。 TCP的连接是一条逻辑连接,和电路交换网络中的物理连接不同,其连接状态仅对端系统可见,对更低级的系统则不可见。
TCP提供全双工服务,即两个建立连接的主机可以同时发送和接收数据。 同时其服务是点到点的,同时只能服务两个端系统,而不存在多播。
我们将在之后仔细介绍TCP的连接,这里给出一个概述。
首先,客户端调用connect
API试图与服务器发送连接,其系统发送一个特殊的报文段(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)是流量控制的一部分,我们将在之后仔细研究。
值得注意的是六个标志位:
- 紧急(URG)表明紧急指针(Urgent Pointer)所处的位置有带外数据(Out-Of-Band),需要直接通知应用层,几乎不使用;
- 确认(ACK)表明该报文段的确认序号确实确认了另外的报文段;
- 推送(PSH)指示接收端立刻将数据推送到用户层,几乎不使用;
- 重置(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使用流水线进行数据传输,因此其也必须维护一个滑窗。 除此之外,也要响应四个事件:
- 初始化;
- 发包调用;
- 超时;
- 确认。
其伪代码如下:
// 初始化:设置滑窗的初始位置
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生成建议,归纳为以下四条:
- 报文段按序到达,且所有该报文段序号之前的数据都已确认:等待至多500ms,然后发送该报文段的ACK;
- 报文段按序到达,且上一个报文段正在等待中:放弃上一个ACK,立刻发送刚刚接受的报文段的ACK,确认两个报文段;
- 报文段失序到达,即比期望序号大的报文段到达:发送带有期望序号的ACK,称为冗余ACK(Duplicate ACK)。
- 报文段到达,且新的报文段可以填充之前的失序报文段导致的空隙:
- 如果新的报文段恰好位于空隙的低端(即其序号正好为期望序号),则立刻发送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}$字节的空间,我们额外维护两个变量:
- $\mathit{LastByteRead}$:表示应用进程从缓存读出的最后一个字节的编号;
- $\mathit{LastByteRcvd}$:表示网络中已经到达并接受入缓存中的最后一个字节的编号。
那么缓存不溢出表示: \(\mathit{LastByteRcvd} - \mathit{LastByteRead} \le \mathit{RcvBuffer}\) 我们使用$rwnd$表示接受窗口,和RFC中保持一致,那么其值为: \(\mathit{rwnd} = \mathit{RcvBuffer} - \left( \mathit{LastByteRcvd} - \mathit{LastByteRead} \right)\) 显然,随着新的报文段被TCP接受,$\mathit{rwnd}$会随时间变化,而且除非清空缓存否则是随时间单减的。 这个值被填充到报文段首部的窗口字段中,然后随着其他报文段发送至发送端。
类似接收端,发送端也要维护两个变量:
- $\mathit{LastByteSent}$:表示发送端发送的最后一个字节的编号;
- $\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不提供流量控制,因此如果发送的速度过快,缓存会溢出而报文段将丢失。