操作系统中的网络控制软件(协议栈)的内部如图所示,分为几个部分,分别承担不同的功能。这张图中的上下关系具有一定的规则,上面的部分会向下面的部分委派工作,下面的部分接受委派的工作并实际执行
我们已经了解了协议栈的内部结构,再来了解在数据收发中扮演关键角色的套接字。
在协议栈内部有一块用于存放控制信息的内存空间,这里记录了用于控制通信操作的控制信息,例如通信对象的 IP 地址、端口号、通信操作的进行状态等。本来套接字就只是一个概念,并不存在实体,如果一定要赋予它一个实体,我们可以说这些控制信息就是套接字的实体,或者说存放控制信息的内存空间就是套接字的实体。协议栈在执行操作时需要参阅这些控制信息
知道套接字的具体样子后,再来看看当浏览器调用 socket、connect 等 Socket 库中的程序组件时,协议栈内部是如 何工作的:
创建套接字之后,应用程序(浏览器)就会调用 connect,随后协议栈会将本地的套接字与服务器的套接字进行连接。连接实际上是通信双方交换控制信息,所谓控制信息,就是用来控制数据收发操作所需的一些信息,IP 地址 和端口号就是典型的例子。此外,当执行数据收发操作时,我们还需要一块用来临时存放要收发的数据的内存空间,这块内存空间称为缓冲区,它也是在连接操作的过程中分配的。上面这些就是连接一词的具体含义
控制信息大体上可以分为两类:
第一类是客户端和服务器相互联络时交换的控制信息,这些信息不仅连接时需要,包括数据收发和断开连接操作在内,整个通信过程中都需要,这些内容在 TCP 协议的规格中进行了定义
这些信息会被添加在客户端与服务器之间传递的网络包的开头,在连接阶段,由于数据收发还没有开始,所以网络包中没有实际的数据,只有控制信息。这些控制信息位于网络包的开头,因此被称为头部
第二类控制信息就是,就是保存在套接字中,用来控制协议栈操作的信息。应用程序传递来的信息以及从通信对象接收到的信息都会保存在这里,还有收发数据操作的执行状态等信息也会保存在这里,协议栈根据这些信息来执行每一步的操作
这个过程从应用程序调用 Socket 库的 connect 开始
connect(< 描述符 >, < 服务器 IP 地址和端口号 >, ......)
上面的调用提供了服务器的 IP 地址和端口号,这些信息会传递给协议栈中的 TCP 模块。然后,TCP 模块会与该 IP 地址对应的对象,也就是与服务器的 TCP 模块交换控制信息,这一交互过程包括下面几个步骤:
数据收发操作是从应用程序调用 write 将要发送的数据交给协议栈开始的,协议栈收到数据后执行发送操作。协议栈并不是一收到数据就马上发送出去,而是会将数据存放在内部的发送缓冲区中,并等待应用程序的下一段数据,在数据积累到一定量时再发送出去。至于要积累多少数据才能发送,不同种类和版本的操作系统会有所不同
在发送网络包之后,还需要进行确认操作,确认对方是否成功收到网络包
首先,TCP 模块在拆分数据时, 会先算好每一块数据相当于从头开始的第几个字节,接下来在发送这一块数据时,将算好的字节数写在 TCP 头部中,序号字段就是这个时候派上用场的。然后,发送数据的长度也需要告知接收方,不过这个并不是放在 TCP 头部里面,因为用整个网络包的长度减去头部的长度就可以得到数据的长度,所以接收方可以用这种方法来进行计算。有了上面两个数值, 我们就可以知道发送的数据是从第几个字节开始,长度是多少了
通过这些信息,接收方能够检查收到的网络包有没有遗漏。如果确认没有遗漏,接收方会将到目前为止接收到的数据长度加起来,计算出一共已经收到了多少个字节,然后将这个数值写入 TCP 头部的 ACK 号中发送给发送方。这个返回 ACK 号的操作被称为确认响应,通过这样的方式,发送方就能够确认对方到底收到 了多少数据
在实际的通信中, 序号并不是从 1 开始的,而是需要用随机数计算出一个初始值。在之前的连接过程中,有一个将 SYN 控制位设为 1 并发送给服务器的操作,就是在这一步将序号的初始值告知对方的
我们刚刚只考虑了单向的数据传输,但 TCP 数据收发是双向的,在客户端向服务器发送数据的同时,服务器也会向客户端发送数据,这是我们只需要反过来就可以了
首先,客户端在连接时需要计算出与从客户端到服务器方向通信相关的序号初始值,并将这个值发送给服务器。接下来,服务器会通过这个初始值计算出 ACK 号并返回给客户端,同时,服务器也需要计算出与从服务器到客户端方向通信相关的序号初始值,并将这个值发送给客户端。接下来像刚才一样,客户端也需要根据服务器发来的初始值计算出 ACK 号并返回给服务器。到这里,序号和 ACK 号都已经准备完成了,接下来就可以进入数据收发阶段了。数据收发操作本身是可以双向同时进行的,但 Web 中是先由客户端向服务器发送请求,序号也会跟随数据一起发送。然后,服务器收到数据后再返回 ACK 号。从服务器向客户端发送数据的过程则正好相反
每发送一个包就等待一个 ACK 号的方式是最简单也最容易理解的,但在等待 ACK 号的这段时间中,如果什么都不做那实在太浪费了。为了减少这样的浪费,TCP 采用滑动窗口的方式来管理数据发送和 ACK 号的操作。所谓滑动窗口,就是在发送一个包之后,不等待 ACK 号返回,而是直接发送后续的一系列包。这样一来,等待 ACK 号的这段时间就被有效利用起来了
虽然这样做能够减少等待 ACK 号时的时间浪费,但有一些问题需要注意。在一来一回方式中,接收方完成接收操作后返回 ACK 号,然后发送方收到 ACK 号之后才继续发送下一个包,因此不会出现发送的包太多 接收方处理不过来的情况。但如果不等返回 ACK 号就连续发送包,就有可能会出现发送包的频率超过接收方处理能力的情况
下面来具体解释一下。当接收方的 TCP 收到包后,会先将数据存放到接收缓冲区中。然后,接收方需要计算 ACK 号,将数据块组装起来还原成原本的数据并传递给应用程序,如果这些操作还没完成下一个包就到了也不用担心,因为下一个包也会被暂存在接收缓冲区中。如果数据到达的 速率比处理这些数据并传递给应用程序的速率还要快,那么接收缓冲区中 的数据就会越堆越多,最后就会溢出。缓冲区溢出之后,后面的数据就进不来了,因此接收方就收不到后面的包了,这就和中途出错的结果是一样 的,也就意味着超出了接收方处理能力。我们可以通过下面的方法来避免这种情况的发生。首先,接收方需要告诉发送方自己最多能接收多少数据, 然后发送方根据这个值对数据发送操作进行控制,这就是滑动窗口方式的基本思路
关于滑动窗口的具体工作方式,还是看图更容易理解。在这张图中,接收方将数据暂存到接收缓冲区中并执行接收操作。当接收操作完成后,接收缓冲区中的空间会被释放出来,也就可以接收更多的数据了, 这时接收方会通过 TCP 头部中的窗口字段将自己能接收的数据量告知发送方。这样一来,发送方就不会发送过多的数据,导致超出接收方的处理能力了
此外,单从图上看,大家可能会以为接收方在等待接收缓冲区被填满 之前似乎什么都没做,实际上并不是这样。这张图是为了讲解方便,故意体现一种接收方来不及处理收到的包,导致缓冲区被填满的情况。实际上,接收方在收到数据之后马上就会开始进行处理,如果接收方的性能高,处理速度比包的到达速率还快,缓冲区马上就会被清空,并通过窗口字段告知发送方
要提高收发数据的效率,还需要考虑另一个问题,那就是返回 ACK 号和更新窗口的时机。如果假定这两个参数是相互独立的,分别用两个单独的包来发送,结果会如何呢?
首先,发送方的数据到达接收方,在接收操作完成之后就需要向发送方返回 ACK 号,而再经过一段时间,当数据传递给应用程序之后才需要更新窗口大小。但如果根据这样的设计 来实现,每收到一个包,就需要向发送方分别发送 ACK 号和窗口更新这两个单独的包。这样一来,接收方发给发送方的包就太多了,导致网络效率下降
因此,接收方在发送 ACK 号和窗口更新时,并不会马上把包发送出 去,而是会等待一段时间,在这个过程中很有可能会出现其他的通知操作, 这样就可以把两种通知合并在一个包里面发送了。举个例子,在等待发送 ACK 号的时候正好需要更新窗口,这时就可以把 ACK 号和窗口更新放在 一个包里发送,从而减少包的数量。当需要连续发送多个 ACK 号时,也可以减少包的数量,这是因为 ACK 号表示的是已收到的数据量,也就是说,它是告诉发送方目前已接收的数据的最后位置在哪里,因此当需要连续发送 ACK 号时,只要发送最后一个 ACK 号就可以了,中间的可以全部省略。当需要连续发送多个窗口更新时也可以减少包的数量,因为连续发生窗口更新说明应用程序连续请求了数据,接收缓冲区的剩余空间连续增 加。这种情况和 ACK 号一样,可以省略中间过程,只要发送最终的结果就可以了
对于响应消息,浏览器需要进行接收操作,这一操作也需要协议栈的参与
浏览器在委托协议栈发送请求消息之后,会调用 read 程序来获取响应消息。然后,控制流程会通过 read 转移到协议栈,然后协议栈会执行接下来的操作。和发送数据一样,接收数据也需要将数据暂存到接收缓冲区中
协议栈尝试从接收缓冲区中取出数据并传递给应用程序,但这个时候请求消息刚刚发送出去,响应消息可能还没返回。响应消息的返回还需要等待一段时间,因此这时接收缓冲区中并没有数据,那么接收数据的操作也就无法继续。这时,协议栈会将应用程序的委托,也就是从接收缓冲区中取出数据并传递给应用程序的工作暂时挂起,等服务器返回的响应消息到达之后再继续执行接收操作
协议栈会检查收到的数据块和 TCP 头部的内容,判断是否有数据丢失,如果没有问题则返回 ACK 号。然后, 协议栈将数据块暂存到接收缓冲区中,并将数据块按顺序连接起来还原出原始的数据,最后将数据交给应用程序
收发数据结束的时间点应该是应用程序判断所有数据都已经发送完毕的时候,这时客户端会先发起断开过程。这一判断是应用程序作出的,协议栈在设计上允许任何一方先发起断开过程
完成数据发送的一方会发起断开过程,这里我们以服务器一方发起断开过程为例来进行讲解:
和服务器的通信结束之后,用来通信的套接字也就不会再使用了,这时就可以删除这个套接字了。不过,套接字并不会立即被删除,而是会等待一段时间之后再被删除
等待这段时间是为了防止误操作,引发误操作的原因有很多,例如,如果最后客户端返回的 ACK 号丢失了,服务器没有接收到 ACK 号,可能会重发一次 FIN。这时,客户端的套接字已经删除了,套接字中保存的控制信息也就跟着消失了,套接字对应的端口号就会被释放出来。如果别的应用程序要创建套接字,新套接字碰巧又被分配了同一个端口号,而服务器重发的 FIN 正好到达,这个 FIN 就会错误地跑到新套接字里面,新套接字就开始执行断开操作了
至于具体等待多长时间,这和包重传的操作方式有关。网络包丢失之后会进行重传,这个操作通常要持续几分钟。如果重传了几分钟之后依然无效,则停止重传。在这段时间内,网络中可能存在重传的包,也就有可能发生前面讲到的这种误操作,因此需要等待到重传完全结束。协议中对于这 个等待时间没有明确的规定,一般来说会等待几分钟之后再删除套接字
TCP 整体流程如下: