第二章从协议栈这部分来看网络中的通信如何实现,准备从两部分来进行分解。本篇是第一部分:详细介绍TCP协议栈收发数据的过程。
首先来看下面的图。从应用程序到网卡需要经过如下几部分,上面的部分通过委托下面的部分来完成工作。首先是应用程序,通过Socket库来委托协议栈完成工作。Socket库中包含解析器,可以通过向DNS发送信息获取IP地址,这在第一篇【读书笔记-《网络是怎样连接的》- 1】Chapter1-从Web浏览器开始中已经做了介绍。
接下来进入协议栈。协议栈的上半部分分两种:TCP协议与UDP协议。TCP协议与UDP协议会在后面详细介绍,这里只需要先记住浏览器、邮件等一般应用程序收发数据时用TCP,DNS查询等收发较短的控制数据时用UDP就可以了。
下半部分是IP协议控制网络包收发操作的部分。在互联网上传送数据时,数据会被切分成多个网络包,IP协议就负责将网络包发送给通信对象。此外IP中还包括ICMP协议与ARP协议。ICMP用于告知网络包传送过程中产生的错误以及各种控制消息,ARP用于根据IP地址查询相应的以太网MAC地址。
再下面就是网卡驱动程序,控制下一层的网卡完成数据的收发操作。
本篇我们就来看协议栈的上半部分中TCP协议完成数据收发操作的过程。
1. 创建套接字
我们已经知道套接字在收发数据中发挥着关键作用,那么套接字具体是什么?
其实套接字没有实体。协议栈在创建套接字时会分配一块内存,其中存放了通信控制信息,这块内存空间就可以被称为套接字。协议栈在执行操作时要参照这些控制信息。这些控制信息包含什么呢?例如,在发送数据时,需要查看套接字中通信对象的IP地址与端口号。发送数据之后,协议栈会等待对方的响应信息。网络中的数据可能丢失。如果一直等不到响应信息,则需要在等待一定时间之后重新发送,这就需要在套接字中记录是否收到响应信息,以及发送数据后经过的时间等等。
从以上的例子可以看出,协议栈是根据套接字中的控制信息来工作的。
我们来看看实际的套接字。在Windows系统下运行netstat -ano命令,可以查看到如下信息:
展示的每一行信息表示一个套接字。
创建套接字主要有两个步骤。首先协议栈分配一块内存空间,并写入如上初始的控制信息;接下来还需要将套接字的描述符告知应用程序。这样后续应用程序在使用套接字时只需要提供这个描述符即可。通过描述符,协议栈可以定位套接字,并获取所有的相关信息。
2. 建立连接
套接字创建完毕,接下来需要建立连接。
2.1 建立连接的目的
服务器和客户端能实现通信,物理上的网线肯定是连接好的,不需要每次访问都进行插拔网线的操作。那么这里的建立连接是指什么呢?
简而言之,这里建立连接的过程是通信双方交换控制信息,在套接字中记录这些信息并准备收发数据的一系列操作。
套接字刚刚创建完成时,套接字中没有通信对象的信息。在客户端一侧,协议栈不知道应该将信息发给谁。虽然应用程序知道通信对象的信息,如Web客户端知道要通信的服务器IP与端口号,但是在创建套接字的时候,这些信息并没有传递给协议栈,因此建立连接的目的之一就是将这些信息通知协议栈,协议栈将这些信息保存在套接字中。
而在服务器一侧,是创建好了套接字等待客户端一侧的连接。在连接建立之前,服务器端的应用程序和协议栈都不知道客户端通信对象的信息。因此建立连接的第二个目的是客户端向服务器传达开始通信的请求,传送自己的控制信息。
此外,还需要分配一块内存空间用于临时存放收发的数据,称为缓冲区。
2.2 控制信息
以上就是建立连接的目的。所说的控制信息具体包含哪些内容呢?
***控制信息可以分为两类。一类是客户端和服务器端相互通信需要交换的控制信息。***这类信息不仅在建立连接是需要,在后续收发数据以及断开连接的过程中也需要。这些内容在TCP协议的规格中进行了定义,如下表所示。这些字段是固定的,在每次客户端与服务器端进行通信时都需要提供,会被添加在二者之间传递的网络包的开头。由于位于网络包的开头,因此被称为头部。以太网协议与IP协议等也有自己的控制信息,也被称为头部,因此有以太网头部(MAC头部),IP头部,TCP头部等。
***另一类是保存在套接字中的控制信息。***这些控制信息与协议栈的实现有关,如在Windows系统和Linux系统中,协议栈的实现不同,套接字中保存的控制信息就有所不同。但只要根据协议将正确的信息写入了头部,就可以进行正常通信。
2.3 建立连接的实际过程
下面来看连接的实际过程。应用程序调用Socket库中的connect函数,调用connect函数时会传送套接字的描述符与服务器的IP地址、端口号,这些信息会传送给协议栈中的TCP模块。接下来TCP模块会与该IP地址对应的对象,即服务器端的TCP模块进行信息交换,这一过程包括以下几个步骤:
-
客户端创建一个包含表示开始数据收发操作的控制信息的头部,如上表所示。字段很多,主要关注发送方与接收方的端口号。这样客户端套接字就准确找到了服务器端的套接字,然后将头部信息中控制位的SYN比特设置为1,当前可以先理解为表示连接。此外还需要设置序号和窗口大小,会在稍后详细讲解。客户端TCP模块接下来委托IP模块将信息发送给服务器端。
-
在服务器端IP模块接收到信息之后,再传送给服务器端的TCP模块,服务器端的TCP模块会根据端口号找到对应的套接字,也就是在状态为等待连接的套接字中找到与接收信息中端口号一致的套接字。找到之后,这一套接字的状态会被修改为正在连接。完成后,服务器端的TCP模块会返回响应。与客户端操作一样,需要在TCP头部中设置发送发与接收方的端口号以及SYN比特。此外,还需要设置ACK比特。ACK比特用于互相确认信息是否送达,稍后也会进行详细介绍。
-
服务器端的信息返回到客户端,客户端通过TCP头部信息确认连接是否成功。如果SYN比特为1,则表示连接成功,此时会向套接字中写入服务器的IP地址与端口号,并将套接字的状态修改为连接完毕。最后,对于服务器端发来的信息,客户端也需要将ACK比特设置为1并返回服务器。服务器收到响应之后,连接操作才算全部完成了。
3. 收发数据
3.1 平衡发送时间与发送数据量
到此为止,连接建立,接下来就可以进行收发数据的操作了。
对于协议栈来说,应用程序委托其发送的数据只是二进制序列而已,并不关心其具体内容。而且协议栈并不会一收到数据就马上发送出去。如果协议栈一收到数据就马上发送出去,可能会发送大量的小包,导致网络的效率下降。一般会先存放在缓冲区中,积累到一定量时再发送出去。至于积累的量,不同的操作系统也会有所不同,主要考虑的要素有以下两个。
***首先是每个网络包所能容纳的数据长度。***MTU表示一个网络包的最大长度,在以太网中一般是1500字节,其中包含了头部。因此MTU减去头部的长度,得到的就是一个网络包所能容纳的最大数据长度MSS。当数据积累到接近或超出MSS时再进行发送,就可以避免发送大量小包的问题了。
第二个要素是时间。当应用程序发送数据的频率比较低的时候,如果每次都积累到MSS再发送,可能会因为等待时间过长而导致延迟。因此协议栈中有一个计时器,经过一定时间后也会将网络包发送出去。
这两个要素其实是互相矛盾的。如果长度有限,则网络效率得到提高,但可能因为等待填满缓冲区而产生延迟;相反如果时间优先,则延迟时间会变少,但网络效率又降低了。所以在实际发送过程中需要综合考虑这两个要素以达到平衡,因此不同的操作系统在实现上就存在一定的差异。
此外协议栈也给应用程序保留了控制发送的时机。像浏览器这种会话型的应用程序在向服务器发送数据时,延迟会产生很大影响,因此一般会使用直接发送的选项。
对于一次发送大量数据的情况下,缓冲区中的数据远远超出MSS的长度,这时候就需要将缓冲区中的数据以MSS为单位拆分成许多块,每一块加上TCP头部后就可以委托给IP模块进行发送了。
3.2 序号与ACK号
TCP协议的一项重要功能就是确认对方是否成功收到网络包。因此发送网络包之后,还需要进行确认操作。
首先,TCP模块在拆分数据时,会计算处每一块数据相当于从头开始的第几个字节,在发送时会将这个字节数写在TCP头部中,这就是“序号”字段。此外对方还可以通过网络包的长度减去头部的长度,计算得到发送数据的长度。
通过序号和长度,对方可以确认接收的数据有没有遗漏。如果上次接收到第1460字节,下一次接收从1461字节开始的包,则说明中间没有遗漏。如果确认没有遗漏,接收方会将到目前为止接收到的数据长度加起来,计算一共收到了多少字节,将这个字节数写入到TCP头部的ACK号来返回给发送方。这样发送方就可以确认接收方一共收到了多少数据。
原理如此,实际操作过程中还有些变化。由于序号从1开始很容易被预测和攻击,因此序号的初始值是一个随机数,发送方需要在数据收发开始之前将这个初始值告知通信对象。在连接过程中将SYN比特设置为1就是为了实现这一步骤的。在将SYN比特设置为1的同时,会将序号字段设置为初始值。
前面只考虑了单向的传输,反之亦然。客户端需要计算序号发送给服务器端,服务器端收到后会回复ACK号给客户端;服务器端也需要计算出一个序号发送给客户端,客户端收到后同样会回复ACK号给服务器端。如下图所示。TCP采用这样的方法确认对方是否收到了数据,在得到对方确认之前,发送过的包都会保存在发送缓冲区中。如果对方没有返回某些包对应的ACK号,那么就重新发送这些包。因为有了这一机制,在网络的其他部分也不需要对错误进行补救了。在网卡、集线器、路由器等部分,一旦检测到错误就直接丢弃相应的包。
当然,如果出现了服务器宕机等情况,无论重试多少次都不会收到响应,TCP在重试几次后会强制结束通信,并向应用程序报错。