网络是怎样连接的(十二)—— 数据收发操作中重要标志位 ACK

454 阅读17分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第10天,点击查看活动详情

往期文章

前言

在上一篇文章中我们学习了协议栈收发数据的关键特性:

  1. 不关心数据内容,都看做是二进制数据;
  2. 数据不会立即被发送,而是会被发送到发送缓冲区中;
  3. 大数据会被拆分;

在本文中我们一起来学习下数据收发过程中一个关键的标志位 ACK,这个标志位在 TCP 数据收发操作中起了关键作用,可以说,TCP 协议能保证数据不丢主要是靠 ACK 了,下面我们一起来学习一下 ACK 这个关键标志位的作用、工作原理等。

作用及工作原理

我们先来学习一下 ACK 的作用以及工作原理。ACK 的使用参考 TCP 的连接建立过程,如下图:

TCP 三次握手

作用:确认网络包已收到

网络包被协议栈发送到服务器并非立即终止,TCP 还会 确认对方是否成功收到网络包,以及 当对方没收到时进行重发功能

因此在网络包发送完毕之后,接下来还需要进行确认操作。确认操作即通过 ACK 来实现。

确认原理

那么 ACK 是如何实现数据收到确认的呢?

首先,TCP 模块在拆分数据时,会先算好每一块数据相当于从头开始的第几个字节,接下来在发送这一块数据时,将算好的字节数写在 TCP 头部中,“序号”字段就是派在这个用场上的。然后,发送数据的长度也需要告知接收方,不过这个并不是放在 TCP 头部里面的,因为用整个网络包的长度减去头部的长度就可以的到数据的长度,所以接收方可以用这种方法来进行计算。有了上面两个数值,我们就可以知道发送的数据是从第几个字节开始,长度是多少了。

通过这些信息,接收方还能检查收到的网络包有没有遗漏。比如上次接收到第 n 字节,那么接下来如果收到序号为 n+1 的包,说明中间没有遗漏。但如果收到的包序号大于 n+1,则说明中间有包遗漏了。

像这样,如果确认没有遗漏,接收方会将到目前为止接收到的数据长度架起来,计算出一共已经收到了多少个字节,然后将这个数值写入 TCP 头部的 ACK 号中发送给发送方。这个返回 ACK 号的操作被称为确认响应,通过这样的方式,发送方就能确认对方到底收到了多少数据。

初始值

在实际的通信中,序号并不是从 1 开始的,而是需要用随机数计算出一个初始值,这是因为如果需要都从 1 开始,通信过程就会非常容易预测,有人会利用这一点来发动攻击。但是如果初始值是随机的,那么对方就搞不清楚序号到底是从多少开始计算的,因此需要在开始收发数据之前将初始值告知通信对象。

告知的这个操作是什么时候进行的呢?从上图中我们可以看出是在连接建立过程中,SYN 标志位置为 1,同时设置了 seq 的值,这个值就是序号的初始值。

双向传输

在上图中我们可以看到,在 TCP 三次握手中,客户端和服务端发送的包中都有 seq 标志位,同理,数据发送也是如此,因为数据发送是双向的。在客户端向服务器发送数据的同时,服务器也会向客户端发送数据,即:

客户端和服务端双方都需要建立自己的初始值,并在连接过程中告知对方。

工作过程

了解了 ACK 的工作原理,下面我们再来看看在数据收发过程中 ACK的工作过程:

  1. 客户端提供初始值:客户端在连接时需要计算出从客户端到服务器方向通信相关的序号初始值,并将这个值发送给服务器;
  2. 服务端计算 ACK 号(seq+1),提供初始值,返回给客户端:服务器会通过这个初始值计算出 ACK 号并返回给客户端;初始值有可能在通信过程中丢失,因此当服务器收到初始值后需要返回 ACK 号作为确认;同时,服务器也需要计算出与从服务器到客户端方向通信相关的序号初始值,并将这个值发送给客户端;
  3. 客户端计算 ACK 号(seq+1) :同上述流程,客户端也需要根据服务器发送的初始值计算出 ACK 号并返回给服务器;至此,序号和 ACK 号都已经准备完成了,接下来进入数据收发阶段;
  4. 客户端向服务端发送序号和数据
  5. 服务端收到数据后再返回 ACK 号
  6. 从服务端想客户端发送数据过程则相反

TCO 采用这样的方式确认对方是否收到了数据,在得到对方确认之前,发送过的包都会保存在 发送缓冲区 中,如果对方没有返回某些包对应的 ACK 号,那么就重新发送这些包。

这一机制非常强大。通过这一机制,我们可以确认接收方有没有收到某些包,如果没有收到则重新发送,这样一来,无论网络中发生任何错误,我们都可以发现并采取补救措施(重传网络包)。反过来说,有了这一机制,我们就不需要在其他地方对错误进行补救了。

因此,网卡、集线器、路由器都没有错误补偿机制,一旦检测到错误就直接丢弃相应的包。应用程序也是一样,因为采用 TCP 传输,即便发生一些错误对方最终也能够收到正确的数据,所以应用程序只管自顾自地发送这些数据就好了。不过,如果发生网络中断、服务器宕机等问题,那么无论 TCP 怎样重传都不管用。这种情况下,无论如何尝试都是徒劳,因此 TCP 会在尝试几次重传无效之后强制结束通信,并向应用程序抛错;

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

前面说的知识一些基本原理,实际上网络的错误检测和补偿机制非常复杂,现在来说几个关键的点,首先是返回 ACK 号的等待时间(这个等待时间叫超时时间)。

当网络传输繁忙时就会发生拥塞,ACK 号的返回会变慢,这时我们就必须将等待时间设置的稍微长一些,否则可能会发生已经重传了包之后,前面的 ACK 号才姗姗来迟的情况。这样的重传是多余的,看上去知识多发了一个包而已,但它造成的后果却没那么简单。因为 ACK 号的返回变慢大多是由于网络拥塞引起的,因此如果此时再出现很多多余的重传,对于本来就很拥塞的网络来说无疑是雪上加霜,那么等待时间是不是越长越好呢?也不是。如果等待时间过长,那么包的重传就会出现很大的延迟,也会导致网络速度变慢。

因此等待时间需要设为一个合适的值,不能太长也不能太短。但是这个值却不容易设置,原因是根据服务器物理距离的远近不同,ACK 号的返回时间也会产生很大波动,而且我们还必须考虑到拥塞带来的影响。例如,在公司里的局域网环境下,几毫秒就可以返回 ACK 号,但在互联网环境中,当遇到拥塞时需要几百毫秒才能返回 ACK 号也并不稀奇。

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

使用窗口有效管理 ACK 号

上面我们学习到的一来一回的数据收发操作其实是有限制的,即虽然每发送一个包就等待一个 ACK 号的方式简单且容易理解,但是在等待 ACK 号的这段实践中,如果什么都不做那实在是太浪费了,为了减少这样的浪费,TCP 采用 滑动窗口 方式来管理数据发送和 ACK 号的操作。

所谓滑动窗口,就是在发送一个包之后,不等待 ACK 号返回,而是直接发送后续的一系列包。这样一来,等待 ACK 号的这段时间就被有效利用起来了。

虽然这样做能够减少等待 ACK 号时的时间浪费,但有一些问题需要注意。在一来一回方式中,接收方完成接收操作后返回 ACK 号,然后发送方收到 ACK 号之后才继续发送下一个包,因此不会出现发送的包太多接收方处理不过来的情况。但如果不等返回 ACK 号就连续发送包,就有可能会出现发送包的频率超过接收方处理能力的情况。

下面我们来具体看一下。当接收方的 TCP 收到包后,会先将数据存放到接收缓冲区中。然后,接收方需要计算 ACK 号,将数据块组装起来还原成原本的数据并传递给应用程序,如果这些操作还没完成下一个包就到了也不用担心,因为下一个包也会被暂存在接收方缓冲区中。如果数据到达的速率比处理这些数据并传输给应用程序的速率还要快,那么接收缓冲区中的数据就会越堆越多,最后就会溢出。缓冲区溢出之后,后面的数据就进不来了,因此接收方就收不到后面的包了,这就和中途出错的结果是一样的,也就意味着超出了接收方处理能力。

我们可以通过下面的方法来避免这种情况的发生。首先,接收方需要告诉发送方自己最多能接收多少数据,然后发送方根据这个值对数据发送操作进行控制,这就是滑动窗口方式的基本思路。

关于滑动窗口的具体工作方式如下图所示:接收方将数据暂存到缓冲区中并执行接收操作。当接收操作完成后,接收缓冲区中的空间会被释放出来,也就可以接收更多的数据了,这时接收方会通过 TCP 头部中的 窗口字段 将自己能接收的数据量告知发送方。这样一来,发送方就不会发送过多的数据,导致超出接收方的处理能力了。

image.png

这个能够接收的最大数据量称为窗口大小(一般和接收方的缓冲区大小一致),它是 TCP 调优参数中非常有名的一个。

ACK 与窗口的合并

另外,要提高收发数据的效率,还需要考虑另一个问题,那就是返回 ACK 号和更新窗口的时机。

如果假定这两个参数是相互独立的,分别用两个单独的包来发送,结果会如何呢?

首先,什么时候需要更新窗口大小呢?当收到的数据刚刚开始填入缓冲区时,其实没必要每次都向发送方更新窗口大小,因为只要发送方在每次发送数据时减掉已发送的数据长度就可以自行计算出当前窗口的剩余长度。

因此,更新窗口大小的时机应该是接收方从缓冲区中取出数据传递给应用程序的时候。这个操作是接收方应用程序发出请求时才会进行的,而发送方不知道什么时候会进行这样的操作,因此当接收方将数据传递给应用型恒旭,导致接收缓冲区剩余容量增加时,就需要告知发送方,这就是更新窗口大小的时机。

那么 ACK 号又是什么情况呢?

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

如果将前面两个因素结合起来看,首先,发送方的数据到达接收方,在接收方操作完成之后就需要向发送方返回 ACK 号,而在经过一段时间,当数据传递给应用程序之后才需要更新窗口大小。但如果根据这样的设计来实现,每收到一个包,就需要向发送方分别发送 ACK 号和窗口更新这两个单独的包。这样一来,接收方发给发送方的包就太多了,导致网络效率下降。

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

举个例子,在等待发送 ACK 号的时候正好需要更新窗口,这时就可以把 ACK 号和窗口更新放在一个包里发送,从而减少包的数量。

当需要连续发送多个 ACK 号时,也可以减少包的数量,这时因为 ACK 号表示的是已收到的数据量,也就是说,它是告诉发送方目前已接收的数据的最后位置在哪里,因此当需要连续发送 ACK 号时,只要发送最后一个 ACK 号就可以了,中间的可以全部省略。

当需要连续发送多个窗口更新时也可以减少包的数量,因为连续发送窗口更新说明应用程序连续请求了数据,接收缓冲区的剩余空间连续增加,这种情况和 ACK 号一样,可以省略中间过程,只要发送最终的接口就可以了。

接收 HTTP 响应消息

到这里,我们已经讲完了协议栈接到浏览器的委托后发送 HTTP 请求消息的一系列操作过程了。

不过,浏览器的工作并非到此为止。发送 HTTP 请求消息后,接下来还需要等待 Web 服务器返回响应消息。对于响应消息,浏览器需要进行接收操作,这一操作也需要协议栈的参与。按理说,按照探索之旅的顺序,这一部分应该放在最后讲,不过为了我们能够更容易理解和记忆,我们把这部分内容放在这里讲一讲。

首先,浏览器在委托协议栈发送请求消息之后,会调用 read 程序来获取响应消息。然后,控制流程会通过 read 转移到协议栈,然后协议栈会执行接下来的操作。

和发送数据一样,接收数据也需要将数据暂存到接收缓冲区中,这里的操作过程如下:

首先,协议栈尝试从接收缓冲区中取出数据并传递给应用程序,但这个时候请求消息刚刚发送出去,响应消息可能还没返回。响应消息的返回还需要等待一段时间,因此这时接收缓冲区中并没有数据,那么接收数据的操作也就无法继续进行,这时,协议栈会将应用程序的委托,也就是从接收缓冲区中取出数据并传递给应用程序的工作暂时挂起,等服务器返回的响应消息到达之后再继续执行接收操作。

协议栈接收数据的具体操作过程已经在发送数据的部分讲解过了,因此这里我们就简单总结一下。

首先,协议栈会检查收到的数据块和 TCP 头部的内容,判断是否有数据丢失,如果没有问题则返回 ACK 号。然后协议栈将数据块暂存到接收缓冲区中,并将数据块按顺序连接起来还原出原始的数据,最后将数据交给应用程序。具体来说,协议栈会将接收到的数据复制到应用程序指定的内存地址中,然后将控制流程交回应用程序。将数据交给应用程序之后,协议栈还需要找到合适的实际向发送方发送窗口更新。

总结

这篇文章中,我们学习了在数据收发操作中非常重要的标志位 ACK,它是 TCP 数据准确传输的重要保证。

TCP 通过 ACK 可以确定接收方收到的网络包有没有遗漏

TCP 采用了动态调整等待时间的方法,这个等待时间是根据 ACK 号返回所需时间来判断的。具体来说,TCP 会在发送数据的过程中持续测量 ACK 号的返回时间,如果 ACK 号返回变慢,则相应延长等待时间;相对地,如果 ACK 号马上就能返回,则相应缩短等待时间。

TCP 使用窗口有效管理 ACK 号,所谓滑动窗口,就是在发送一个包之后,不等待 ACK 号返回,而是直接发送后续的一系列包。这样一来,等待 ACK 号的这段时间就被有效利用起来了。

另外,为提高数据收发效率,接收方在发送 ACK 号和窗口更新时,并不会马上把包发送出去,而是会等待一段时间,在这个过程中很有可能会出现其他的通知操作,这样就可以把两种通知合并在一个包里面发送了。

参考文章

  • 《网络是怎样连接的》—— 户根勤