如果没有 TCP 和 UDP,互联网就不会像我们现在所知道的那样存在,但许多开发人员并不十分了解推动网络发展的技术。在本期节目中,我们与《使用 Go 进行网络编程》 一书的作者 Adam Woodbeck 进行了交谈,了解了 TCP 和 UDP;它们是什么、它们如何工作,以及如何尝试使用 Wireshark 和 Go 等工具来了解更多信息。

本篇内容是根据2021年5月份#176 TCP & UDP音频录制内容的整理与翻译

过程中为符合中文惯用表达有适当删改, 版权归原作者所有.




Jon Calhoun: 大家好,欢迎收听 Go Time 播客。今天我们邀请到了 Adam Woodbeck。你好吗,Adam?

Adam Woodbeck: 我很好,你呢?

Jon Calhoun: 我也很好。Adam 是 Baracuda Networks 的一名软件工程师,负责编写分布式服务,并且他最近与 No Starch Press 合作出版了一本书,叫《Network Programming With Go》。这是对的吗?

Adam Woodbeck: 没错。

Jon Calhoun: 太棒了。今天我们要讨论一下你书中的一小部分内容,关于 TCP 和 UDP,对吧?

Adam Woodbeck: 是的,这在书中大概占了四章的篇幅。

Jon Calhoun: 好的。我们今天还有另外一位嘉宾,Kris Brandow。你好吗,Kris?

Kris Brandow: 我很好,你呢?

Jon Calhoun: 我也不错。我希望今天你能多承担一些讨论,特别是如果我们谈到一些非常技术性的内容时 [笑声]。我已经有一段时间没看过 TCP 和 UDP 相关的内容了,所以我对今天的讨论非常期待,不仅能刷新我的记忆,也能教给大家一些新东西。

那么,首先我们从简单的开始吧。TCP 和 UDP 到底是什么?它们的缩写分别代表什么?能否从高层次上解释一下它们的作用?

Adam Woodbeck: 当然可以。TCP 代表传输控制协议(Transmission Control Protocol),UDP 代表用户数据报协议(User Datagram Protocol)。传输控制协议是一种流式、状态化的协议,你发送的是一串数据流,通常会有另一端的确认,因此整个对话过程中是有状态的。而 UDP 的开销要小得多,几乎没有可靠性保障---实际上可以说没有任何可靠性保障。你只是简单地把数据发给对方,不保证收到确认,甚至不保证数据能到达对方。换句话说,它是无状态的,以消息为基础,而不是数据流。

Jon Calhoun: 这可能会让很多人感到困惑,因为当谈到数据传输,尤其是编程时,我们通常认为所有数据都需要到达目的地。你能解释一下为什么 UDP 适用于像游戏这样注重实时性的场景吗?他们是如何在数据不完整的情况下继续运行的?

Adam Woodbeck: 我会在某些场景下使用 UDP,比如当一个数据包晚到比没有到达更糟糕时。如果我在玩游戏,比如第一人称射击游戏,我需要知道对手现在的位置,而不是两秒钟前我丢失数据包时他的位置。又比如,如果我们在进行语音对话,如果某些数据包丢失了,你可能听不到我现在说的所有话,而 TCP 会试图重新发送这些数据,这会导致延迟,影响实时性。而 UDP 更注重及时性,即使数据不完整也要立即传输。

Jon Calhoun: 好吧,这很有道理。我记得我曾经用过某种视频软件,当某人的网络延迟时,你会突然看到他们的视频快速赶上来……这总是一个很奇怪的体验,视频流好像快速回放了一下。我猜那是因为他们使用了类似 TCP 的协议重放了丢失的数据。而在 Zoom 等应用中,如果某人的网络连接有问题,他们通常会断开一会儿,然后再回来。这听起来对吗?

Adam Woodbeck: 是的。在实时应用中,你没有缓冲的优势。TCP 非常擅长缓冲数据,就像你刚才描述的场景,你可能是从网络上读取已经到达的数据,所以看起来像是快进到了实时。而用户体验可能会更好,如果它只是丢弃了那些数据包,而不是试图赶上过去的内容。

Jon Calhoun: 这很有道理。那么我们先深入讨论一下 TCP 吧,因为我觉得大多数人会对它比较熟悉……至少在我的经验中,大部分我做的网络操作都是基于 TCP 的。所以我们先从这里开始,如果你和我不一样,抱歉各位听众……

你刚才说 TCP 是一种确保数据包不会丢失的协议。那么它的连接过程是怎样的?它的步骤是什么?能否从高层次上解释一下?

Adam Woodbeck: 让我先用一个比喻来解释……TCP 就像你要给邻居送一块派。如果我们使用 TCP,我可能会从窗户大喊,“嘿,你在吗?” 邻居会回喊,“是的,我在。” 然后我说,“我想给你送一块派。” 我们就建立了对话。

邻居回应说,“好的,拿过来吧。” 然后我把派送过去。最后我说“再见”,邻居也说“再见”,对话结束。这就是 TCP,我们有一个状态化的对话。

而如果用 UDP,那我可能只是把派直接扔向邻居的窗户,完全不管窗户是开着还是关着。我也不知道邻居有没有收到派。他可能因为我扔派而非常生气。这就是两种协议的区别。一个有很多的来回确认,有可靠性和对话过程,另一个则非常直接,“这是数据,希望你能收到,自己处理吧。” 如果你需要可靠性,那就需要在应用层处理。

我们现在讨论的只是传输层。在应用层之下,当你在 Go 语言中向网络套接字写入数据时,实际上是写入了传输层。TCP 或 UDP 就是在这个层次上开始工作的。有很多事情在这个层次上自动处理,你不需要在应用层关心。

例如,当我们发起一次网页请求时---假设我访问 Google,那么我的计算机会打开一个本地端口,并通过这个端口向 Google 的 IP 地址和端口 80 发送请求……我们暂时忽略 TLS,直接使用端口 80。这样就建立了一个唯一的连接。该连接由四个元素组成:我的 IP 地址、我的端口号、Google 的 IP 地址和 Google 的端口号。它们的组合是唯一的。

我的计算机会发送一个空的数据包,里面只有头部信息,并且带有一个 SYN 标志,告诉 Google,“这是我想要使用的连接设置。” 如果 Google 想要与我通信,它会返回一个数据包,空载荷,只带有头部信息,并且有一个确认标志,表示“我收到了你的 SYN 包,并且这是我同意的连接设置。” 如果我的计算机同意这些设置,它会再发送一个确认包---同样是空的数据包,只携带确认标志。

这就是所有 TCP 连接开始时的三次握手。握手完成后,我们就建立了一个会话,数据可以在两端自由流动。当我们完成数据交换时,通常会有一个优雅的终止过程。任何一方都可以发起这个过程,但假设我发起了,我会发送一个 FIN 包,同样是空的,只带有 FIN 标志。Google 会确认这一点,说“我收到了你的 FIN,这是我的 FIN。” 然后我会确认这点,连接就终止了。

Jon Calhoun: 那么,确保我和听众都明白……当我向 Google.com 发起一次网络请求时,是整个页面打开期间都保持连接,还是只在获取 HTML 时保持连接,之后就关闭?还是说有一些场景是两者皆有?

Adam Woodbeck: TCP 有 keepalive 机制,我相信在大多数操作系统中默认是关闭的……但基本上,如果我向 Google 发送请求,Google 需要很长时间才能响应,比如数据处理复杂、延迟大等,我的计算机可能会发送一个 keepalive 请求,这是一个特殊请求,发送给 Google 的传输层,而不是应用层,询问“我们还在对话吗?” 正常情况下,我会收到一个确认回复,表示“是的,我们还在对话,只是应用层的回复还没准备好。” 这种请求可能会持续一段时间,直到我收到应用层的回复,或者达到某个超时阈值,连接自动关闭。

Jon Calhoun: 当我们谈论这些来回的通信时,大多数人可能认为网络请求是“我发送请求,他们回复全部数据”。而我猜 TCP 的传输层可能有更多的交互。我的问题是,数据在这个层面上是如何传输的?是二进制编码吗?还是完全不同的形式?这些消息的实际传输是怎样的?

Adam Woodbeck: 我们可以先谈一下握手过程中确定的一些机制,它们帮助确保可靠性,并控制信息的流动。我之前提到 TCP 是一种基于流的协议。所以如果我向你发送一个网页或大量数据,它会以字节流的形式传输,而这些字节可能包含多个独立的消息。但当我读取这些数据时,我只是接收了一堆字节,需要自行解析这些数据。

在我们深入讨论如何编码和解码这些数据之前,关于网页传输,握手过程中双方会建立一个序列号。通常它是一个很大的数字,只在当前会话中有意义,但为了方便理解,我们假设它从 0 开始。

当客户端发送第一个 SYN 包时,部分信息会告诉服务器“我要从序列号 0 开始。” 服务器在确认时,即使是空载荷,也会确认序列号为 1,表示客户端发送了一个字节,尽管实际上并没有发送。这被称为 “幽灵字节”。同样,服务器发回 SYN 包时,它的序列号也从 0 开始。然后客户端在第三次握手时确认序列号为 1。这样,双方的序列号都设置为 1。这种确认机制帮助确保了数据的可靠传输。

例如,如果我向 Google 发送了一个 500 字节的请求,Google 会确认序列号为 501,因为序列号会根据我发送的字节数递增。

Adam Woodbeck: 所以,谷歌的序列号仍然设置为1,因为它还没有发送任何数据给我。它只是发送了一个确认消息。所以当谷歌发给我请求的数据时---比如说是2000字节---它会发送所有这些数据,而我会确认序列号为 2001。如果我确认的是 1001,那么谷歌会意识到“等一下,他并没有收到最后的1000字节,我应该重新发送这些数据。”

这是我们确保可靠性的方式之一,也是我们如何跟踪已经发送的内容,并确认那些数据已经发送的方式。现在,另一个在握手中协商的因素是窗口大小。

每个 TCP 连接都会分配一定数量的内存作为接收窗口。你可以把它想象成一个桶。这允许我作为发送者,向谷歌发送数据,这些数据会暂时存储在这个桶里,应用程序可以随后读取。当我发送数据时,应用程序不需要立即从套接字中读取数据,它可以坐等数据存储在桶里。但是,我不想让这个桶装满,因为如果桶满了,我尝试放入的任何数据都会被丢弃、丢失。

在这种情况下---我们可能会深入讨论窗口缩放之类的细节......但基本上,我会发送请求,试图填满这个桶。每次请求我能发送的数据包大小是有上限的。举个例子,这里的最大段大小可能是1460字节,这意味着每个数据包的最大大小是1500字节,因为 TCP 有20字节的头部,IP 也有20字节的头部。

所以每次我发送的请求或谷歌返回的响应,都会以这些1460字节的数据块为单位。如果我请求的是2000字节的数据,那么我会收到两个数据包。一个是1460字节的数据包,第二个是剩下的数据。所以我会以这样的数据块为单位,逐步接收,直到我的“桶”满了,无法再接受更多,或者直到我请求的数据全部接收完毕。

Jon Calhoun: 当你提到这些“桶”时,我认为你说的是缓冲区。如果我们在代码中读取来自谷歌的传入数据,那么缓冲区会被清空,然后可以再次接收更多数据,对吗?

Adam Woodbeck: 没错。如果我从谷歌接收数据,并且从未从连接中读取这些数据,它们就会一直存储在那个“桶”里。最终桶会满,然后我的一方会告诉谷歌“别再发送了,我没有空间接收你发送的数据。” 所以作为程序员,我的责任是确保从桶中读取数据,以便继续接收来自谷歌的更多数据。

Jon Calhoun: 作为开发者,这些内容在我们的代码中很少见。我们不会手动编写 HTTP 请求时,还在计算“我要发送100字节,然后200字节”等等。

Adam Woodbeck: 当然。

Jon Calhoun: 我猜这些都是标准库已经封装好的……每种语言可能都会决定如何处理这些事情。你提到最大值是1460字节,我假设每种语言或网络库都可以根据需要发送更少的数据,对吗?

Adam Woodbeck: 绝对可以。

Jon Calhoun: 那么,编程语言或库作者可以自行决定这些数字吗?

Adam Woodbeck: 操作系统会有默认值,但我们谈的是从我的电脑到谷歌的每一个节点,这个最大大小会在每个跳跃点被强制执行。TCP 的好处是我们可以将数据分割开来,并且在接收端重新组装。数据包可以以不同于我发送的顺序到达远程端,TCP 会为应用程序将其重新排序。

使用 UDP 时,我们不希望数据被分割开来;我们希望保持在大小限制之内,以避免拆分。但在 TCP 中,我可以发送大量数据,例如1GB,TCP 会帮我将其分块,发送数据过去,接收端会负责将所有数据重新组装成我最初发送的流。

Jon Calhoun: 你提到数据可能会乱序到达……你之前的比喻是通过窗户大喊和邻居对话,通常对话不会乱序发生。但是在网络中,我认为有时我们很容易忽略这一点,因为我们假设这些事情是连续发生的,尤其是在编程中,直到你进入并发领域,事情才会并行发生。你能详细说明一下数据是如何乱序的吗?有哪些情况会导致这种情况?

Adam Woodbeck: 好的,可能会出现这样的情况:一个数据包通过一条路径到达谷歌,而另一个数据包通过另一条路径。我没有一条直接从我家到谷歌的电缆,所以到谷歌不止一条路径。我们谈的是互联网,它本质上是一个网状结构。我可以从这里发送数据包到我的互联网服务提供商(ISP),而我的 ISP 可以通过不同的端点发送数据,数据包会沿着最短路径逐步传递,直到到达谷歌。而这个最短路径可能会根据当时的网络情况发生变化。

所以我不能保证我发送的所有数据包都会走与之前相同的路径。它们可能会走不同的路径。我们可能正在通信,结果一棵树倒下,摧毁了一条线路;或者有人剪断了我正在通信的光纤电缆,结果这些数据包必须通过其他途径发送。因此,它们不一定会按顺序到达。但当数据包到达接收缓冲区时,接收端的 TCP 会开始将它们重新排序,然后才将它们提供给应用程序读取。如果有任何数据丢失,TCP 会向我发送请求,询问“嘿,我收到了这些数据包,但这里有一组数据包丢失了,我需要你重新发送这些。” 这就是所谓的选择性确认,我们还没有详细讨论过。

Kris Brandow: 我觉得除了数据包可能通过不同路径发送外,它们也可能被丢弃。

Adam Woodbeck: 完全正确。

Kris Brandow: 如果某些网络设备负载过重,它可能会简单地丢弃所有这些数据包,因为它无法处理或处理这些数据包。在 IP 协议下,这是完全有效的行为。所以正如 Adam 刚才提到的,TCP 可以请求重新发送数据包。这也是数据包乱序的原因之一。

Jon Calhoun: 明白。

Adam Woodbeck: 交换机和路由器可以处理的数据量是有限的,如果它们超载了,它们就会选择丢弃数据包。

Jon Calhoun: 我觉得 TCP 协议最有趣的一点是,我们谈论构建冗余系统时,TCP 似乎是我们接触的最冗余的系统之一……或者至少是我们在互联网上使用的最冗余的系统之一。换句话说,任何设备都可以停止接收数据包,而系统却知道如何处理并继续前进。

Adam Woodbeck: 是的。我对此没有太多经验,但想象一下,如果你在开车时观看 YouTube 视频,使用手机连接到网络,你会不断从一个塔跳到另一个塔,并且获得不同的 IP 地址。然而,从你的角度来看,TCP 能够处理所有这些变化和中断,并适当地缓冲数据,确保播放顺畅。

Jon Calhoun: 说到这点---我不知道这是否太复杂了,但当你开车时,TCP 连接是如何工作的?假设我们在路上行驶,TCP 是如何处理的?它会创建新的连接吗?

Adam Woodbeck: 正如我之前提到的,这个问题有点超出我的经验范围,但我想象的处理方式是:当你切换到不同的基站并获得新的 IP 地址时,应用程序会知道它上次从 YouTube 请求了哪些帧。当它建立新连接时,可能会从上次中断的地方重新请求帧数据。不过如果你观察 YouTube 的进度条,你会发现它会缓冲,也就是说,它下载了比你当前观看更多的视频内容。所以在应用程序层面,它给自己留了一些缓冲空间。

Kris Brandow: 我认为移动网络在背后做了很多魔术,使 TCP 连接看起来像是永远存在的……我们稍后可能会讨论这个问题,这也是 HTTP/3(QUIC)开发的主要原因之一,旨在解决 TCP 连接与移动网络操作方式之间的冲突问题。

Jon Calhoun: 这很有趣,因为当人们设计这些协议时,他们无法预见未来……我想象当时可能很难想象一个人开车时需要保持一个仿佛是单一连接的状态,而实际上却不是。这就是软件的奇妙之处吧---你永远无法真正预测未来的需求。

好的,假设我想深入研究这个问题。我想实际查看这些字节。我该怎么做?我应该从 Go 代码开始,还是应该查看一些工具来监控我的网络流量?你对此有什么建议?

Adam Woodbeck: 我绝对推荐你熟悉一下 Wireshark。如果你正在编写网络代码,并且想了解传输层上发生了什么,Wireshark 是一款必备工具。它是免费的,非常容易安装。只需安装 Wireshark,开始捕获数据,然后在浏览器中访问一个网站,你会看到所有这些流量涌入 Wireshark。你可以使用 Wireshark 过滤出特定的会话,并查看我们之前讨论的三次握手;你可以看到它们建立的序列号,以及这些序列号和确认是如何工作的,类似于我们刚才描述的内容,以及每个网络请求如何优雅地终止。

Jon Calhoun: 好的,大家去看看 Wireshark吧……如果有问题,可以找 Adam。哈哈,我开玩笑的……

Adam Woodbeck: 这方面有一本好书,No Starch 也提供这本书,所以我会推荐你看看。

Jon Calhoun: 太棒了。你提到 TCP 是以字节流的形式传输数据……通常,当我们发送字节时,我们需要某种编码来告知接收方字节的大小……这一部分是谁控制的?它看起来是什么样子的?

Adam Woodbeck: 假设我想通过网络发送一个字符串,或者我想发送数字或其他类型的数据。基本上,我所做的就是将一个字节切片写入网络,而在另一端接收时,接收方只会读取一个字节切片。接收方看到的只是一个字节序列;它并不知道这是一个字符串,或者字符串有多大……因为当我从网络读取数据时,实际上是分配了一个特定大小的字节切片,当我使用该字节切片进行读取时,网络会尝试读取尽可能多的数据字节,直到填满我分配的缓冲区,或者没有更多字节可读。

所以,如果我一次发送了多个字符串给你,而你从网络读取这些字符串,它们都会存储在同一个缓冲区中,即同一个字节切片中。你不会知道一个字符串从哪里开始、另一个字符串从哪里结束。因此,一种行之有效的编码技术是采用类型-长度-值的简单编码方法。你可以规定,第一个字节表示一种类型,1 表示字符串,2 表示整数,等等。接下来的两个或四个字节表示我要发送的有效载荷的长度,而接下来的字节就是那个有效载荷。因此,当我从套接字读取数据时,我可以读取第一个字节,知道“哦,这是一个字符串。” 然后读取接下来的四个字节,转换为一个整数,知道“好吧,他要发送500千字节的数据。” 然后我会知道我需要读取接下来的500千字节的数据,这就是我的字符串。

Jon Calhoun: 嗯,我明白了,这意味着如果我正在编写自己的客户端和服务器程序进行通信,我可以自己定义这种范式,比如“这些字节代表类型,这些字节代表消息的大小”,然后就可以从那里开始了。

那么,这是否意味着像 web 这样的地方会有标准,大家都遵循它?因为显然,我不会去编写一个谷歌服务器,所以我不能告诉他们应该期待什么……所以 web 是不是通过某种标准来工作的?

Adam Woodbeck: 是的,当你请求一个资源时,比如请求一个网页以发送 HTML 页面,你会收到包含内容长度的头部信息……这本质上就是你请求的 HTML 页面内容的长度。

还有其他的编码方法。Go 的标准库中包括 gob,它实际上可以做我们刚刚讨论的事情,比如我可以实例化一个结构体,并将其发送到网络上,然后它可以使用类似于我们刚刚讨论的类型-长度-值格式在另一端正确解码。

Jon Calhoun: 但是这个是需要双方提前知道的吗?还是说是在握手过程中传递的?我想问的是这个。


Adam Woodbeck: 是的,你必须事先约定编码和解码的标准。换句话说,如果我给你发送的是 JSON 数据,你需要知道它会从一个左花括号开始,并以右花括号结束。你必须能理解我发给你的这些混乱的文本;你必须能够理解 JSON。但如果我发给你的是 YAML,而你期望的是 JSON,你就不知道该怎么处理了。它不会按照你期望的方式解码。

Jon Calhoun: 好的,太棒了。那么,当我们处理这些网络连接时---我还有一个问题……你基本上说 TCP 有点像我们现在的对话。那我们需要关闭它吗?因为我猜在 UDP 中,你只是发送消息,然后忘掉它,不用再担心。那么在 TCP 中,你需要担心在对话结束时告诉对方再见吗?这个是怎么操作的?

Adam Woodbeck: 是的,在 Go 语言编程中,当你有一个网络连接对象时,它有一个 close 方法。如果你没有调用这个 close 方法,你这边的对话就不会发送 FIN 信号。它不会终止连接。这样的话,你这边的连接就会保持打开状态,实际上你是在泄漏连接。如果你这样做很多次,你可能会耗尽操作系统可以打开的连接数量,因为你有很多孤立的网络连接,它们处于所谓的“关闭等待”状态,因为你在代码中没有正确地关闭它们。而在 UDP 中,你不需要担心这些。

Kris Brandow: 我有一个关于 TCP 的问题……你提到数据包的序列号,以及我们如何根据字节数递增这些序列号……这些序列号是如何表示的?如果它们是用固定字节数表示的,那如果它溢出了怎么办?我们选择一个随机数并尝试发送---如果它是 32 字节,并且通过 TCP 连接发送了四五个 GB 的数据,它会绕回到起点吗?这是如何处理的?

Adam Woodbeck: 我不太确定。我老实说需要查一下序列号的限制以及它的大小。它是一个非常大的数字;我会很惊讶如果它真的溢出了……但我假设如果它是无符号的,它会绕回并从它结束的地方继续。但基本上,它只是用来指示我发送了你多少数据,以及你接收了多少数据。我们需要在这方面达成一致。

所以如果我给你发送了 5000 字节的数据,我期望你确认它比我上次发送的序列号多了 5000。

Kris Brandow: 明白了。

Adam Woodbeck: 现在,如果它绕回来了或者溢出了,那也没关系……只要它比之前多了 5000 就行。

Jon Calhoun: 如果大家分别存储这些值,而一端搞错了,可能会出现一个奇怪的 bug……但很难说这到底是怎么工作的。

Adam Woodbeck: 你可以在 Wireshark 中看到这个……Wireshark 会为你做很多友好的操作。它会帮你计算窗口大小……在 Wireshark 中所有用方括号括起来的东西,都是 Wireshark 根据你正在查看的数据包计算出来的。所以原始的序列号可能在……它是 6 个字节长;这是我们这里的序列号的长度。但当你查看时,Wireshark 给你的是一个相对的序列号,从 0 开始,就像我们这里讨论的那样,这样你在查看数据包时会更容易理解。如果你想查看原始的序列号,也可以做到。

Jon Calhoun: 我有点好奇……我现在在 Stack Overflow 上查了一下,看来它确实是一个 32 位的数字,Chris……

Kris Brandow: 哦。

Jon Calhoun: 看起来,通常情况下,你应该选择一个起始序列号,理论上你不会超过这个限制……它看起来确实会在需要时绕回去,然后你用时间戳来处理这个问题。

Kris Brandow: 哦……

Adam Woodbeck: 是的,你得发送非常大量的数据才能绕回到自己,对吧?

Kris Brandow: 我是说,这是 4 个 GB,对吧?这是大约 40 亿字节;这是你用 32 位无符号数能表示的范围。如果你下载一个 10 GB 的应用程序或视频文件,超过了 TCP 连接的限制,你可能会看到它绕回来了……

Jon Calhoun: 是的,这很有趣,因为我们现在已经到了一个 4 GB 不是很多的地步。以前这是个大数目,但现在你经常下载 30 GB 的游戏……嗯,可能不是所有人,但如果你是一个游戏玩家,你可能会定期下载 30 GB 的游戏。

所以,是的,这会让事情变得非常棘手。这又是一个例子,最初设计 TCP 时,他们可能说“哦,我们永远不会达到这个数字,别担心。”但后来他们又说“我们需要想个办法解决这个问题……”

Kris Brandow: 是啊,就像 IPv4 地址……[笑]

Jon Calhoun: 我不确定我们今天是否要讨论这个,因为---如果我没记错的话,TCP 和 IP 最初不是合在一起的一体化协议吗?

Adam Woodbeck: 是的,它曾被称为---我记得是传输控制程序(Transmission Control Program)……直到后来他们把 TCP 跟 IP 分开了。分开之后,他们就能够实现 UDP。但对,TCP 和 IP 以前是一个单一的协议。

Jon Calhoun: 很高兴他们有远见,能够将这两个拆分……否则的话,尝试将 TCP 升级到 IPv6 可能会非常麻烦……好的,我猜我们接下来要讨论的是 UDP。我们讨论了 TCP,它是一个有状态的连接,因为你必须跟踪发送了多少字节,以及对话中的所有信息……而 UDP 就像你说的那样,只是发送消息;那它在实践中是什么样的?显然,我们不会随机选择一个 IP 地址发送随机数据……你能举些例子说明一下吗?

Adam Woodbeck: 当然。DNS 可能是大多数人会遇到的最常见的 UDP 应用,即使你可能没有意识到。基本上,我会发送一个 UDP 包---通常大小不会超过 512 字节---给一个域名服务器,询问它一个 IP 地址,比如 Google.com 的 IP 地址。通常它会回复我一个答案。如果它没有回复---因为我不会收到它已收到我请求的确认---我只是得到一个答案,或者没有得到。如果我没有及时收到,我会再次发送请求,一次又一次,直到我不再想问了,或者我得到了一个回复。

如果我们用 TCP 来做同样的请求,我们需要经过三次握手,我得发送请求,它会确认,然后它发送结果,我再确认,然后我们才会断开这个连接。这样的话,会有更多的请求往返;如果我们使用 TCP 来做这个,开销会大得多。

但对 UDP 来说,考虑到我们处理的是少量信息---大多数情况下小于 512 字节,当然也有例外---UDP 是非常合适的。我只需要发送一个请求,得到一个答案,一直问,直到我得到我期望的结果。

Jon Calhoun: 我想到一个比喻,回到你之前提到的馅饼的例子。如果你给某人送一个馅饼,你肯定希望他们能确认他们在那儿接收;你不希望馅饼就那样被放在他们门口。但假设他们的信件在你的邮箱里,如果你只是想把信拿过去塞进他们的信箱,你不需要确认他们收到了信,也不需要与他们进行对话。这只是把信塞进去,然后忘掉它,因为你不太在意这个。不需要花上五分钟和他们寒暄。

我猜你说的一个 DNS 使用 UDP 的主要好处就是,通常只需要两条消息:一条是“给我这个信息”,另一条是返回信息。这比进行整个握手、花费大量时间进行不必要的对话要高效得多。

那么,如果我想在 Go 代码中实现这个,我该怎么做呢?有哪些包可以使用?或者你会建议我再次使用 Wireshark?有什么方法可以用来尝试 UDP 吗?

Adam Woodbeck: Go 对 UDP 的支持很好。如果你使用过 Go 的 TCP,你可能熟悉 net 包中的 Conn 接口。如果你使用 dialdial timeout,或者 net 包中的 Dialer 有一个 dial context 方法。当你调用这个方法时,你会得到一个 Conn 接口,允许你做几件事---你可以写入网络套接字,你可以从中读取,你可以更改期限(我们还没讨论过),你还可以通过这个接口对连接进行一些调整。你还可以对 TCPConn 结构体做类型断言,这是该接口下的对象,它给了你更多的选项。你可以修改接收窗口、发送窗口等。你还可以启用 keepalive。如果你只是用 TCP,大多数情况下,Conn 接口就足够了。

我提到这些是因为当你使用 dial 函数时,你可以指定一个网络类型为 UDP,你仍然会得到一个 Conn 对象。然而,底层的连接现在是一个 UDP 连接,它的功能会有点不同。尽管 UDP 是无状态的,但对象知道它是要发送给谁的。换句话说,当你创建这个连接对象时,即使它是 UDP,你也要给 Go 一个你想要通信的地址,所以当你向这个对象写入数据时,它只会发送给你的那个目标地址。而当你从这个对象读取时,它只会关注来自该目标地址的 UDP 包。

还有一个 PacketConn,这可能更适合 UDP,因为 UDP 在 Go 中没有像 TCP 那样的客户端和监听器。UDP 对象既是客户端,也是监听器。它监听你电脑上的本地端口,网络上的任何地方都可以给它发送消息。所以当你从 UDP 连接读取数据时,你会得到读取的数据量、一个地址和一个错误接口。作为程序员,你需要检查这个地址,看它是谁发来的,因为它不一定来自你正在通信的目标;可能是第三方。所以在应用层面上,通过 UDP 连接管理传入数据需要更多的工作,尤其是使用 PacketConn 时。

Kris Brandow: 我有一个关于操作系统如何处理数据的问题……在 TCP 中,我们有一个已建立的连接,它有一个接收窗口,所以当字节进来时,它会为你保存这些数据,直到你的应用程序读取它们。UDP 也有类似的机制吗?还是说如果它接收到字节,而你不在监听,它就会把它们丢弃?

Adam Woodbeck: UDP 也有接收缓冲区。但就像我们不知道邻居的窗户是否开着一样,我们不知道它是否已经接收了数据。可能数据已经在那边的接收缓冲区中,但我们不知道那边的缓冲区有多满。我们可能一直在填满这个缓冲区,直到它溢出。而一旦溢出,UDP 包就会开始丢失。所以在 UDP 层面,你不会收到远程缓冲区有多满的反馈。你可以继续发送,希望他们能读取这些数据,否则这些包就会开始丢失。但确实有接收缓冲区的概念。

Jon Calhoun: 我有一个关于 net.Conn 接口的问题……至少在我的理解中,当你使用一个接口时,它通常意味着它是可以互换的。比如,你可能有一个用于与数据存储交互的接口,而你不在乎它是用 SQL、Postgres 还是 SQLite 写的;通常你只需要一个接口来与其交互。但听起来在 TCP 和 UDP 中,你得到的 net.Conn 是一样的,但实际使用它们的方式却需要非常不同,因为在 TCP 中你知道消息最终会到达,但在 UDP 中情况完全不同。你觉得这会让人困惑吗?对此你有什么看法?

Adam Woodbeck: 如果你很习惯用 net.Conn 来处理 TCP,我个人的偏好是不使用它来处理 UDP……因为它确实会在行为上有些微妙的变化。比如,我们之前讨论过在 Go 中读取 TCP 连接的数据。你会创建一个一定大小的字节切片,并将数据读入其中,直到它装满,或者没有更多的数据可读。而在 UDP 中,这种情况有所不同。如果我发送了四条消息,四个单独的 UDP 包,它们都在你的接收缓冲区中,如果你使用 net.Conn 并读取数据,即使你分配的字节切片足够容纳这四条消息,每次读取操作只会返回一条消息。所以要读取这四条消息,你需要调用四次 read,如果我们讨论的是 UDP。这和 net.Conn 在 TCP 中的行为不同。

所以我个人的偏好是使用 PacketConn,这样非常明确“好吧,我在代码中使用的是 UDP。”这对我读取别人代码的时候也很有帮助,这样我就知道“好吧,这里使用的是 net.Conn,我对它很熟悉,因为我在 TCP 中经常使用它……但我没有注意到他们现在正在使用 UDP 进行通信。为什么他们要读取四次,而我认为他们分配的缓冲区应该能够一次性读取所有数据?”

Jon Calhoun: 是的,这很有道理。我还记得你提到过 DNS 通信时消息大小是 512 字节;我想你是这么说的。那么消息大小对 UDP 来说重要吗?你刚才提到你可以有更大的缓冲区……

Adam Woodbeck: 是的,这与互联网上常见的 1500 字节的最大传输单元(MTU)有关。这是大多数路由器和交换机能够传输的最大数据包大小。当然,你可以有更大的超级数据包,但 1500 字节是一个不错的目标,尤其是使用 UDP 时……而在 TCP 中,数据包的分片是完全没问题的。所以如果你的 TCP 数据包在传输过程中遇到不支持 1500 字节 MTU 的路由器,它可能会为你分片,然后这些分片会在稍后重新组装,最终你会在目的地收到所有的数据包,这在 TCP 中是有确认的。

但在 UDP 中情况不同。UDP 在传输层没有这种可靠性保障。所以如果有任何分片,每个分片都有可能损坏、丢失,或者乱序到达,因为在 UDP 中没有分片的顺序保证。所以你尽量避免在 UDP 中发生分片。

通常情况下,如果我使用 UDP 发起一个 DNS 请求,而响应超过 512 字节,服务器通常会告诉我答案被截断了。我没有收到完整的答案。作为客户端,我就会知道“好吧,如果我想要完整的答案,我需要重复这个请求,但要通过 TCP 完成。”然后我会通过 TCP 重新发起这个请求,承受往返开销以及建立会话的开销,但最终我会得到完整的答案。

因此,在 TCP 中我们依赖的、甚至有时被我们认为理所当然的可靠性,在 UDP 中是不存在的,你需要意识到 UDP 的这些限制,并在你的代码中加以处理……也就是说,当你从应用层发送数据时,可能需要在数据中包含你自己的序列号。然后在另一端读取数据时,检查这个序列号并决定“这是我期望的顺序”,或者你自己管理顺序,并发送一个 UDP 确认。

所以在 TCP 中的可靠性,在 UDP 中是没有的。但如果你需要 UDP 的速度和可靠性,你作为开发者需要在你的应用层自己实现这些可靠性机制。

Kris Brandow: 关于分片的问题---当数据包在另一端被分片时,你会以两个不同的 UDP 消息接收到它们,还是它们会在操作系统层面合并成一个?


Adam Woodbeck: 据我理解,这并不会重新组合。即使是碎片化的,并且每个碎片都有自己的校验和,它也可能丢失或损坏……我们基本上是把一个数据包分成了多个部分……如果其中任何一个部分损坏、丢失或消失,你就无法重新组装原始的UDP数据包了。整个包基本上是无用的,你需要重新发送它。

Kris Brandow: 明白。

Jon Calhoun: 这样确实会给通过这种方式发送大型消息带来一些复杂性……

Adam Woodbeck: 所以有一个简单文件传输协议(TFTP)……我唯一一次用它是在我把一个Wi-Fi路由器刷坏了之后,这是我唯一能加载固件的方式,就是用TFTP。它是基于UDP的。它基本上有我们刚才谈到的序列号和确认机制。但这又是在应用层。我记得这是书中的第七章,你实际上要写一个TFTP服务器。

Kris Brandow: 我觉得这些网络协议中有趣的一点是,为什么我们按照现在的方式开发TCP……我认为这与网络拥塞有很大关系,以及人们过度使用网络。当网络过载时[无法辨识的 00:49:41.28],这就是为什么我们有窗口大小和其他机制……

我觉得有趣的是,我们现在已经到了一个地步,在某些情况下,这些机制对我们来说成了一种障碍,而不是一种帮助。我认为HTTP的演变过程就是一个很好的例子。我们最开始使用HTTP/1、HTTP/1.1,当时它是一个文本协议,无法实现多路复用;我们遇到了队头阻塞的问题,所以必须打开多个TCP连接来使浏览器更快……然后我们开发了HTTP/2,它增加了多路复用,但仍然是在TCP连接上,我们立刻又遇到更多的队头阻塞问题,因为TCP是有序的。即使你有独立的流在运行,如果其中一个由于处理或其他原因被阻塞,那么所有的流都会被阻塞,这个本来应该更快的协议在网络不好的情况下反而表现得非常糟糕……

我觉得另一件我们也开始意识到的事情是,TCP在你不移动的情况下表现得很好,比如在桌面上,或者你在Wi-Fi环境中,可以建立这些连接。但一旦你在Wi-Fi和移动网络之间切换,你就会遇到问题……因为在移动网络中,它们会让你觉得你的TCP连接还存在。但如果你切换到Wi-Fi,你的TCP连接实际上已经断开了,你需要重新建立连接,这非常耗费资源……所以QUIC/HTTP/3现在为我们提供了一种在不同网络之间切换的能力,你可以在移动网络和Wi-Fi之间切换,并保持连接活跃。但这本质上是在UDP之上重建了我们在TCP中已有的所有功能,因为TCP太过限制了。

Adam Woodbeck: 是的,它将所有的功能都移到了应用层。

Jon Calhoun: Kris,你刚才提到的HTTP/2让我印象深刻的是,当你看到那些把所有JavaScript放在页面底部的网页时,比如引入标签……我猜这就是你说的如果它被阻塞了加载……你是这个意思吗?

Kris Brandow: 我指的是如果你同时尝试加载多个文件,其中一个文件在HTTP应用代码中处理得很慢或有问题,它会阻止其他所有文件的加载。所以如果你正在发送一个巨大的文件,然后还有几个较小的文件,但它们在网络上的传输顺序是较小文件的最后一个字节在较大文件的所有字节之后,那么你必须先读取完较大文件的所有字节,才能读取较小文件的那个字节。

使用UDP,由于这些数据包本身没有内在的顺序,如果你先收到较小的数据包,你可以直接处理它。你不必按照发送者的顺序读取所有内容。这就是在网络不稳定、丢包严重的情况下,人们通常会遇到的队头阻塞问题……因为你在等待这些丢失的数据包被重新传送过来,而在此期间你无法读取后续的包,即使它们与当前正在读取的内容无关。

Jon Calhoun: 所以你谈到的序列字节,我猜测这是一种情况---假设我们处在序列号1的位置,我们收到了从序列号3到1000的所有字节,但还没有收到序列号2的字节,所以我们必须在这里等着,直到收到2号字节,才能处理这些数据。

Kris Brandow: 是的。

Jon Calhoun: 明白。你之前提到的HTTP/3和QUIC协议,我对此一无所知……如果你们想谈论这个,完全可以……但我不能在这部分对话中提供太多帮助。

Kris Brandow: 我们可以从概述开始……我之前简要介绍了我们为什么会走到今天这一步……我觉得有趣的一点是,你可能已经在使用HTTP/3和QUIC了。如果你使用Chrome浏览器并连接到Google的服务,大多数情况下这些服务已经在使用这个新协议了。

我觉得最简单的描述方式是,HTTP 1和2依赖于TCP作为字节排序的机制,它只是把所有的字节按一定格式发送出去;在HTTP/2中有帧格式,所以你可以进行多路复用,比如“好吧,如果我有三个流在发送数据,我会把它们分成更小的部分,并在准备好发送时把它们多路复用到TCP连接上。”

而HTTP/3是更高层次的抽象。所有的排序都在更高层次的抽象中完成……所以不再依赖连接来提供任何排序感知,而是直接在UDP中进行帧的处理,按流的大小分割数据包,每个UDP数据包在发送时都包含所有的标识信息。

我已经提到了,这样做的最大好处是你可以在不同的网络之间切换时保持连接,因为连接信息不再存在于TCP这个协议层级内,而是在应用层中,所以应用程序只需要知道“哦,这是我已经有的这个流的一部分……”

我还想说一点有趣的事情是,无论你使用的是HTTP/1、HTTP/2还是HTTP/3,HTTP本身是保持不变的。这些细节都被抽象掉了,你不需要担心它;大多数编程语言中你与它交互的方式与之前的版本是一样的,这非常酷,也是一个非常有趣的设计模式。

所以尽管我觉得玩TCP和UDP协议并构建东西很有趣,但除非有非常好的理由,否则我几乎总是会选择HTTP。

Jon Calhoun: 是的,我觉得这也是我想做这一期节目的原因之一。总体来说,大多数人使用HTTP包是最好的选择……但了解底层的工作原理也是有益的,因为有时候我们没有时间去深入研究和了解这些实际是如何工作的……而这样做可能非常有趣,也可能激发你去构建自己的一些想法,看看过去人们是如何构建这些东西的。

Kris Brandow: 是的。我觉得大多数人应该去尝试一下TCP和UDP,构建一些东西---可能不适合用于生产环境---这样有助于你理解这些东西是如何工作的。我觉得很多时候这些东西看起来像是魔法一样……“哦,TCP帮我处理了一切”。

我曾经遇到过这样的情况,我们在调试一个应用程序时,有人说“哦,TCP总是能正确地清理连接状态,不会有TCP连接卡住的情况”,但我们调试了一个应用程序,发现有一方认为连接仍然有效,并在36小时内不断尝试写入数据,而另一方早已终止了连接,结果在我们的应用程序中引发了一个巨大的问题。但那些了解TCP工作原理的人会说“这不应该发生,但我们有能力去查看并发现这个问题,并通过实现一个超时机制来确保它不会再次发生。其实应该是一个截止时间,而不是超时。”

但我觉得了解这些东西的工作原理能让它不再那么神秘,这样你就能够调试这种类型的问题,并且理解得更透彻。或者,如果你学得足够多,也许将来你还能设计出类似HTTP/3这样的东西,让世界变得更好。

Jon Calhoun: 是的,我们没有时间详细讨论截止时间和超时问题,但每当你讨论网络相关的东西时,我想这些问题都是很重要的。Adam,你能在节目结束前快速谈谈这个话题吗?

Adam Woodbeck: 当然可以。我们提到TCP有保活机制,它试图保持连接的完整性,或者至少确保对方仍在监听,即使它还没有收到数据。但这并不是最通用的机制,而这些保活包可能会被中间的防火墙过滤掉。所以我更喜欢的方法是在Go中使用截止时间。你可以为你的连接设置一个截止时间,这样一旦达到这个截止时间,任何阻塞的读取或写入调用都会立即返回。

在这种情况下,假设我正在与一个我期望发送数据的客户端通信,但我自己并不经常发送数据。所以我基本上是阻塞在读取调用上。如果发生了某种情况,比如那个客户端消失了,但我没有收到一个FIN信号。那么我就不会收到任何数据。也许有人把那个连接防火墙掉了,或者其他什么情况。所以我仍然保持着这个打开的TCP连接,认为自己在与另一端通信。我当然可以启用TCP保活功能,如果它的另一端也支持的话……但在我的经验中,截止时间是处理这种情况的更好或更通用的方法。

所以每次我从对方收到一条消息时,我可以将这个截止时间推迟,比如推迟10分钟。这意味着在接下来的10分钟内,我会保持这个连接打开,并期望在这10分钟的时间内收到一个回复或消息。否则,我将终止这个TCP连接。我也可以在应用层设置一个类似“乒乓球”或“挑战-回应”的机制,如果我很久没有收到消息,我可以发送一个ping,并期望它能触发一个响应给我,然后我可以再次推迟截止时间。

如果在一定时间内没有收到响应,那么我会让截止时间到期,它会导致我的阻塞读取调用以一个“截止时间超时”错误退出,然后我可以在那时关闭我的连接,或者尝试重新建立一个新的连接。

Jon Calhoun: 这使用的是context包吗?这就是你通常做的吗?

Adam Woodbeck: 不是,在你的网络连接对象net.Conn上,有一个SetDeadline方法,还有SetWriteDeadline和SetReadDeadline方法,所以你可以分别控制读取截止时间、写入截止时间,或者如果你调用SetDeadline,它会同时设置读取和写入的截止时间。默认情况下,Go中的网络连接是没有任何截止时间的,这意味着你会无限期地阻塞,直到操作系统决定“我们不再等待了”并关闭连接,如果它被配置为这样做的话。

Jon Calhoun: 我记得即使在HTTP客户端中,似乎也会遇到类似的情况,人们会设置一个客户端来发起请求,然后我认为你应该设置一个截止时间……虽然我有点忘了,但基本上你设置截止时间是为了避免某些请求无限期地挂起。我知道人们谈到过这种情况会引发各种问题,比如你让它无限期地保持打开状态,而实际上不应该这样做。

Adam Woodbeck: 是的,默认的HTTP客户端没有默认的超时时间。它会无限期地阻塞。

Jon Calhoun: 我们快到节目结束了……对于正在收听的听众,我们计划送出至少两本Adam的书,是实体书,会邮寄给你们……如果你想参与,去关注@Changelog和@GoTimeFM的Twitter账号(特别是@GoTimeFM账号),你有机会获得一本书。如果你还没有看过,可以去No Starch的网站看看这本书。

Adam,在我们结束之前,我们需要听你的一个不受欢迎的观点……

Adam Woodbeck: 好吧,这是一个不受欢迎的观点,但不是那种广泛流传的不受欢迎的观点……

Jon Calhoun: 说实话,这个尺度不一……我们听到过各种不同的意见。

Adam Woodbeck: 好的。

Jon Calhoun: 通常,他们会在Twitter上做一个投票,Mat总是说,如果这个观点被证明是受欢迎的,而并非不受欢迎,那么你就得再回到节目来。这就是你失败的方式,你必须再次回到节目。

Adam Woodbeck: 让我想想---这是一个有争议的观点……我是ThinkPad的忠实粉丝。不过,我也是触控板手势的粉丝;我使用了很多手势。所以我认为联想应该取消ThinkPad上的TrackPoint,并腾出空间放一个更大、更好的触控板,类似于MacBook的触控板。

Jon Calhoun: 所以对于正在听的所有人,你指的是那些笔记本上的那个小红点。

Adam Woodbeck: 是的。物理按钮就在空格键下方。直接取消这些按钮吧……你可以保留那个小红点。如果你想保留那个点,保留它。但请去掉那些物理按钮,给我一个更大的触控板吧,那将是我理想中的笔记本电脑。

Jon Calhoun: 我觉得我们无法讨论这个话题了。

Adam Woodbeck: 我的意思是,无论你是同意还是反对,都会收到仇恨邮件。

Jon Calhoun: 对我来说比较难,因为我已经很久没用ThinkPad了……所以我无法深入参与这个讨论。我不能说我不喜欢那些按钮,但部分原因是我用MacBook已经很久了,所以已经习惯了……我确实想说,有时如果你的掌心碰到了触控板并点击了某个地方---我曾经因为这个意外发送了一封邮件,那是世界上最烦人的事情,我当时就想“该死……”我的邮件才写到一半,所以我就想“是的,这封邮件现在完全没有任何意义了……”但这是个罕见的情况,也就发生过一两次。Kris,你有什么看法吗?

Kris Brandow: 我觉得这是个好主意。我觉得更好的触控板总是好的。手势非常棒。我不知道怎么回到没有手势的电脑上工作。每当我有机会选择魔术鼠标和触控板时,我总是选择触控板,虽然魔术鼠标有一个很小的区域可以做手势,但我想要一个大的区域来做我想要的所有手势。所以任何能让我做更多手势的东西,我都支持。

Jon Calhoun: 我很幸运,大多数时间我都有键盘和鼠标,所以我还好……我习惯了手势,我会用一些,但我确实要说,如果你喜欢自然滚动,那我们绝对不在同一个频道上,Kris,完全不是……

Kris Brandow: [笑] 我就是喜欢……我有一台Linux电脑,它显然没有自然滚动,这让我很困惑。这也是为什么我不太喜欢在我的Mac电脑上用鼠标,因为我……我就是搞不定。但我可以很轻松地切换;如果是触控板的话,我没问题;然后在我的Linux电脑上滚动也是正常的。但我不能用同一只鼠标同时在两台电脑上滚动。

Jon Calhoun: 我遇到过这种情况,当我在帮父母或亲戚修理他们的笔记本电脑时,我会去滚动,结果就想“为什么它不动?”这总是让我摸不着头脑,因为这是我买新MacBook时第一件要改的设置,就是关掉这个。

Kris Brandow: 但你会在手机上改吗?你会在手机上把滚动反过来吗?你能这么做吗?我不确定你能不能。

Jon Calhoun: 我手机上的滚动方式是默认的,这对我来说感觉很正常……但不知为什么,在电脑上我就不行。我就是不习惯它,我不喜欢它。但我想我也改了几个手势。我的设置可能和大多数人有点不同,因为我做了一些调整,比如三指和四指在同一个方向上执行相同的操作,因为我实际上不喜欢它们做不同的事情……

这有点奇怪,因为其中有一个手势我从来不用,所以我就想“让这两个手势做一样的事情吧。”我觉得它们都设置成返回操作了。我得去查一下。你知道的,这种事情你不会真的去记住,直到你在用它的时候……就像设置新手机一样,你会想“某个应用应该放在这里。我不知道是什么,但当我要打开它时,我会记得。”我不知道是不是只有我会这样设置新手机,但这就是我的方式。

Kris Brandow: 我也遇到过这种情况……当我用我的iPhone时,不小心拖动一个应用,把所有图标都移位了,我就会想“我不知道哪个应用放在哪里,但我知道一切都不对了,之后我去打开某个应用时肯定会感到困惑,觉得‘它去哪儿了?这不是它应该在的地方!’”

Jon Calhoun: 好的。Adam,谢谢你加入我们。对于所有正在收听的听众,当你看到投票时,记住要投票决定你是否喜欢他的这个不受欢迎的观点,并去看看Adam的书《Network Programming with Go》。

Adam Woodbeck: 谢谢你,我非常感激。


好文收藏
38 声望6 粉丝

好文收集