Plaza 新闻汇总

网络协议:程序员手册

网络协议栈执行了一些看似不可能完成的任务。它能够在不可靠的网络上实现可靠的数据传输,并且通常没有任何明显的故障。它能够平滑地适应网络拥塞。它能够为数十亿个活动节点提供寻址功能。它能够绕过受损的网络基础设施路由数据包,即使数据包到达的顺序不同,也能在另一端按正确的顺序重新组装。它能够适应一些特殊的模拟硬件需求,例如平衡以太网电缆两端的电荷。

这一切都运作得如此出色,以至于用户从未听说过它,甚至大多数程序员也不知道它是如何运作的。

**网络路由**

在模拟电话的早期,拨打电话意味着从你的电话到朋友的电话之间需要一个持续的电气连接。这就像一根导线直接从你连接到他们一样。当然,没有这样的导线——连接是通过复杂的交换系统建立的——但它在电气上等同于一根导线。互联网节点的数量太多,无法以这种方式工作。我们无法为每台机器与其想要通信的每台其他机器之间提供一条直接的、不间断的路径。

相反,数据是通过“桶接力”的方式传递的——从一台路由器传递到下一台路由器,形成一个链,每台路由器都将数据带到更接近其目的地的位置。在我的笔记本电脑和google.com之间,每台路由器都连接到许多其他路由器,维护着一张简单的路由表,显示哪些路由器更靠近互联网的哪些部分。当一个目标为google.com的数据包到达时,路由器在路由表中快速查找,了解数据包接下来应该去哪里才能更接近Google。数据包很小,因此链中的每个路由器只占用下一台路由器一小部分时间。

路由分解为两个子问题。首先是寻址:数据的目的地是什么?这由IP(互联网协议)处理,IP地址由此而来。IPv4仍然是最常见的IP版本,它只提供32位的地址空间。它现在已完全分配,因此向公共互联网添加节点需要重用现有的IP地址。IPv6允许2128个地址(约1038),但截至2017年,其采用率仅约为20%。

现在我们有了地址,我们需要知道如何通过互联网将数据包路由到其目的地。路由发生得很快,因此没有时间查询远程数据库以获取路由信息。例如,思科ASR 9922路由器的最大容量为每秒160太比特。假设使用完整的1500字节数据包(12000比特),那么在一个19英寸机架中,每秒可以处理13,333,333,333个数据包!为了快速路由,路由器维护路由表,指示到各种IP地址组的路径。当新数据包到达时,路由器会在表中查找它,告知它哪个对等节点最接近目的地。它将数据包发送到该对等节点,然后继续下一个。BGP的工作是在不同的路由器之间传达此路由表信息,确保路由表是最新的。

不幸的是,IP和BGP本身并不能构成一个有用的互联网,因为它们无法可靠地传输数据。如果路由器过载并丢弃数据包,我们需要一种方法来检测丢失并请求重新传输。

**分组交换**

如果互联网的工作原理是路由器沿线将数据传递给彼此,那么当数据量很大时会发生什么?如果我们请求88.5 MB的《JavaScript的诞生与死亡》视频呢?我们可以尝试设计一个网络,其中88.5 MB的文档从Web服务器发送到第一个路由器,然后发送到第二个路由器,依此类推。

不幸的是,这样的网络在互联网规模甚至内联网规模上都无法工作。首先,计算机是有限的机器,具有有限的存储量。如果给定的路由器只有88.4 MB的缓冲内存可用,它就无法存储88.5 MB的视频文件。数据将被丢弃,更糟糕的是,我不会收到任何提示。如果路由器非常繁忙以至于正在丢弃数据,它就没有时间告诉我关于丢弃的数据。其次,计算机不可靠。有时,路由节点会发生故障。有时,船锚意外地损坏水下光纤电缆,导致互联网的大部分区域瘫痪。

由于这些原因以及更多其他原因,我们不会通过互联网发送88.5 MB的消息。相反,我们将它们分解成数据包,每个数据包通常在1400字节左右。我们的视频文件将被分成大约63214个独立的数据包进行传输。

**数据包乱序**

使用数据包捕获工具Wireshark实际传输《JavaScript的诞生与死亡》视频,我看到总共接收了61807个数据包,每个数据包1432字节。将这两个数相乘,得到88.5兆字节,这正是视频的大小。(这并不包括各种协议添加的开销;如果包括,我们会看到一个略高的数字。)传输是通过HTTP完成的,HTTP是一个建立在TCP(传输控制协议)之上的协议。它只花了14秒,因此数据包的平均到达速率约为每秒4400个,或每个数据包250微秒。在14秒内,我的机器接收了所有这61807个数据包,这些数据包可能是乱序的,并在它们到达时将它们重新组装成完整的文件。

TCP数据包重组使用最简单的机制完成:计数器。每个数据包在发送时都会分配一个序列号。在接收方,数据包按序列号排序。一旦它们都按顺序排列,没有间隙,我们就知道了整个文件都存在。(实际的TCP序列号往往不是简单地每次递增1的整数,但此细节在这里并不重要。)

但是,我们如何知道文件何时结束呢?TCP对此一无所知;这是更高级别协议的任务。例如,HTTP响应包含一个“Content-Length”标头,用于指定响应的长度(以字节为单位)。客户端读取Content-Length,然后继续读取TCP数据包,并将它们重新组装回原始顺序,直到它获得了Content-Length指定的所有字节。这是HTTP标头(以及大多数其他协议的标头)位于响应有效负载之前的其中一个原因:否则,我们将无法知道有效负载的大小。

当我们在这里说“客户端”时,我们实际上指的是整个接收计算机。TCP重组发生在内核内部,因此像Web浏览器、curl和wget这样的应用程序不必手动重新组装TCP数据包。但是内核不处理HTTP,因此应用程序必须了解Content-Length标头并知道要读取多少字节。

有了序列号和数据包重新排序,即使数据包到达的顺序不同,我们也可以传输大量字节序列。但是,如果一个数据包在传输过程中丢失,导致HTTP响应中出现一个空洞会发生什么?

**传输窗口和慢启动**

我在Wireshark打开的情况下,正常下载了《JavaScript的诞生与死亡》视频。滚动浏览捕获内容,我看到一个接一个的数据包成功接收。例如,一个序列号为563321的数据包到达了。与所有TCP数据包一样,它都有一个“下一个序列号”,这是用于后续数据包的编号。此数据包的“下一个序列号”为564753。下一个数据包确实具有序列号564753,因此一切正常。一旦连接达到速度,这种情况每秒就会发生数千次。

偶尔,我的计算机会向服务器发送一条消息,例如“我已经接收了所有数据包,包括数据包编号564753”。这是一个ACK(确认):我的计算机确认已收到服务器的数据包。在新的连接上,Linux内核每十个数据包发送一个ACK。这由TCP_INIT_CWND常量控制,我们可以在Linux内核的源代码中看到它的定义。(TCP_INIT_CWND中的CWND代表拥塞窗口:一次允许在飞行中的数据量。如果网络变得拥塞——过载——则窗口大小将减小,从而降低数据包传输速度。)十个数据包大约为14 KB,因此我们一次最多只能限制14 KB的数据在飞行中。这是TCP慢启动的一部分:连接以较小的拥塞窗口开始。如果未丢失任何数据包,则接收方将不断增加拥塞窗口,从而允许一次在飞行中的数据包更多。最终,一个数据包将会丢失,因此接收窗口将减小,从而降低传输速度。通过自动调整拥塞窗口以及其他一些参数,发送方和接收方使数据尽可能快地移动,但不会更快。这发生在连接的两端:每一端都确认另一端的消息,并且每一端都维护自己的拥塞窗口。非对称窗口允许协议充分利用具有非对称上行和下行带宽的网络连接,例如大多数住宅和移动互联网连接。

**可靠传输**

计算机不可靠;由计算机组成的网络更加不可靠。在像互联网这样的大规模网络中,故障是操作的正常部分,必须予以适应。在分组网络中,这意味着重新传输:如果客户端接收到数据包编号1和3,但没有接收到2,则它需要请求服务器重新发送丢失的数据包。当每秒接收数千个数据包时,就像在我们的88.5 MB视频下载中一样,错误几乎是不可避免的。为了证明这一点,让我们回到我对下载的Wireshark捕获。对于数千个数据包,一切正常。每个数据包都指定一个“下一个序列号”,后跟另一个具有该编号的数据包。突然,出了问题。第6269个数据包的“下一个序列号”为7208745,但该数据包从未到达。相反,一个序列号为7211609的数据包到达了。这是一个乱序数据包:缺少某些内容。我们无法确切地知道这里出了什么问题。也许互联网上的某个中间路由器过载了。也许我的本地路由器过载了。也许有人打开了微波炉,产生了电磁干扰并减慢了我的无线连接速度。无论如何,数据包丢失了,唯一的迹象是意外的数据包。

TCP没有特殊的“我丢失了一个数据包!”消息。相反,ACK被巧妙地重新用于指示丢失。任何乱序数据包都会导致接收器重新确认最后一个“好”数据包——最后一个按正确顺序排列的数据包。实际上,接收器在说“我收到了数据包5,我正在确认它。我也收到了之后的一些内容,但我知道它不是数据包6,因为它与数据包5中的下一个序列号不匹配。”如果两个数据包在传输过程中简单地交换了位置,这将导致一个额外的ACK,并且在接收到乱序数据包后,一切将继续正常。但是,如果数据包确实丢失了,则将继续到达意外的数据包,并且接收器将继续发送最后一个好数据包的重复ACK。这可能导致数百个重复的ACK。当发送方连续看到三个重复的ACK时,它假设后续数据包丢失并重新发送它。这称为TCP快速重传,因为它比旧的基于超时的方案更快。值得注意的是,协议本身没有任何明确的方式来表示“请立即重新传输此数据包!”。相反,自然来自协议的多个ACK充当触发器。(一个有趣的思想实验:如果某些重复的ACK丢失了,从未到达发送方会发生什么?)

即使在正常工作的网络中,重新传输也很常见。在我的88.5 MB视频下载的捕获中,我看到了这一点:

由于持续的成功传输,拥塞窗口迅速增加到大约1兆字节。几千个数据包按顺序显示;一切正常。一个数据包乱序到达。数据继续以兆字节/秒的速度涌入,但数据包仍然丢失。我的机器发送了几十次最后一个已知好数据包的重复ACK,但内核还会存储挂起乱序的数据包以备稍后重新组装。服务器接收重复的ACK并重新发送丢失的数据包。我的客户端确认先前丢失的数据包和由于传输顺序错误而已经接收到的后续数据包。这是通过简单地确认最近的数据包来完成的,这隐含地也确认了所有早期的数据包。传输继续,但由于数据包丢失,拥塞窗口减小了。

这是正常的;在我所做的完整下载的每次捕获中都发生了这种情况。TCP在它的工作中如此成功,以至于我们在日常使用中甚至不认为网络不可靠,即使它们在正常情况下会经常发生故障。

**物理网络**

所有这些网络数据都必须通过物理介质传输,例如铜、光纤和无线电。在物理层协议中,以太网是最广为人知的。它在互联网早期的流行导致我们设计其他协议来适应它的局限性。首先,让我们先解决物理细节。

以太网与RJ45连接器最密切相关,后者看起来像较旧的四针电话插孔的较大八针版本。它还与cat5(或cat5e、cat6或cat7)电缆相关,该电缆包含八根总线,扭曲成四对。存在其他介质,但这些是我们在家中最可能遇到的:八根线包裹在护套中,连接到八针插孔。

以太网是一个物理层协议:它描述了比特如何转换为电缆中的电信号。它也是一个链路层协议:它描述了一个节点与另一个节点的直接连接。但是,它纯粹是点对点的,并且没有说明数据如何在网络上路由。在TCP连接的意义上,它没有连接的概念,也没有在IP地址的意义上重新分配地址的概念。

作为一种协议,以太网有两个主要任务。首先,每个设备都需要注意到它已连接到某些东西,并且需要协商一些参数,例如连接速度。其次,一旦建立了链路,以太网就需要承载数据。与更高级别的协议TCP和IP一样,以太网数据也分成数据包。数据包的核心是帧,它具有1500字节的有效负载,以及另外22字节的标头信息,例如源和目标MAC地址、有效负载长度和校验和。这些字段很熟悉:程序员经常处理地址、长度和校验和,我们可以想象为什么它们是必要的。然后,帧被包装在另一层标头中,形成完整的数据包。这些标题……很奇怪。它们开始与模拟电子系统的底层现实发生冲突,因此它们看起来与我们永远不会放在软件协议中的任何东西都不一样。一个完整的以太网数据包包含:

* **前导码**,这是56位(7字节)的交替1和0。设备使用它来同步它们的时钟,有点像人们在数“1-2-3-GO!”时所做的那样。计算机无法数到1以外的数字,因此它们通过说“10101010101010101010101010101010101010101010101010101010”来同步。

* 一个8位(1字节)的**起始帧定界符**,它是数字171(二进制为10101011)。这标志着前导码的结束。请注意,它是“10”重复,直到最后出现“11”。

* **帧本身**,包含源和目标地址、有效负载等,如上所述。

* 一个96位(12字节)的**帧间隙**,其中线路保持空闲。大概,这是为了让设备休息,因为它们很累。

将所有这些放在一起:我们想要传输1500字节的数据。我们添加22个字节以创建帧,该帧指示源、目标、大小和校验和。我们再添加20字节的额外数据来适应硬件的需求,从而创建一个完整的数据包。你可能会认为这是堆栈的底部。事实并非如此,但事情会变得更奇怪,因为模拟世界会更深入地渗透。

**网络与现实世界相遇**

数字系统不存在;一切都是模拟的。假设我们有一个5伏的CMOS系统。(CMOS是一种数字系统;如果您不熟悉,请不要担心。)这意味着完全开启的信号将为5伏,完全关闭的信号将为0。但没有任何东西是完全开启或完全关闭的;物理世界不是这样运作的。实际上,我们的5伏CMOS系统会将任何高于1.67伏的电压视为1,将任何低于1.67伏的电压视为0。(1.67是5的1/3。让我们不要担心为什么阈值是1/3。如果您想深入研究,当然有一篇维基百科文章!此外,以太网不是CMOS,甚至与CMOS无关,但CMOS及其1/3截止点提供了一个简单的说明。)

我们的以太网数据包必须通过物理电线传输,这意味着改变电线上的电压。以太网是一个5伏的系统,因此我们天真地期望以太网协议中的每个1比特都是5伏,每个0比特都是0伏。但有两个问题:首先,电压范围为-2.5 V到+2.5 V。其次,更奇怪的是,在到达电线之前,每组8位都会扩展为10位。有256个可能的8位值和1024个可能的10位值,因此可以想象这是一个映射它们的数据表。每个8位字节可以映射到四个不同的10位模式中的任何一个,每个模式在接收端都会转换为相同的8位字节。例如,10位值00.0000.0000可能映射到8位值0000.0000。但也许10位值10.1010.1010也映射到0000.0000。当以太网设备看到00.0000.0000或10.1010.1010时,它们将被理解为字节0(二进制0000.0000)。(警告:现在将出现一些电子术语。)

这样做是为了满足一个极其模拟的需求:平衡设备中的电压。假设这种8b/10b编码不存在,并且我们发送了一些碰巧全是1的数据。以太网的电压范围是-2.5到+2.5伏,因此我们正在将以太网电缆的电压保持在+2.5 V,持续地从另一端拉电子。我们为什么关心一侧拉的电子比另一侧多?因为模拟世界很混乱,它会导致各种不良影响。举一个例子:它可能会给低通滤波器中使用的电容器充电,从而在信号电平本身产生偏移,最终导致位错误。这些错误需要一段时间才能积累,但我们不希望我们的网络设备在运行两年后突然损坏数据,仅仅因为我们碰巧发送的二进制1比0多。(电子术语到此结束。)

通过使用8b/10b编码,以太网可以平衡通过电线发送的0和1的数量,即使我们发送的数据大部分是1或大部分是0。硬件跟踪0与1的比率,将传出的8位字节映射到10位表中的不同选项,以实现电气平衡。(更新的以太网标准,如10 GB以太网,使用不同的、更复杂的编码系统。)我们将在此处停止,因为我们已经超出了可以被视为编程的范围,但还有许多其他协议问题需要适应物理层。在许多情况下,硬件问题的解决方案在于软件本身,例如用于校正直流偏移的8b/10b编码。这对于我们程序员来说可能有点令人不安:我们喜欢假装我们的软件存在于一个完美柏拉图世界中,没有物理现实的粗俗瑕疵。实际上,一切都是模拟的,适应这种复杂性是每个人的工作,包括软件的工作。

**相互关联的网络协议栈**

互联网协议最好被认为是层层叠叠的。以太网提供两个点对点设备之间的物理数据传输和链路。IP提供了一层寻址,允许路由器和大规模网络存在,但它是无连接的。数据包被发射到以太网中,没有任何迹象表明它们是否到达。TCP通过使用序列号、确认和重新传输添加了一层可靠传输。最后,像HTTP这样的应用程序级协议建立在TCP之上。在此级别,我们已经有了寻址和可靠传输以及持久连接的错觉。IP和TCP使应用程序开发人员不必不断地重新实现数据包重新传输、寻址等。

这些层的独立性非常重要。例如,当我的88.5 MB视频传输过程中丢失数据包时,互联网骨干路由器并不知道;只有我的机器和Web服务器知道。来自我计算机的几十个重复的ACK都按部就班地通过了与丢失原始数据包相同的路由基础设施。负责丢弃丢失数据包的路由器也可能是几毫秒后承载其替换的路由器。对于理解互联网来说,这是一个重要的点:路由基础设施不知道TCP;它只路由。(当然,总有一些例外,但通常情况下是正确的。)

协议栈的各层独立运行,但它们并非独立设计。更高级别的协议往往建立在更低级别的协议之上:HTTP建立在TCP之上,TCP建立在IP之上,IP建立在以太网之上。低级别的设计决策往往会影响更高级别的决策,即使几十年后也是如此。以太网很老,并且涉及物理层,因此它的需求设定了基本参数。以太网有效负载最多为1500字节。IP数据包需要装入以太网帧中。IP的最小标头大小为20字节,因此IP数据包的最大有效负载为1500 - 20 = 1480字节。同样,TCP数据包需要装入IP数据包中。TCP的最小标头大小也为20字节,这使得TCP有效负载的最大值为1480 - 20 = 1460字节。实际上,其他标头和协议会导致进一步减少。1400是一个保守的TCP有效负载大小。

1400字节的限制影响了现代协议的设计。例如,HTTP请求通常很小。如果我们将它们放入一个数据包而不是两个数据包中,我们将减少丢失请求部分的概率,从而相应地降低TCP重新传输的可能性。为了从小型请求中挤出每一个字节,HTTP/2指定了标头的压缩,这些标头通常很小。在没有来自TCP、IP和以太网的上下文的情况下,这看起来很愚蠢:为什么要向协议的标头添加压缩以节省只有几个字节?因为,正如HTTP/2规范在第2节的引言中所说,压缩允许“许多请求被压缩成一个数据包”。HTTP/2进行标头压缩是为了满足TCP的约束,而TCP的约束来自IP的约束,IP的约束来自以太网的约束,而以太网是在20世纪70年代开发的,于1980年投入商业使用,并于1983年标准化。

最后一个问题:为什么以太网有效负载大小设置为1500字节?没有深刻的原因;它只是一个很好的权衡点。每个帧需要42字节的非有效负载数据。如果有效负载最大值只有100字节,那么只有70%(100/142)的时间用于发送有效负载。1500字节的有效负载意味着大约97%(1500/1542)的时间用于发送有效负载,这是一个很好的效率水平。将数据包大小提高将需要设备中更大的缓冲区,而我们不能仅仅为了获得另外一个百分比或两个百分比的效率而证明这一点。简而言之:HTTP/2具有标头压缩功能,因为20世纪70年代后期网络设备的RAM限制。

原文地址
2024-12-18 00:42:33