TCP/IP简明教程 - 从零构建TCP/IP协议(二)连接,断开与拥塞控制

这是第二篇,主要讲三次握手,四次挥手,滑动窗口,拥塞控制

回顾

上次我们从0构建了一个粗糙的初步可用的协议,叫做PCT协议(文章链接在这里: https://jiajunhuang.com/articles/2017_08_12-tcp_ip.md.html )。

这个协议从0和1的二进制世界,抽象一步到了网卡的层面,所以我们获得了MAC地址, 之后再进一步,于是有了IP地址,最后我们在IP的基础上完成了可以跨主机传输的PCT协议, 如果你还没有看,建议先去看一下上篇 :)。

回顾一下,如果我们的ip地址是192.168.1.1, 端口号是 1, 目标的ip地址是 192.168.1.2, 端口号是 2。那么PCT协议将会发送这样的包:

111(开始)
00000000 11101000(长度)
01110010 01101111 01110101 01110100 01100101 01110010(来源MAC地址)
01110000 01101000 01101111 01101110 01100101 01110010(目标MAC地址)
11000000 10101000 00000001 00000001(来源ip地址)
11000000 10101000 00000001 00000010(目标ip地址)
00000001(来源的端口号)
00000010(目标的端口号)
00000001(发送的包的序号是1)
00000000(已经确认的包的序号是0,表示啥都没有嘛)
01101000 01100101 01101100 01101100 01101111(字符串"hello")
000(结束)

为什么是三次握手和四次挥手?

“你知道TCP/IP吗?三次握手和四次挥手是什么?”这是面试中很常见的一个问题,我第一次 听说这个词语的时候很奇怪,为什么是三次?为什么是四次?

我们来看一下,我们要创建一个可靠的协议,什么是可靠?100%保证送达吗?不是,现实世界 里我们根本做不到无论何时何地100%送达,比如古代的飞鸽传书,鸽子可能跑去和异性约会, 也可能被人烤了。例如我们打电话对方可能占线,也可能上网上着上着光缆被人挖断了。 那怎么定义可靠呢?我们只能说在99%的情况下,只要能送达,我一定送达,要是送不到, 我就告诉你送不到。嗯,这是我自己定义的可靠,当然,可靠系数越高越好。

话说回来,为什么刚好是三次和四次呢?

可以想到,假设我们在一栋墙的两边,只能透过小纸条传递信息,我怎么确定你一定收到了呢? 那当然是有人告诉我你收到了,这就好解决了,有人告诉我你收到了我就觉得你收到了。有人 告诉我要给你转账一百元我就给你转账一百元。。。感觉似乎不太对,最好的方法还是你亲自 写纸条告诉我你收到了,因为可以通过笔迹来确认。

那问题就来了,我给你发送一条信息,你告诉我你收到了,我告诉你我收到了你的收到了,你告诉我 你收到了我收到了你收到了,你。。。

这样设计协议肯定不行,如果这样我们根本没有时间传输真正有用的信息,而是一直在确认 互相收到了。我们能不能把这些信息合在一起呢?我们把协议改一改(看第五条,首尾包裹的部分先忽略):

  • 首先是来源地址的端口号,8个bit来表示
  • 然后是目标地址的端口号,8个bit来表示
  • 然后是这个包的序号,8个bit来表示
  • 然后是确认收到的包的序号,8个bit来表示
  • 然后是这个包的类型,8个bit来表示

我们加入了包的类型,具体定义一下包的类型有哪几种:

  • 0x01 请求进行通信
  • 0x02 同意进行通信
  • 0x03 请求对方关闭通信
  • 0x04 通知对方我方已经关闭
  • 0x05 确认收到了某个包
  • 0x06 包中带有具体传输的内容

这样我们就可以这样干:

A: B啊,我要和你通信可以吗?这个包序号为1
B: 可以啊,通信吧,收到了你序号为1的包,我这个包的序号为1
A: 好啊,那我开始传内容了,并且我还收到了你序号为1的包,我这个包的序号为2
...

瞧,对话的第三回我们就可以确认双方都可以通信了,并且开始带上真正的内容了,让我们 来发个hello试试,当然了,第一篇中的MAC地址等头部我们继续忽略,下同。继续之前的假设, A的IP地址为 192.168.1.1,端口号是1,B的IP地址为 192.168.1.2,端口号是2。

这是第一句:“B啊,我要和你通信”,其中带有的信息有:我要和B请求通信,我之前还没收到 过B的包,我这个包的序号为1。

11000000 10101000 00000001 00000001(来源ip地址)
11000000 10101000 00000001 00000010(目标ip地址)
00000001(来源的端口号)
00000010(目标的端口号)
00000001(发送的包的序号是1)
00000000(已经确认的包的序号是0,表示啥都没有嘛)
00000001(请求进行通信)

然后是B的回复:“通信吧,我这个包的序号是1,我收到了你的序号为1的包”

11000000 10101000 00000001 00000010(来源ip地址)
11000000 10101000 00000001 00000001(目标ip地址)
00000010(来源的端口号)
00000001(目标的端口号)
00000001(发送的包的序号是1)
00000001(已经确认的包的序号是1)
00000010(同意进行通信)

然后就是第三句:“好啊,那我开始传输内容了,我这个包的序号为2,我收到了你序号为1的包”

11000000 10101000 00000001 00000001(来源ip地址)
11000000 10101000 00000001 00000010(目标ip地址)
00000001(来源的端口号)
00000010(目标的端口号)
00000010(发送的包的序号是2)
00000001(已经确认的包的序号是1)
00000110(包中带有具体传输的内容)
01101000 01100101 01101100 01101100 01101111(字符串"hello")

四次挥手,这次我就不把具体的报文写出来了,而是谈谈为什么是四次。其实不是四次, 三次也可以。

假设客户端主动要求断开连接,那对话就是:

A: B啊,我请求断开连接,并且我收到了你的上一个包,序号是n。我这个包的序号是m。
B:好,我收到了你的包m。我这个包的序号是n+1。我也关闭了哦。
A:好的我收到了你的包m, 我这个包的序号是n+2
B: 好的,我收到了你的包n+2,我的包的序号是m+1
...

似乎又陷入了无限循环。我们还得想个办法,跳出这个循环。当A主动发起关闭请求的时候, 就意味着A不会再发送想传输的真实数据了。而且当B收到关闭请求时,如果不能立即返回, 那么A可能会一直重发,但是B又不能立即关闭,因为上层应用可能还要写入数据。所以 我们把B的回复拆成两部分,一部分是确认A的回复,一部分是当B的上层应用关闭之后,告诉 A自己也关闭了。

A: B啊,我想断开连接。并且我收到了你的上一个包n。我的这个包序号是m。
B: 好。我收到了你的包m。我这个包序号是n+1。
B: 我的上层应用也关闭了,意味着我也关闭了。我收到了你的包m。并且请你那边也断掉吧。我这个包的序号是n+2。
A: 好。我知道你也关闭了。我收到了你的n+2。我这个包是m+1。
(B:断都断了,懒得回你。等待一段时间后,断掉连接就是了。)

注:此处与现实世界的TCP实现略有不同,请注意区别 :)。

此处我构造了一个特殊情况:最后B不进行确认。这样是否可行呢?我们来分析一下。当 A说要关闭,意味着A自己不会再写入新的数据,此时B只有可能收到老的数据。当B收到了 A的第一个请求之后,知道A不会再写入新的数据,但是还有可能有老的数据没收到,这个时候 B能做到的就是一方面告诉A,我收到了你这个包(根据上篇,这同时意味着收到了此前的所有 包),另一方面告诉上层的应用,A请求关闭。等上层应用关闭的时候,B再发包,说自己 也要关了。此时A再回复最后一个请求就可以了。因为也就意味着此前的B的包都受到了。然后 自己就关掉。B不用再进行确认,因为A一定已经关掉了(或者说不管A是不是关掉了,我们 都当作已经关掉了)。然后自己再关掉就好了。

初步想来,似乎可行。

另外服务器主动关掉连接,对话也是一样的。因为总是有主动一方和被动一方。

滑动窗口

试想,如果每次我们都要等对方确认了上一个包已经收到了,那么,得有多慢?像我这样 没耐心的人,一定早就炸了。PCT协议也是,必须炸。

我们得想个办法,最简单的办法就是,我们不管对方收没收到,得劲儿发就是,没收到就 再发呗。我记得曾经听人说过有一个网络加速工具就是这种玩法儿,这种玩法有个缺点,就是 费流量,而且有点小缺德,你想啊,路只有那么宽,上面全是你的车,别的车就上不了路了。

不过不管这么多,我们先来看看怎么表示这种方式,最简单的,就是发了哪个包我们就 记下来发了哪个。但是这样很明显,耗内存,这样很容易把内存用完。所以有一个更 简单的方法,按照顺序来发,我们用两个int来保存数组的左右边界,左边是下一个等着确认收到 的,右边是下一个要发的。

00000101010101 [001001001001001010100101] 110101001010010101

一段时间后可能会变成:

0000010101010100100 [1001001001010100101110101001010010101]

这就是传说中的滑动窗口,滑,我滑。中括号左边是已经确认完了对方已经收到的。中间是 发出去了但是还没有确认收到的。右边是还没发送的。

拥塞控制

刚说了,狂发可以,但是有点小缺德。那我们要怎样即充分利用网络,又不会让别人用不了呢? 有个办法,我们可以在每个包里都把当前的窗口大小带上。我们再修改修改协议:

  • 首先是来源地址的端口号,8个bit来表示
  • 然后是目标地址的端口号,8个bit来表示
  • 然后是这个包的序号,8个bit来表示
  • 然后是确认收到的包的序号,8个bit来表示
  • 然后是这个包的类型,8个bit来表示
  • 然后是发送方发送时的窗口大小,8个bit来表示

发个hello:

11000000 10101000 00000001 00000001(来源ip地址)
11000000 10101000 00000001 00000010(目标ip地址)
00000001(来源的端口号)
00000010(目标的端口号)
00000001(发送的包的序号是1)
00000000(已经确认的包的序号是0,表示啥都没有嘛)
00001000(表示此时窗口为8)
01101000 01100101 01101100 01101100 01101111(字符串"hello")

回复一个hello:

11000000 10101000 00000001 00000010(来源ip地址)
11000000 10101000 00000001 00000001(目标ip地址)
00000010(来源的端口号)
00000001(目标的端口号)
00000001(发送的包的序号是1)
00000001(已经确认的包的序号是1)
00001000(表示此时窗口为20)
01101000 01100101 01101100 01101100 01101111(字符串"hello")

这样就可以调整窗口大小了:

  • 如果对方上一个收到的包的窗口比我还大,那说明对方发的比我还多
  • 反之说明我发的太多了,那就缩小窗口

看起来似乎可行。

咦,不对,既然是速率,没有时间怎么能确定速率呢?我们要调整一下协议。

为了方便解释什么是拥塞控制,我们要使用一种带bug的算法。与现实世界的算法相差有点远。

我们记录最后一个发出去未收到的包的发送时间,同时也记录它何时收到。两者 相减便是时间,也就是发一个包要花多少时间。如果时间很短,那么和包的个数 相除,便会得到一个较大的值,如果时间很长,那么便会得到一个较小的值,这样来调整 我们的窗口:

如果这个包发出去到收到,花了10秒钟,那么速率为 1(个)/10(秒) = 0.1(个/秒) 如果这个包发出去到收到,花了1秒钟,那么速率为 1(个)/1(秒) = 1(个/秒) 如果这个包发出去到收到,花了0.1秒钟,那么速率为 1(个)/0.1(秒) = 10(个/秒)

由此我们来调整窗口大小,速率越大,发的越多,当到了顶峰之后,速率会下降,所以窗口 大小开始减小,再之后趋于平缓。如果网络恶化,则窗口主动减小。

综合以上,我们称之为拥塞控制算法。当然,现实世界中的拥塞控制算法复杂得多,上篇 也说了,去详细了解还得看 TCP/IP协议详解 系列。

总结

上篇我们从0构建到了基本的流协议,这一篇我们在此基础上,增加了滑动窗口,发送速度 动态调整(拥塞控制),也讲了一下三次握手四次挥手为什么刚好是三次和四次。当然我需要 再次声明,所谓算法,其实就是一种思路,一种做法,把它从具体的事物中抽象,归纳总结 之后,我们称之为算法。所谓协议,就是一种约定,大家商量好了,这里这样做代表这个意思, 那里那样写代表那个意思。从理论上来说,这两者都不难,但是和现实结合之后,要处理 现实世界的诸多复杂性,复杂度也就随之上升(例如为什么要有拥塞控制而不是自己狂发包, 这就是一个现实问题,为了解决这个问题,我们花费了很多心力,这个问题的复杂度也上升 了许多)。

我一直认为程序员很缺乏表达能力,要能把难的东西用一种简单地方法方式讲出来对程序员 来说并不简单。其实这事儿就跟写代码一样,要能把看起来难的东西拆开来,大事化小, 各个击破。当然这两篇文章也是遵循这种思路,尽管和现实的实现有些脱节,但是把问题 用一种比较简单地方式讲了出来(我希望真的和我想象的一样)。

说实话,看完这两本书之后我理解了TCP/IP协议的很多内容,但是其中具体的部分已经忘光了。 我才不记得什么blabla算法。。。大家学这些理论知识我相信也是一样,其实并不是要求 你记得具体具体怎么做,而是要真正的理解本质,知道为什么这么做。当然了,如果还记得 怎么做那必然是一个大的加分项 :)

PS,最近我都在看Haskell,下一篇很想讲讲我学Haskell的感悟,因为已经地三遍 看 Learn You a Haskell For Great Good这本书了,每次都有新发现 :)


更多文章
  • gRPC-gateway 源码阅读与分析
  • 如何阅读源代码
  • 我心目中的配置中心应该怎么做?
  • 设计一个HTTP网关
  • 设计一个分布式块存储
  • Linux低电量自动关机
  • CGO简明教程
  • 求值策略:Applicative Order vs Normal Order
  • High Performance MySQL阅读笔记
  • MySQL EXPLAIN中的filesort是什么?
  • 数据库索引设计与优化
  • 如何调试?
  • Docker CE 18.03源码阅读与分析
  • 容器时代的日志处理
  • Golang和Thrift