前言

这个系列的博客是针对阅读《网络是怎样连接的》([日] 户根勤, 译 [中] 周自恒, ISBN: 9787115441249)一书的记录与思路整理。
有些概念是做纯路由交换的网络工程师很少接触但又很重要的部分,因此整理到这里以做分享。

协议栈 - 数据收发

当控制流程从connect回到应用程序之后,接下来就进入数据的收发阶段了。数据收发操作是从应用程序调用write组件将要发送的数据交给协议栈开始的。

数据类型无关性

协议栈并不关心应用程序传递的数据是什么内容。应用程序调用write时指定发送数据的长度,在协议栈看来,要发送的数据就是一定长度的二进制字节序列而已。

数据发送

协议栈并非一收到数据就马上发送出去,而是将数据存放在内部的发送缓冲区中,等待一段时间再发送。这样做的理由是,应用程序交给协议栈发送的数据长度是由应用程序本身决定的,不同的应用程序在实现上不同,有些程序会一次性传递所有的数据,有些程序会逐字节或逐行传递数据。这是协议栈不能控制的行为,在这样的情况下,如果协议栈一收到来自应用程序的数据就立即发送,就可能会发送大量的小包,导致网络效率下降。因此积累到一定的数量再发送出去,是合理的。至于积累多少数据再发送,不同种类和版本的操作系统会有所不同,不能一概而论。

积累数据的大小有以下两个因素来决定。

  • MTU
    • MTU 表示一个网络包的最大长度,在一般的以太网中,这个值一般设置为1500字节。MTU 是包含头部的总长度,因此需要从MTU减去头部长度,得到的长度就是一个网络包中所能容纳的最大数据长度,这一长度叫做MSS。当应用长度收到的数据长度超过或接近MSS 时发送,可以避免发送大量小包的问题。
  • 时间
    • 当应用程序发送数据的频率不高的时候,如果每次都等待长度接近MSS时再发送,可能会因为等待时间太长而造成发送延迟,这种情况下即使缓冲区的数据长度没有达到MSS,也应该发送出去。为此,协议栈的内部有一个计时器,当经过一定时间后,就会将缓冲区数据打包送出。

判断的因素只有两个,但它们其实是互相矛盾的。如果长度优先,那么网络的效率会提高,但可能会因为等待填满缓冲区而产生延迟;相反地,如果时间优先,那么延迟时间会变少,但又会降低网络的效率。因此,在进行发送操作时需要综合考虑这两个要素以达到平衡。不过,TCP 协议规格中并没有告诉我们怎样才能平衡,因此实际如何判断是由协议栈的开发者来决定的,也正是由于这个原因,不同种类和版本的操作系统在相关操作上也就存在差异。

同时,协议栈给应用程序也保留了控制发送时机的余地。应用程序在发送数据时可以指定一些选项,例如指定“不等待填满缓冲区直接发送”,则协议栈会按照应用程序的要求来处理数据。

对较大数据进行拆分

当应用程序提交到发送缓冲区的数据很大,远远超过MSS的长度时,例如在博客或论坛上发表长文。

这种情况下,发送缓冲区的数据远超MSS 的长度,协议栈无需等待后面的数据即可发送。发送缓冲区的数据会被以MSS长度为单位进行拆分,拆分出来的每块数据会被放进单独的网络包中。每一块数据的前面都会添加上TCP头部,并根据套接字中记录的控制信息标记发送方和接收方端口号,然后交给IP模块执行发送数据的操作。

应用程序数据的拆分发送

使用ACK 号确认网络包已收到

序号的作用:
用来标识每一个TCP 数据包相当于从头开始的第几个字节。
还可以用来检验接收方收到的网络包有没有遗漏。假设,上次收到第1460字节,那么接下来如果收到的序号为1461的数据包,则表明传输过程没有遗漏;但如果收到的包序号为2921,那就说明中间有包遗漏了。

ACK 号的作用:
如果接收方确认接收过程没有遗漏,接收方会将到目前为止接收到的数据长度加起来,计算出一共已收到多少个字节,这个数值就是ACK 号;
也可以将ACK号理解为,下一个接收的数据包中期待的序号。

“简单来说,发送方说的是“现在发送的是从第 ×× 字节开始的部分,一共有 ×× 字节哦!”而接收方则回复说,“到第 ×× 字节之前的数据我已经都收到了哦!”

序号不是从0或1开始的

一般来说为了讲解的方便,大家习惯描述一个TCP连接的序号是从0或1开始的(其实在wireshark 的抓包软件中,会针对TCP流将序号显示一个相对序列号,TCP 的首包seq 相对序列号为0)。但在实际通信过程中,序号是需要用随机数计算出一个初始值。这是因为如果序号都从0 或1开始,通信过程就会非常容易预测,有人会利用这一点来发动攻击。

数据双向传输的序列号和ACK

序号和ACK 是TCP 协议进行数据确认的思路,这种想法能够确认单向的数据传输。但是TCP 的数据收发是双向的,所以我们需要在连接时,分别同步客户端和服务器两端的序列号。这样在TCP两端收发的数据就能通过各自的序列号和ACK 值来进行确认了。

根据网络包平均往返时间调整ACK号等待时机

在TCP中采用了动态调整等待时间的方法,为什么?

真实的网络环境千变万化,如果将ACK 号返回的等待时间设定为一个固定值,这显然无法满足很多时候网络的真实情况。例如,当网络繁忙以至于拥塞的时候,ACK号的返回会变慢。如果这时我们设置的等待值小了,发送方在等待超时后重传报文,ACK 号才姗姗来迟,那么重传就已经发生了。显而易见的,发送方刚刚完成的重传是多余的,而且看似只是多发出了一个包,但对本来已经拥塞的网络无疑是雪上加霜。同样的,将等待时间直接设为一个更大的值,也是不可取的,发送方等待时间过长,也就意味着发送缓冲区的数据无法及时清理,势必也增加了发送方的内存占用等情况。

正因为真实的网络波动是无法预测的,所以将等待时间设置一个固定值并不是一个好办法。因此,TCP采用了动态调整等待时间的方法。简单来说,TCP会在发送数据的过程中持续测量ACK 号的返回时间,如果ACK号返回变慢,则相应延长等待时间;相对地,如果ACK号马上就能返回,则相应的缩短等待时间。

“ 由于计算机的时间测量精度较低,ACK 返回时间过短时无法被正确测量,因此等待时间有一个最小值,这个值在每个操作系统上不一样,基本上是在 0.5 秒到 1 秒之间。”

使用窗口管理ACK号

为什么会有滑动窗口

如果TCP的收发方都是在接收到对方返回ACK号之后再传递下一个数据,即没发送一个包就等待一个ACK号。那么在等待ACK号的这段时间,简直就是浪费资源和时间。为了减少这样的浪费,TCP 采用了这样的办法:发送一个包之后,无需等待ACK号返回,而是直接发送后续的一系列包。

但是直接发送无脑发送数据在某些情况下也是有问题的。当接收方的TCP收到包后,会先将数据存放到接收缓冲区中。然后,接收方需要计算序号,将数据块组装还原成原数据并传递给应用程序。如果这些操作尚未完成下一个数据包就到了,那么接收方会将新到的数据暂存在缓冲区中。这里的问题就是,假设接收方处理数据的效率低于接收数据的速率,那么缓冲区的数据会越来越多,直至缓冲区填满溢出。这样即使后面有数据到达,接收方也只能丢弃数据。所以TCP 需要一个机制来保证接收方接收缓冲区不爆满,想做到这一点,首先,接收方需要告诉发送方自己最多能接收多少数据,然后发送方根据这个值对数据发送操作进行控制。这就是滑动窗口方式。

书中所写是:“接收方收到TCP包后,将其存储到缓冲区中。然后,接收方需要计算ACK号,将数据块组装起来还原成原本的数据并传递给应用程序。”我个人浅薄的理解将数据块重组是依赖序号的,而非ACK号,所以在笔记中直接写的是计算序号,将数据块重新组装。

滑动窗口具体工作原理

滑动窗口展示

以上图为例,接收方首先告知发送方自己的缓冲区大小为4380字节,发送方发送了3个1460字节数据的数据包。这时发送方通过计算得知接收方缓冲区空间已占满,所以发送方停止发送数据,等待接收方更新滑动窗口。当接收方发送消息确认收到以上三个数据包的同时告知发送方自己的缓冲区大小现在是2920字节。那么发送方再次开始发送数据,直到接收方的接收缓冲区占满,或者收到接收方新的缓冲区通知。(ACK 在下一节讨论,此处仅仅只关注窗口)

接收方给发送方告知自己接收缓冲区的大小的字段在TCP包头中被称为窗口字段。

同时,需要注意上图是一个示例,是故意体现一种接收方来不及处理收到的包,导致缓冲区被填满的情况。实际上,接收方在收到数据之后马上就会开始进行处理,如果接收方的性能高,处理速度比包到达速率还要快,缓冲区马上就会被清空,并通过窗口字段告知发送方。

另一个需要注意的点是,图中只显示了一侧的数据收发操作,实际环境中,数据的收发、滑动窗口等是双向 进行的。

ACK与窗口的合并

ACK号是接收方用来向发送方确认已收到发送方发来的数据包。

窗口是接收方用来通知发送方自己接收缓冲区的大小。

两者是相互独立的。那么再深入思考一下,什么时候接收方该更新ACK号和窗口值呢?

当接收方收到数据时,如果确认获取的数据没有问题,就应该向发送方返回ACK号,因此我们可以认为收到数据之后马上应该进行这一操作。

而对于窗口的更新呢?当收到数据刚刚进入接收缓冲区的时候,有必要每次都想发送方更新窗口大小吗?其实是没有必要的,因为发送方在每次发送数据时减掉已发送数据长度就可以自行计算接收方当前接收缓冲区(窗口)的剩余长度了。真正有必要更新窗口大小的时机是在接收方的应用程序已经取出收到的数据并通知协议栈清空已取数据的缓存时,这个操作是发送方无法感知到的,而且这个操作会导致缓冲区剩余容量的增加,因此需要接收方告知发送方。

那么综上所述,没收到一个包,接收方就需要向发送方分别发送ACK号和窗口更新这两个独立的包。这样一来,接收方发给发送方的包就太多了,导致网络效率下降。

因此,实际情况是,接收方在发送ACK号和窗口更新时,不会马上把包发出去,而是等待一段时间,在这个过程中很可能出现其它的通知操作,这样就可以将两种通知合并在一个包中发送了。