本课程为MIT 6.829 计网课程,课程对应官网链接: Computer Networks Lecture Notes
本节对应课程文档链接: Lecture 2: The Internetworking Problem
本节课我们会学习网络互联中遇到的问题,包括互联网的设计原则,IP,互联网尽力而为(best-effort)的服务模型等。
之后我们将讨论IP和TCP的区别,并学习TCP是如何完成可靠数据传输。对于TCP,我们首先会看TCP的ACK机制,之后会看两种形式的丢包重传:
前者依赖于预估TCP连接的round-trip time(RTT)来设置超时,而后者依赖于接收到数据流中更靠后的包来避免等待超时。数据驱动重传的例子包括了快速重传(fast retransmission)和选择性确认(selective acknowledgment,SACK)。
上一节课,我们讨论了packet switching的原理,并且设计了一种方案来将多个LAN连接在一起。这里的核心在于一个叫做switch的设备,它会在不同的LAN之间转发packet。
尽管这样的网络可以工作并且也很常见,它并不能用来构建一个覆盖全球的网络基础设施,因为它有两个主要的缺陷:
以上每一点都值得讨论。
首先以太网绝不是世界上唯一的链路层技术,实际上,在以太网之前,packet已经在其他诸如无线射频,卫星信号,电话线等链路上传输。其次,以太网也通常不适用于几百公里的长距离传输。
现实中,各种新的数据链路层技术一直在出现,并且会持续出现,而我们的目标就是确保任何新的数据链路层技术都可以接入到全球网络基础设施中。这就是为什么互联网需要能容纳网络异构性。
不同网络之间的差异,可能包含以下方面:
我们在L1学习的LAN switching时,要求switch存储网络中每个host(实际上是每个网卡)的状态,如果switch中没有目的地址的状态,它会退而求其次将packet广播到整个网络中的所有链路上。这种工作方式甚至在中等规模的主机数和链路数上都无法工作。
从规模角度考虑,我们的目标就是尽可能的减少由switch维护的状态,和减少由控制流量消耗的带宽(例如路由信息,定期广播的消息等)。
所以,网络互联的问题或许可以这样描述:设计一个支持大规模的的网络基础设施,它能互联多个不同的小的网络,可以使得位于差异很大的网络上的主机之间能传递packet。
通过叫做gateway的设备可以互联不同的网络(这里的Gateway就是实际中的各种Router)。Gateway可以连接具备不同特性的网络并在它们之间传递packet。Gateway有至少两种工作方式:翻译(translation)和统一的网络层(a unified network layer)。
基于翻译的Gateway会尽可能无感的存在,它会将packet header和对应的协议从一个网络翻译到另一个网络。这里的例子有OSI X.25/X.75 协议。翻译存在的主要问题包括:
互联网的设计者很早就发现了翻译网关的缺陷,并且认为互联不同网络的正确方法是在所有网络中标准化一些关键属性,并且定义少量的特性,让所有想连接到互联网的主机和网络都必须实现这些特性。
时至今日,互联网已经较为成熟,我们在查看整个互联网的设计和协议时,已经可以识别并总结出藏在其中一些重要的设计原则。我们可以将这些原则分为两类:通用性原则(universality)和健壮性原则(robustness)。
互联网并非没有缺点,很多缺点都是源自其最初设计的目标。
互联网基础设施的标准设立和开发的过程是一个有趣的话题。总的来说,许多来自不同组织的人朝着共识进行努力,作为这已经成为了一个非常成功的社会工程学的实践。作为一个自发的组织,IETF(Internet Engineering Task Force),是设立互联网标准的主体。它会每4个月,按照working group的结构开一次会。这些working group从开始到终止通常都少于2年。互联网标准被记录在RFC(Request For Comments),并由IETF发布。可以查看http://www.ietf.org/获取更多信息。
Working group通过电子邮件完成大量的工作,并且对所有感兴趣的人开放。大体上来说,Working group避免了基于提案的投票,而是采用”大体一致“的流程。过去对于一个协议需要至少两个独立的实现,但是现在看起来这个要求被忽略了。
Cerf和Kahn在他们1974年的论文中描述了他们对于互联网问题的解决方案。这份早期的工作有着巨大的影响,所以尽管今天互联网的实现细节与之非常不同,我们仍然要学习它。不过论文中还是有一些设计原则保留至今。另外,这是一篇极好的论文,它描述了一个具备足够细节的设计,使得完全可以通过代码将它描述的规范实现出来。
下面是这篇论文的一些关键点:
网关。不在网关上做翻译。
IP over everything
每个网络的内部属性可以不相同,但是整个互联网的地址格式需要是统一的。
统一的packet header,但是这篇论文将一些传输层信息放到了用来路由的header中(也就是常规的网络层),并且缺失了一些字段,例如TTL。
网关在必要的时候执行分片,终端主机负责重新组装packet。
TCP:进程通过TCP进行通信,TCP是一个可靠的顺序的字节流协议。实际上,他们提出用TCP来完成分片的组装(而不是现在在接收端的IP层完成组装)。在他们的论文中,TCP和IP紧密的结合在一起。过了好几年,TCP和IP才开始逐渐分开。
他们花费了很多时间来确定同一组主机上的多个进程如何通信。他们考虑了两个选项:
对于多个TCP segment(TCP segment与Packet,Frame本质上都是对包的描述,只是从不同的协议层为视角出发)使用相同的IP packet
对于不同的TCP segment使用不同的IP packet
但是他们并没有考虑在一组主机间建立多个TCP连接!
第2个选项更好,因为它更容易复用,且在第1个选项中更难处理乱序的packet。
通过端口号来区分多路复用(一般一个进程对应一个端口)
TCP基于字节流。
滑动窗口,基于ACK的ARQ协议(同时带有timeout)以达到可靠性和流控。类似于CYCLADES和ARPANET协议。
基于窗口的流控。可以是进程粒度的流控。
I/O处理,TCB(Transmit Control Block)的实现细节和建议。
第一次提出连接建立和释放的机制(虽然之后经过大幅修改才到了今天的样子)。
在审计的问题上错误较大(问题比他们预测的要难得多)。
一句话总结:一篇非常好的论文,具备了实现一个互联网协议(对标现在的TCP/IP协议)的许多细节。细节多到可以实际实现出来这个协议,但同时又不是一个枯燥的文档。
有关IP协议以及其地址策略最重要的部分在于,它是为大规模场景设计的。这是通过一个简单的想法来实现:IP地址并不是任意的,而是表明了它们在网络拓扑中的位置。这个想法使得IP地址可以聚合在一起,并且维护在路由表中,最终达到一个层级的结构。激进的路由聚合使得当网络规模变大时,路由表的规模不会相应的变得很大。
当IP协议的第4版1981年在RFC791中确定时,地址被定为32bit长。并且地址被分为不同的类,各个组织和机构可以获取某一类的地址。不同的地址分类中,地址的前半部分bit对应网络地址,后半部分bit对应主机地址。
但是实际中很快就发现这种网络/主机的两级地址结构不够用。在1984年,IP地址中增加了第三级叫做”subnet“。Subnet可以有任意的长度,并且由一个32bit的网络掩码(netmask)来确定其长度。要查看一个地址是否属于一个subnet,将IP地址与网络掩码进行与操作,结果如果等于subnet,那么地址属于subnet。网络掩码的唯一的限制是bit1必须连续出现在前半部分,bit0必须连续出现在后半部分。
在1990年代早期,IPv4地址就开始出现用尽的迹象(尽管现在还没有真正的用尽)。随后,IETF发现将IP地址按照分类划分是低效的,所以部署了CIDR(发音成cider,也就是网络地址可以是任意长度)。这使得路由表的尺寸呈爆炸增长,因为越来越多的组织使用了非连续的C类地址(也就是 /24长度的CIDR)。在实际使用中,A类地址很难从IANA(Internet Address Number Authority)申请到,因为它有太多的主机地址(2^24
个)。而C类地址只有256个主机地址,通常又不够用。所以B类地址被用的最多,但是B类地址只有2^14=16384个,所以B类地址很快就被用光了。
CIDR可以优化通用的场景。通常来说,大部分组织需要使用最多几千个地址,不用分配一个B类地址,几个C类地址就足够了。如果这些C类地址是连续的,就可以减少路由表的尺寸,因为路由器会基于IP地址前缀聚合路由。我们之后在讨论路由协议的时候会讨论路由表的聚合。
不同的网络并不总是有相同的MTU(Maximum Transmission Unit)。当一个网关(也就是常见的router)收到一个packet,并且需要转发到一个有着更小MTU,且MTU小于packet大小的网络时,它有以下选择:
将packet丢弃。实际上,如果发送端在IP header中能设置了不能分片,IP协议规定就是丢弃packet。丢包之后,网关会向发送端发送一个基于ICMP协议的错误消息。
将packet分片。IPv4的默认行为是会将packet分片到MTU大小,并将每个分片发送到目的端。在IP header中包含了packet ID和offset,这样接收端可以根据这些信息重新组装分片。
为了避免packet陷入无尽的循环,IP header中有一个TTL字段。它通常会在每个router减一,一旦当TTL等于0,packet会被丢弃。
IP header中有8bit的TOS字段,router可以根据这个字段来区别对待packet。TOS现在并没有很多应用,不过我们之后会看到DSCP是如何使用这个字段。
为了将收到的packet解析到更高的协议栈层(通常是传输层),IP header中会包含8个bit表明下一层的协议类型。
16bit的校验和,以判断header是否被破坏了。
IP协议使得节点可以在header中增加option。虽然有各种option被定义了并且有些option是真的有用,但是鲜有人使用IP option,因为在快速路径处理IP option对于高速router来说是个代价很高的操作。虽然大多数的IP option是为了让终端主机使用,但是IPv4协议强制了所有的router也要处理所有的option,即使router需要做的只是忽略它们。如果考虑到IP option,最终会使得router变得很慢,所以这导致了option不被人使用。
现在的工程实践是避免使用option,并且router有时会查看传输层header(例如TCP/UDP 端口号,基于5元组的各种算法都需要用到传出层端口号),这些传输层字段都在固定的位置,只需要很少的解析,但是加上IP option之后情况会变得复杂。
一个尽力而为(best-effort)的网络可以很大程度的简化网络的内部设计,但是又意味着从发送端送出的packet并不总是能到达接收端。在这个场景下,TCP解决了3个问题:
很多应用程序,包括文件传输,Web等,需要可靠的数据传输,以及接收端能以发送端发出的顺序接收数据。这些应用程序可以从TCP获得收益。
TCP是一个顺序的、可靠的、双向的字节流。它并不是将packet作为最小单元,而是将字节作为可靠性传输的最小单元。TCP是在两个主机(实际上是在两个接口)之间的传输数据。这里的双向是指,一个TCP连接可以同时处理双向的可靠数据流。
通常来说,可靠传输协议可以使用至少一种下面的技术,以在丢包时达到可靠性(注,详见L0):
在TCP中,接收端会定期的通过ACK提示发送端它收到了什么样的数据。
现在的TCP协议中,接收端会采用一种叫做delayed ACK的策略,如果TCP连接中持续的有数据流,这个策略会每2个packet回一个ACK,但是不管怎么样,至少要在500ms回复一个ACK。基于BSD实现的TCP,这里的定时器是200ms。
TCP的ACK是累积的,举个例子,如果接收端收到了下面的字节序列:
在每个ACK里都会确认,截止到目前为止在TCP字节流中收到的所有字节数(比如第二个ACK确认了1700个字节),并告诉发送端自己期望收到的下一个字节序列号(所以ACK的是1700 + 1 = 1701)。
每个TCP的ACK中都会包含一个接收端的窗口,以告诉发送端当前自己的socket buffer中还有多少可用的空间。这在端到端的流控(flow-control)中是有用的。但是不要跟TCP的拥塞控制(congestion control)弄混了,拥塞控制是处理网络中带宽资源竞争的方式。流控只会确保发送端在任何时候都不会使得接收端过载(你会看到流控比拥塞控制简单的多)。
当丢包时,TCP有两种形式的重传:时间驱动(timer-driven)重传和数据驱动(data-driven)重传。前者依赖预估一个连接的RTT(round-trip time)来设置一个超时时间,如果发送端在发送TCP segment之后经过一定时间还没收到ACK,那么这个TCP segment就会被发送端重传。后者依赖丢包之后的其他数据的ACK能够被发送端收到,进而触发发送端的重传,而不用等待超时时间。
为了完成重传,发送端需要知道什么时候发生了丢包。如果发送端经过一段时间没有收到ACK,它会假设packet丢失了并且重传packet。现在的问题是发送端究竟要等多久?
这里的超时时间依赖什么因素呢?很明显,它应该依赖TCP连接的RTT。所以发送端需要预估RTT,它是通过监控发送一个packet和收到对应ACK的时间差来计算RTT,并且它会获取多次时间差并求平均。这里有很多种方法,TCP选用的是一种叫做EWMA(Exponential Weighted Moving Average)的简单方法。这个方法如下计算:
这里r是当前采样的时间差,srtt是预估的RTT。为了计算更高效,a=1/8,因为这样可以通过bit移位来完成计算。
我们现在知道如何获取RTT,那如何用RTT来设置重传超时(RTO: retransmission timeout)呢?一种古老的办法是让RTO等于RTT的倍数,例如等于RTT的2倍。实际上,原始的TCP规范RFC793里面用的就是这种方法。不幸的是,这种简单的方法并不能避免虚假重传(Spurious retransmissions)。虚假重传是指packet还在传输的过程中,但是却被发送端认为已经丢失。虚假重传可能会导致网络拥塞,因为健壮性原则中的保守发送被打破了(设想这样一个场景,网络从正常变得拥塞,但是之前计算的RTT还没有来得及更新,因为新的时间差只占1/8。网络拥塞会使得很多包没有丢失,只是变得延时增加了,但发送端还不知道,触发了重传并且向这个拥塞的网络发送了更多的包,进而加重了网络拥塞)。
简单的修复方法是使得RTO等于平均值和标准方差的函数,这样可以使得虚假重传的可能性大大降低。
rttvar是RTT的平均线性偏差,它按照如下方式计算
其中dev = |r-srtt|, y = 1/4。
问题还没完。TCP还有retransmission ambiguity的问题。当一个被重传了packet的ACK被收到时,发送端怎么计算RTT,使用最初的packet的时间还是用重传packet的时间?这看起来似乎微不足道,但是实际上很重要,因为如果选错了RTT将使得预估变得毫无意义,并且会影响到网络的吞吐。这个问题的解决方法也很简单,计算RTT时直接忽略有重传的packet。
现代的避免retransmission ambiguity的方法是使用TCP的timestamp option。大部分好的TCP实现都遵从了RFC1323的建议,使用了timestamp option。在这个option中,发送端用8个字节(4个字节记录秒,4个字节记录微秒)来记录当前TCP segment的时间。接收端,会在ACK中,直接返回对应的timestamp option中的值。发送端在接收到ACK之后,可以用当前时间减去ACK中的时间以获取时间差,所以现在是否发生了重传并不重要。
另一个重要的问题是RTO之后该做什么?很明显,因为TCP是一个”完全可靠“的终端协议,发送端需要重传packet。但是TCP不是以相同的频率重传packet,它采用了一种避免竞争的方式,具体来说就是exponential backoff的机制来更新重传计数器。
有关RTO最后一个需要注意的点是它在实际中非常的保守。TCP的重传计数器通常来说(但并不是所有情况)是粗粒度的,粒度是500或者200毫秒。这就是为什么虚假重传在现代TCP中很少发生的原因,同时也是为什么在下载的时候,一旦超时重传发生了,用户可以很容易的察觉到。
超时重传的代价很高(尽管它牺牲了TCP连接的带宽,但是它很有必要,因为它可以在极端拥塞的情况下确保发送端回退),所以需要探讨其他的重传策略来降低这里的代价。这样的重传策略通常被称为数据驱动重传。回到之前的例子,假设TCP接收端收到了下面的字节序列:
返回的ACK是:
很明显,这里重复的ACK表明一些奇怪的事情正在发生,因为在正常情况下,ACK中的序号应该单调递增。在TCP传输过程中的重复ACK可能有以下原因:
接收窗口更新。当接收端发现自己的socket buffer有更多的空间时(比如应用程序从socket buffer读取了一些数据,并释放了部分socket buffer),尽管发送端没有发送新的数据,接收端会通过ACK发送窗口更新到发送端。
丢包。
包乱序。例如当TCP segment 1701-2500因为走了不同的网络路径,晚于其他的segment才到达接收端,先到达的segment,例如2501:3000,只能触发接收端返回ACK 1701。
非窗口更新的重复的ACK被称为dupack。TCP使用了一个简单的方法来区分丢包和包乱序:如果发送端看到ACK没有确认一个TCP segment,但是确认了它后面的三个TCP segment。那么发送端认为之前未被确认的TCP segment丢包了。
但是不幸的是,ACK并不会告诉发送端一个丢包的TCP segment之后哪些segment被收到了,它们只会告诉发送端在TCP字节流中收到的最后一个连续的字节数(例如上面例子的1701)。所以发送端只会统计dupack的数量,如果发现超过3个dupack,那么对应的segment(1701:2500)就认为丢失了。各种基于实际经验的研究表明,至少在今天的互联网里面,这种方式工作的很好。因为今天的互联网中,TCP的乱序非常不常见,且一个TCP连接不会有多路径(至少对于一个TCP连接来说,一般不会有多路径)。
当一个TCP连接的带宽延时乘积较大时,例如在一个高带宽,高延时的卫星线路上,TCP的窗口可以变得非常大。
当发生丢包时,由于ACK中的信息有限,需要重传整个TCP窗口中的多个TCP segment,这会使得TCP连接的性能变得很差。这些TCP连接通常被称为LFN(Long Fat Network)。针对这些TCP连接,selective ACK(SACK)被提出,用来改进标准的ACK。经过多年的讨论之后,SACK由RFC2018描述。
通过SACK TCP option,接收端可以最多告诉发送端3个它连续收到数据。例如,对于下面的数据流:
接收端会返回如下的ACK和SACK(中括号中表示):
SACK使得LFN中的TCP连接可以只重传丢失的packet,而不用采用默认的Go-Back-N的策略。尽管SACK通常是个好的功能,在今天的互联网中,它并不总是能阻止超时。一个原因是,在很多网络路径上,TCP窗口非常小(进而导致RTO很小),这种情况下很多时候丢包并不能给TCP发送端机会来使用数据驱动重传。
还有一些其他的问题对于TCP的可靠性很重要:
连接的设置和关闭。TCP连接的最开始,需要通过三次握手来同步TCP连接的两端。结束时,需要一个关闭过程(注,四次挥手)。
TCP Segment Size。TCP连接应该如何选择segment的大小?在TCP连接建立的过程中,两端都会选择一个默认的MSS(Maximum Segment Size),并且在SYN包中交换,其中更小的MSS会被选为TCP连接的segment大小。为了避免中途发生IP分片,这里推荐使用path MTU discovery。发送端会发出一个小于等于本地网卡MTU的packet,并且在IP header中带上DF(Don’t Fragment)。如果接收端收到了这个packet,那么网络路径可以支持这样一个MTU,因为沿途中并没有发生分片。如果接收端没有收到,并且发送端收到了某个路由器发来的ICMP error message,说这个packet太大了,那么发送端会尝试更小的packet,直到能成功为止。
低带宽的链路。一些TCP segment尺寸会比较小,并且大部分是由协议header组成,例如telnet packet和TCP的ACK们。为了在低带宽的链路上工作好,可以使用TCP头部压缩(定义在RFC1144)。这是基于大部分TCP header要么是一样,要么是可以从前一个TCP segment中推断出来。这种方法可以使得40字节的TCP header可以缩减到3-6字节。
TCP允许在数据包中带上ACK,并且在一个双向连接中,大部分的数据包中都会带上ACK来确认反方向的数据。
互联网架构是基于通用性和健壮性原则设计的。它通过一种体现了网络拓扑的地址定义方式来实现大规模部署,这样网络拓扑的地址信息可以在路由器中聚合。
TCP提供了一个顺序的,可靠的,双向的字节流抽象。它使用了时间驱动和数据驱动重传,后者在很多情况下提供了更好的性能,但是前者可以保证正确性。