缘起是我想了解一下底层网络的原理,看了几天《TCP/IP 详解 卷一》,但是这部书读起来十分吃力。这时候正好看到 hn 谈到这篇 Network protocols。所以特地翻译过来,希望也能有人从中受益。本人知识有限,在一些译文处补充了原文用词来辅助理解,翻译不对之处欢迎指正。
网络栈技术,完成了几件看起来不可能的任务:它在不可靠网络基础上,实现了可靠数据传输,传输过程中鲜有可察觉的问题出现。它在网络拥塞时能够平滑适应。它给网络中上十亿的活动节点提供地址。它能在受损的网络基础设施中,往正确的路线发送数据包,即使是乱序到达在接收端,也能将数据包重新装配成正确的顺序。它适应了深奥的模拟 analog 硬件需求,比如以太网电缆两端的电荷平衡。网络技术工作得如此之好,以至于网络用户们从没听说过它们,甚至大部分编写程序的工程师们也不知道底层究竟是如何工作的。
在古老的模拟电话年代,打电话意味着建立一个连接你和你朋友的电话的、持续的、电子连接。仿佛真的有根电话线,直接在你和朋友之间工作。当然实际没有这根线——电话连接经过了复杂的交换系统——但是这个连接电子上等效于一根线。
互联网的节点数太多了,不能套用这个方式。我们不可能给每一台机器与另一台机器都建立一个直接连接的、不被打断的路线用于通信。
相应地,数据是由一个路由器发送给下一个路由器,每次传输都使数据离目的地更近一步,整个链式传递过程像传桶队列 Bucket-brigade。举个例子,从我的笔记本到 google.com 之间,途径的每个路由器都连接着许多其他路由器,各自维护着一组不精确的路由表,路由表表示出哪些路由器更靠近互联网的哪一部分。当一组目的地是 google.com 的数据包到达时,路由器在路由表上进行快速查找,并发送数据包到更靠近谷歌的地方。数据包很小,所以传输链上路由器之间的数据传递耗时也极短。
路由可以拆分成两个子问题。第一个是,地址:数据的目的地用什么表示?这个由 IP 协议,其中的 IP 地址来处理。IPv4,作为最广泛使用的 IP 版本,提供的地址空间只有 32 位,已经全部被分配,所以添加一个新的节点到公开的互联网只能重用已存在的 IP 地址。IPv6,允许使用 2 ^ 128 个地址(大约 10 ^ 38),在 2017 年只有 20% 左右的采用率。
既然已经解决了地址的问题,我们现在需要知道如何在互联网上路由数据包到其目的地。路由是非常快的,没有时间在远端数据库中查询路由信息(所以只能在本地)。这速度有多快呢?举个例子,Cisco ASR 9922 路由器拥有着每秒最大 160TB 的处理能力。假设数据包是完整的 1.5KB(12000 位 bit),那么每秒有 133 亿个数据包流经这个 19 英寸小机器。
为了快速路由,路由器维护着指示着到达其他 IP 地址组路径的路由表。当一个新的数据包到达时,路由器查询路由表,告知这个数据包最接近目的地的路由器。这个路由器会把数据包发送到下一个路由器,然后再往下一个发送。BGP 协议的工作就是,在不同路由器之间沟通,保证路由表是最新的。
如果互联网的工作方式是,路由器互相之间沿着线路传递数据,那如果数据很大会发生什么?比如说,如果我们请求一个 88.5MB 的视频The Birth & Death of JavaScript。
我们可以试试设计一个网络,在这当中 88.5MB 的文件直接由网络服务器发送给第一个路由器,然后第二个,如此下去。不幸的是,这样的网络不可能在互联网级别的规模下工作,甚至内网规模下都很难。
首先,计算机的存储量是有限的。如果一个给定的路由只有 88.4MB 的可用缓存,那它就不能存储这个 88.5MB 的视频文件。这个数据会被直接丢弃,甚至更糟,我完全不知道这件事的发生。如果路由器是如此忙碌以至于丢弃了数据之后,都没时间告诉我它丢弃了数据。
其次,计算机都是不可靠的。有时,路由节点崩溃。有时,船只的⚓️意外损坏水下光缆,导致互联网一大部分不可访问。
基于这些提到的以及更多原因,我们不会在互联网中传递 88.5MB 大小的消息。相反,我们把数据拆分成许多数据包,大小通常在 1.4KB 左右。我们的视频文件将被拆分成 63214 左右个分隔的数据包用于传输。
使用抓包工具Wireshark观察The Birth & Death of JavaScript的一次真实传输,我能看到接收了一共 61807 个数据包,每个 1432 字节。两者相乘,我们得到 88.5MB,这正是视频文件的大小。(这不包括其他协议的开支,如果包含的话,数字会更大些)
这次传输是基于 HTTP,一种基于TCP的协议。传输花了 14 秒,所以平均每秒有 4400 个数据包到达,或者说每个数据包花了 250 毫秒到达。在这 14 秒中,我的计算机接收了所有 61807 个数据包,也许不是按顺序接收,在接收过程中进行重新装配成完整文件。
TCP 数据包重新组装使用的是一种可想象的最简单的机制:计时器。每个数据包在发送时都被赋予一个序列号。在接收端,数据包按序列号排列。一旦他们全部排好顺序,没有间隔,我们就知道整个文件都接收到了没有丢失。
(真实情况下,TCP 序列号并非是每次增加一的整数,但这个细节在本文中并不重要。)
就算如此,那我们怎么知道什么时候文件接收完成呢?TCP 对此一无所知,这个是更高级别协议的职责。举个例子,HTTP 响应 response 中包含一个叫做 Content-Length
的头部,说明了返回响应的总长度。客户端读取这个头字段,然后一直读取 TCP 数据包,重新装配它们,直到达到了此头字段指定的数据大小。这是为什么 HTTP 头部(以及其他大多数协议的头部)比响应载荷 payload 率先到达的原因之一,否则我们都不能知晓载荷的大小。
当我们在说客户端的时候,我们实际在说整个接收数据的计算机。TCP 组装是在内核中完成的,所以浏览器、curl 和 wget 这样的应用不需要手动重新装配 TCP 数据包。但是内核不处理 HTTP,所以应用需要理解 Content-Length
头字段并知晓需要读取多少字节。
有了序列号和数据包重新排序,我们能传输大量数据,即使数据包是乱序的。但如果一个数据包在传输中丢失了,在 HTTP 响应中留下一个空洞怎么办?
我开着Wireshark下载了The Birth & Death of JavaScript。查看抓包记录,我能看到数据包一个接一个被成功接收。
举个例子,一个序列号为 563321 的数据包到达了。像所有 TCP 数据包一样,它包含了一个“下一个包序号”,指示着接下来一个数据包的序列号。这个包的“下一个包序号”是 564753。传输过程中下一个数据包,的序列号确实是 564753,所以一切正常。这发生了数千次,随着连接开始加速传输数据。
有时候,我的计算机发出一条消息给服务器说,打个比方,“我已经接收了包序号小于或等于 564753 的所有数据包。”这称为 ACK,确认 acknowledgement 的简写,我的计算机确认接收服务器的数据包。在一个新的连接中,Linux 内核每接收 10 个数据包后,就发出一个 ACK。数字 10 由常数 TCP_INIT_CWND
控制,常数在内核源码中被定义。
(TCP_INIT_CWND
里的 CWND 表示 拥塞窗口 congestion window:同一时刻可以传输的数据总大小。)如果网络变得拥塞(超负荷),窗口大小减小,从而减慢数据包的传输。
十个数据包是大约 14KB,所以一开始的速度限制是 14KB。这是 TCP 慢启动的部分:连接建立时拥塞窗口很小。如果没有数据包丢失,接受者将持续增加拥塞窗口,允许同时传输更多数据包。
最终,将会有数据包丢失,所以接收窗口会减小,减慢传输。像这样自动调整拥塞窗口,以及其他参数,数据发送者和接收者让数据传输最大化利用网络带宽。
这发生在连接的两端:每端都发出 ACK 确认消息,也维护各自的拥塞窗口。不对称窗口允许协议用不对称的上下行带宽,最大化利用网络连接,就像大多数住宅区和移动网络连接一样。
计算机是不可靠的,由计算机组成的网络更加不可靠。在像互联网这样的大规模网络中,失败是操作中常见的一部分,并且必须得到良好处理。在一个数据包网络中,这意味着重传:如果客户端接收了序号 1 和 3 的数据包,但没有接收到 2,那么它需要要求服务器重新发出丢失的数据包。
当每秒接收上千数据包时,比如下载我们的 88.5MB 视频时,错误几乎百分之百会产生。为了给大家展示,让我们打开 Wireshark。很多数据包接收,一切看起来很正常。每个数据包都有一个“下一个包序号”,紧接着一个带着这个序号的数据包。
突然问题出现了。第 6269 个数据包的“下一个包序号”是 7208745,但那个数据包并没有到达。相反,序列号为 7211609 的数据包到达了。这是一个乱序数据包:有东西丢失了。
我们很难说出究竟什么出了问题。也许互联网中的一个中间路由器超负荷了,也许是我的本地路由器超负荷了。也许有人打开了微波炉,产生了电磁干扰,减慢了我的无线连接。无论如何,这个数据包丢失了,唯一的迹象是意外接收到的数据包。
TCP 并没有特别的“我丢失了一个数据包”消息。相反,ACK 消息会被巧妙地复用来表明数据丢失。任何乱序的数据包,会导致接收者重复确认最后的“正确的”数据包——正确顺序的最后一个。实际上,接收者说的是:“我确认接收到了数据包 5。在那之后我也接收到了别的数据,但我知道那不是数据包 6,因为它并不匹配数据包 5 的下一个包序号。”
如果只是两个数据包在传输时调换了顺序,这会导致一次额外的 ACK,等到乱序数据包接收到之后一切就能正常继续下去。但是如果有个数据包真的丢失了,意外数据包将会一直到达,因而接收者会持续发出重复的、最后一个正常数据包的 ACK 消息。这会导致上百个重复的 ACK 消息。
当数据发送者一下看到三个重复 ACK 消息,它就假定紧接着的数据包丢失了,并进行重新传输。这被称为 TCP 快速重传,因为它比以前的基于超时的做法要快一些。有趣的是,协议自身不会显式地去说“请立即重传这个消息!”相反,多个 ACK 消息从协议自然产生,作为重传的触发器。
(一个有意思的思维实验:如果一部分重复的 ACK 消息也丢失了,没能到达数据发送者,会发生什么?)
重传甚至在网络正常工作时都十分常见。在对下载 88.5MB 视频进行抓包的过程中,我看到了
这就是正常情况,这些在每次我对完整下载进行抓包时都会产生。TCP 在自己的职责上做得是如此出色,以至于我们在日常使用中从没考虑过网络是不可靠的,尽管在正常情况下网络都会例行性地失败。
所有这些网络数据,都必须通过像铜线、光缆、无线电这样的物理媒介进行传输。而在物理层协议之中,以太网最为著名。它在互联网兴起之初的流行,导致了我们在设计其他协议的时候必须适应它的局限。
首先,让我们把物理细节弄清楚。以太网与 RJ45 接头关系最紧密,后者看起来像更大的八针 eight-pin 版本的四针手机插孔 four-pin phone jacks。以太网也连接着 cat5(或 cat5e,或 cat6,或 cat7)电缆,该电缆包含了拧成 4 对的 8 根电线。其他媒介也存在,但我们在家中最有可能遇到的就是这些:裹在保护套下的 8 根电线,以及与之相连的 8 针插头。
以太网是一个物理层协议:描述了位信息如何转换成电线中的数字信号。它也是一个链路 link 层协议:描述了两个节点之间的直接连接。然而,这是单纯的点对点,对网络中数据是如何路由的并不关心。以太网这里没有 TCP 连接中的连接概念,也没有 IP 地址中的可重新分配的地址概念。
作为一个协议,以太网有两个主要的工作。第一,每个设备需要意识到它连接着一些东西,并且连接速度这样的参数需要协商。
第二,一旦链路 link 建立,以太网需要携带信息。像更高层次的 TCP 和 IP 协议一样,以太网的数据也拆分成数据包。数据包的核心是数据帧,帧有 1.5KB 的载荷,外加 22 字节的头部信息。头部信息中包含源 MAC 地址和目的地 MAC 地址,载荷长度,以及校验和 checksum 这样的信息。这些字段令人熟悉:工程师常常需要处理地址、长度以及校验和,我们也知道为什么它们是必须的。
数据帧接着被其他层的头数据包裹起来,构造出完整的数据包。这些头部数据很...奇怪。它们已经开始和模拟电路系统的底层现实发生碰撞了,所以我们绝不想把这些数据放到软件协议中去。一个完整的以太网数据包包含:
总结一下上面:我们想要传输 1.5KB 数据。我们添加 22 字节的包含源地址、目标地址、数据大小以及校验和的头信息以创建数据帧。我们再添加额外的 22 字节的数据,为了适应硬件需求,这些构成了完整的以太网数据包。
你也许会以为以太网已经是网络技术栈的最底层了。不是这样,但事情确实变得更奇怪了,因为模拟世界的对技术的影响更甚 pokes through even more。
数字系统并不存在,一切都是模拟的。
假设我们有一个 5 伏特 CMOS 系统,(CMOS 是一种数字系统,不熟悉也没关系。)这意味着,完全开启 fully-on 的信号将是 5 伏特,完全关闭的信号是 0 伏特。但是没有信号是完全开闭的,物理世界不这样工作。实际上,我们的 5 伏特 CMOS 系统,会把任何高于 1.67 伏特的信号看做 1,低于 1.67 伏特的信号看做 0。
(1.67 是 5 的 1/3。我们不用关心为什么分界线在 1/3。当然如果你想深究,这里有维基百科说明。另外,以太网不是 CMOS,甚至跟 CMOS 都没有关系,但 CMOS 和它的 1/3 分界线能用来做一个简单说明 make for a simple illustration)
我们的以太网数据包必须经由一条物理线,也就是改变电线中的电压。以太网是一个 5 伏特的系统,所以我们会天真地以为,以太网协议中的 1 位 bit 是电线中的 5 伏特,0 位是 0 伏特。但是有两个问题:首先,电压范围是 -2.5 伏特到 +2.5 伏特。其次,更奇怪的是,每组 8 位信息在到达电线之前,都会被拓展成 10 位。
8 位可以有 256 种取值,10 位有 1024 种取值,所以可以想象有张表在它们之间映射。每个 8 位的字节能被映射成 4 种 10 字节的信息,后者到达接收终点之后会被还原成同一个 8 位字节。举个例子,10 位的值 00.0000.0000 也许映射到 8 位 0000.0000。但是也许 10 位值 10.1010.1010 也能映射到同一个 8 位字节。当以太网设备不管看到 00.0000.0000 还是 10.1010.1010,它都能理解这是字节 0(二进制 0000.0000)。
(警告:下面可能需要一些电子电路知识)
上面这种映射的存在,是为了服务一个极其模拟的需求 extremely analog need:平衡设备中的电压。假设这种 8 位到 10 位的编码不存在,并且我们需要发送的数据恰好都是二进制 1。以太网的电压范围是 -2.5 伏特到 +2.5 伏特,所以我们会使以太网线的电压维持在 +2.5 伏特,继而一直从线的另一端吸引电子过来 pulling electrons。
为什么我们要关心一端从另一端获取电子呢?因为模拟世界是混乱的,可能会产生各种各样意外的影响。举个例子,这样会给低通滤波器中使用的电容器充电,使得信号电平中产生偏移,最终导致位错误。这些错误需要时间积累,但我们显然不希望,仅仅因为我们传输的二进制 1 比 0 多,两年之后网络设备中突然开始产生数据错误。
(有关电子电路的说到这里)
通过使用 8 位 -10 位 编码,以太网能保持电线中的 0 和 1 的平衡,即使我们要发送的数据都是 1 或者都是 0。硬件会检测 0 和 1 的比例,映射要发送的 8 位字节到不同的 10 位信号,以达到维持电荷平衡。(新的以太网标准,如 10GB 以太网,使用不同的更复杂的编码系统)
到此打住,因为我们谈论的已经超出编程的范围了,但是必须要说明的是,还有更多协议相关问题是为了适应物理层。在许多情况下,解决硬件问题的方法,都在软件中实现,比如上文使用 8 位 -10 位编码来修正直流偏移 DC offset。这对我们这样的工程师来说可能有点尴尬:我们习惯于假装软件生活在一个完美的柏拉图式的世界中,没有物理上庸俗的缺陷 devoid of the vulgar imperfections of physicality。事实上,一切都是模拟的,适应这种复杂性是每个人的工作,当然也包括软件。
互联网协议族最好理解为一组层的集合。以太网提供物理数据传输以及两个点对点设备之间的链路。IP 提供了地址层,允许路由器和大规模网络的存在,但是是无连接的,数据包双向传输却无从判断是否到达。TCP 通过使用序列号、确认以及重传,添加了可靠的传输层。
最终,应用层协议如 HTTP 建立在 TCP 之上。在这一层,我们已经有了地址,以及可靠传输和持续连接的幻觉 illusion。IP 和 TCP 将应用开发者,从重复实现数据包重传、地址处理等等的地狱中拯救出来。
这些层的独立性是十分重要的。举个例子,当传输 88.5MB 视频有数据包丢失的时候,互联网的网络中枢路由器并不知道;只有我的计算机和网络服务器知道。这个弄丢了原始数据包的路由基础设施,还在尽职地将我计算机发出的许多重复的 ACK 消息路由到目的地去。有可能就是同一个路由器,弄丢了数据包,几毫秒之后又带着重发的数据包来了。这是理解互联网的一个重点:路由基础设施对 TCP 一无所知;它做的仅仅是路由。(当然这也有例外,不过大多数情况下就是这样)
不同层的协议独立工作,但它们不是分开独立设计的。高层次协议通常建立在低层次协议基础上,HTTP 建立在 TCP 上,TCP 建立在 IP 上,IP 建立在以太网上。更底层的设计决策,即使在几十年之后,也会影响到更高层次的决策。
以太网是古老的,且涉及物理层,所以它的需求设置了基本参数。一个以太网载荷最大是 1.5KB。
IP 数据包需要包含于以太网数据帧中。IP 的最小头部大小是 20 字节,所以 IP 数据包的最大载荷是 1500 - 20 = 1480 字节。
同样,TCP 数据包需要包含于 IP 数据包中。TCP 的最小头部大小也是 20 字节,所以 TCP 的最大载荷是 1480 - 20 = 1460 字节。在现实中,其他头部和协议会占据更多空间,保守估计 TCP 的载荷大小是 1400 字节。
1400 字节的限制影响了现代协议的设计。举个例子,HTTP 请求通常很小。如果我们把它们塞进一个数据包而不是两个,就能减小丢失请求某部分的可能,从而减少需要 TCP 重传的可能。为了从小请求中挤出每个字节,HTTP/2 指定了头部压缩,头部通常很小。没有 TCP、IP 和以太网的情境的话,这看起来很不明智:为什么要压缩一个协议的头部,仅仅为了节约几字节大小的空间?因为,正如 HTTP/2 规范在第 2 节的介绍中所说,压缩允许“多个请求被压缩成一个数据包”。
HTTP/2 的头部压缩是为了适应 TCP 的限制,这个限制来自 IP 的限制,再往上来自以太网的限制。而以太网在上世纪 70 年代发展起来的,1980 年投入商用,并在 1983 年标准化。
最后一个问题:为什么以太网的载荷大小设置在 1500 字节(1.5KB)呢?其实没有深层次的原因:只是一个很好的权衡考量。每个数据帧中有 42 字节大小的非载荷数据。如果载荷的最大值只有 100 字节,那么只有 70%(100/142)的时间花在发送载荷上。而 1500 字节大小的载荷,意味着大约 97%(1500/1542)的时间用于发送载荷,这样的效率是可观的。再增加数据包的大小的话,会需要设备拥有更大的缓冲区,这使得再提高一两个百分比的效率变得十分困难。简而言之,20 世纪 70 年代末网络设备的 RAM 限制,导致 HTTP/2 引入了头部压缩。