翻译 网络协议——写给每个懂点编程的同学

jojoma · 2017年06月06日 · 最后由 kafei 回复于 2017年12月21日 · 16388 次阅读
本帖已被管理员设置为精华贴

网络协议——写给每个懂点编程的同学

缘起是我想了解一下底层网络的原理,看了几天《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 协议的工作就是,在不同路由器之间沟通,保证路由表是最新的。

转换成数据包 packet switching

如果互联网的工作方式是,路由器互相之间沿着线路传递数据,那如果数据很大会发生什么?比如说,如果我们请求一个 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 响应中留下一个空洞怎么办?

传输窗口 transmission winsow 与慢启动 slow start

我开着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 视频进行抓包的过程中,我看到了

  • 因为持续性成功传输,拥塞窗口迅速增大到了将近 1MB。
  • 数千数据包按顺序出现,一切正常。
  • 一个数据包到达顺序不正确。
  • 数据继续以每秒几 MB 的速度涌入,但丢失的数据包依旧没出现。
  • 我的计算机发出了不少重复的最后正常数据包的 ACK 消息,但内核也存下待处理的乱序数据包,以备后续的重新组装。
  • 服务器接收到了重复的 ACK,并重新发送了丢失的数据包
  • 我的客户端发出之前丢失的数据包,以及后续数据包的确认接收的消息。简单确认最近的数据包即可,它会隐式地确认之前所有的数据包都被接收。
  • 传输继续,但由于丢失的数据包,拥塞窗口变小了。

这就是正常情况,这些在每次我对完整下载进行抓包时都会产生。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 这样的信息。这些字段令人熟悉:工程师常常需要处理地址、长度以及校验和,我们也知道为什么它们是必须的。

数据帧接着被其他层的头数据包裹起来,构造出完整的数据包。这些头部数据很...奇怪。它们已经开始和模拟电路系统的底层现实发生碰撞了,所以我们绝不想把这些数据放到软件协议中去。一个完整的以太网数据包包含:

  • 序言 preamble,由 56 位交替的 0 和 1 构成(7 字节)。设备使用这个来同步时钟,有点像人们数数发令“1-2-3-开始!”计算机不能数数超过 1,所以他们通过说“10101010101010101010101010101010101010101010101010101010”来同步
  • 一个 8 位(1 字节)起始帧分隔符,通常是十进制数字 171(二进制表示是 10101011)。它标识了序言的结尾,注意分隔符中开始还在重复“10”,直到末尾有个“11”。
  • 核心数据帧,包含了源地址、目标地址、载荷等等,如前所述。
  • 一个 96 位(12 字节)的数据包间隔,其中的行是留空的。大胆猜测一下,这是留给设备休息的,因为它们很累了。

总结一下上面:我们想要传输 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 引入了头部压缩。

以前我也觉得做网站需要懂点网络协议,现在发现完全没有必要。就好像你学开车不需要学机动车原理与构造一样

我猜想,如果像我之前那样,只是抱着学习网络原理的心态(等于自己没有分出轻重主次的能力),这么一个覆盖面广且深的话题很快就能榨干一个人那么点可怜的兴趣。

在这种情况下,像这样在有限的篇幅里面讲了许多工程师“能懂的知识”的文章,就比较有意义。我想这也是它在 hn 能有接近 800 分的原因。

至于工程师需不需要懂网络知识,不是这篇文章讨论的话题。

在一个新的连接中,Linux内核每接收10个数据包后,就发出一个ACK。数字 10 由常数 TCP_INIT_CWND 控制,常数在内核源码中被定义

这段有点问题吧。这个现象应该是接收端 delayed ack 导致

听说入门是写一个 TCP/IP 协议栈😏

dudu_zzzz 回复

我原本对这块知识是不了解的。不过你说的我谷歌了一蛤,你说的应该是对的。减少 ACK 的方法,the Nagle algorithm and Delayed ACK

不过这两者都是可选的,或者有其他的原因,导致作者可能不想陷入这里的细节。结果这里看起来像是,拥塞窗口控制着 ACK 发送频率

flowerwrong 回复

写过 tcp stack 举手我看一蛤!

jasl 将本帖设为了精华贴。 06月06日 17:42
pathbox 回复

我觉得深究这些东西,还有一个原因,就是好奇。就是想搞懂到底是怎么回事。

原文中一些专有名词被翻译成中文以后,可能会造成意思传达的困扰。当然,这是技术书籍翻译最难抉择的地方,并不是楼主的问题。建议英文还可以的同学,看一下英文原文。

然后,原文可能是技术编辑而不是资深专家写的,所以有一部分内容和现在的实现已经不一样了,是比较古老的教科书。作为初学者概念入门很好,如果真的要写网络相关的程序,建议先看一下最新 Linux 内核的实现源码和社区讨论。

yfractal 回复

知其然 与 知其所以然

nong 回复

开车知道点原理还是很好玩的,比如知道了手动挡汽车可以无离合换挡之后驾驶乐趣增加了好多。

ibigbug 回复

小心翻车

tcp 还是蛮复杂的

都是文字啊。。。来点图,会不会更好?

需要 登录 后方可回复, 如果你还没有账号请 注册新账号