前言
书接上文,第一章我们从解析浏览器中输入的网址开始,探索了生成HTTP请求消息,委托操作系统发送消息等步骤。本章,我们将讲解操作系统中的协议栈是如何处理数据发送请求的。我们将从创建套接字、连接服务器、收发数据、断开连接并删除套接字这几个方面开始讲起。
1、创建套接字
1.1、协议栈的内部结构
协议栈是操作系统内的网络控制软件、它和硬件(网卡)共同作用,处理网路数据的收发。协议栈的内部如下图所示

图中最上层的是网络应用程序,他们通过socket库将网路数据收发工作委派给下层;应用程序的下面是Socket库,其中包括解析器,解析器用来向DNS服务器发出查询,它的工作过程我们在第1章已经介绍过了。
在socket库的下层是协议栈,图中可以看到协议栈分层上下两部分;上半部分包括TCP协议和UDP协议,用来应对对数据完整性和收发性能不同的场景。下一半是用IP协议控制网路包收发操作的部分(数据在传输时会被切分成一个一个的网络包),此外,IP中还包括ICMPA协议和ARPB协议。ICMP用于告知网络包传送过程中产生的错误以及各种控制消息,ARP用于根据IP地址查询相应的以太网MAC地址C。
IP下面的驱动程序用来控制网卡,网卡负责完成实际的收发操作。
1.2、套接字
对于套接字想必您一定不会陌生,它在数据收发过程中扮演着极为关键的操作。那么套接字究竟是什么?
在协议栈内部有一块用于存放控制信息的内存空间,这些控制信息主要包括通信对象的IP地址、端口号、通信操作的进行状态等等。套接字只是一个概念,这些控制信息就是套接字的实体。 这些信息在协议栈执行操作时十分重要,比如通过IP和端口确定通信对象,信息操作的进行状态记录了数据发送了多长时间、是否收到了响应并据此确认是否需要重发等。
windows系统中可以通过netstat命令查看套接字内容。让我们来看一看套接字长什么样

上图中每一行都代表一个套接字,当创建套接字时,就会在这里增加一行新的控制信息,赋予"即将开始通信"的状态,并进行通信的准备工作,如分配用于临时存放收发数据的缓冲区空间。
1.3、调用socket时的操作
对套接字有了具体的概念之后,我们来看一看当应用程序调用Socket库中的程序组件时,协议栈内部是如何工作的。如图

在创建套接字阶段,协议栈开辟一块内存空间并写入初始状态的控制信息,之后为这个创建号的套接字生成一个描述符并返回给应用程序。有了描述符后,后续应用程序向协议栈委托数据收发时只需要提供描述符即可。
2、连接服务器
2.1、什么是连接
在网络通信中,连接指的是交换控制信息。我们在前文中提到过什么是控制信息。在应用程序调用socket库的connect方法后,协议栈将通信对象的IP地址和端口号存放到套接字中;对于服务器来说,服务器上也会创建套接字,客户端会向服务器告知自己的IP地址和端口号。连接操作中所交换的控制信息是根据通信规则来确定的,只要根据规则执行连接操作,双方就可以得到必要的信息从而完成数据收发的准备。
此外,当执行数据收发操作时,我们还需要一块用来临时存放要收发的数据的内存空间,这块内存空间称为缓冲区,它也是在连接操作的过程中分配的。上面这些就是"连接"B这个词代表的具体含义。
2.2、负责保存控制信息的头部
关于控制信息,这里在补充一些。之前我们说的控制信息大体上可以分为两类。
第一类是客户端与服务器互相联络时交换的控制信息,这些信息不仅是连接时需要,包括数据收发和断开连接操作在内,整个通信过程都需要。
上图展示了TCP规格中定义的控制信息,这些信息是固定的,在每次通信时都需要提供这些信息。这些信息会被添加在网路包的开头部分,因此也被称为头部。下图展示了包的结构

第二类是保存在套接字中用来控制协议栈操作的信息,应用程序传递来的信息以及从通信对象接收到信息都会保存在这里,还有收发数据操作的执行状态等信息也会保存在这里,协议栈会根据这些信息来完成每一步操作。由于操作系统的实现不同,因此我们没有办法说明协议栈里具体包括了哪些信息。但是像IP地址和端口号这些通用信息都是共通的。且协议栈中的控制信息是对外不可见的,因此只要在通信时按照规则将必要信息写在头部,那么通信就能够实现。
2.3、连接操作的实际过程
我们已经了解了连接的含义,接下来看一看具体的过程。这个过程是从应用程序调用Socket库的connect开始的。
connect(<描述符>, <服务器IP地址和端口号>, ...)
上面的调用提供了服务器的IP地址和端口号,这些信息会传递给协议栈中的TCP模块。然后,TCP模块会与该IP地址对应的对象,也就是与服务器的TCP模块交换控制信息,这一交互过程包括下面几个步骤。首先,客户端先创建一个包含表示开始数据收发操作的控制信息的头部。头部包含很多字段,这里要关注的重点是发送方和接收方的端口号。到这里,客户端(发送方)的套接字就准确找到了服务器(接收方)的套接字,也就是搞清楚了应该连接哪个套接字。然后,我们将头部中的控制位的SYN比特设置为1,可以认为它表示连接。此外还需要设置适当的序号和窗口大小,这一点我们会稍后详细讲解。
当TCP头部创建好之后,接下来TCP模块会将信息传递给IP模块并委托它进行发送。IP模块执行网络包发送操作后,网络包就会通过网络到达服务器,然后服务器上的IP模块会将接收到的数据传递给TCP模块,服务器的TCP模块根据TCP头部中的信息找到端口号对应的套接字,也就是说,从处于等待连接状态的套接字中找到与TCP头部中记录的端口号相同的套接字就可以了。当找到对应的套接字之后,套接字中会写入相应的信息,并将状态改为正在连接。上述操作完成后,服务器的TCP模块会返回响应,这个过程和客户端一样,需要在TCP头部中设置发送方和接收方端口号以及SYN比特(如果由于某些原因不接受连接,那么将不设置SYN,而是将RST比特设置为1)。此外,在返回响应时还需要将ACK控制位设为1,这表示已经接收到相应的网络包。网络中经常会发生错误,网络包也会发生丢失,因此双方在通信时必须相互确认网络包是否已经送达,而设置ACK比特就是用来进行这一确认的。接下来,服务器TCP模块会将TCP头部传递给IP模块,并委托IP模块向客户端返回响应。然后,网络包就会返回到客户端,通过IP模块到达TCP模块,并通过TCP头部的信息确认连接服务器的操作是否成功。如果SYN为1则表示连接成功,这时会向套接字中写入服务器的IP地址、端口号等信息,同时还会将状态改为连接完毕。到这里,客户端的操作就已经完成,但其实还剩下最后一个步骤。刚才服务器返回响应时将ACK比特设置为1,相应地,客户端也需要将ACK比特设置为1并发回服务器,告诉服务器刚才的响应包已经收到。当这个服务器收到这个返回包之后,连接操作才算全部完成。
建立连接之后,协议栈的连接操作就结束了,也就是说connect已经执行完毕,控制流程被交回到应用程序。
3、收发数据
3.1、将数据交给协议栈
应用程序通过调用write将数据交给协议栈,应用程序交给协议栈的数据长度是由应用程序实现的,不同的应用程序实现不同,因此有些应用程序会一次性传递所有数据,而有些应用程序则会逐字节或逐行传递。协议栈在收到应用程序发来的数据后,选择恰当的时间将数据从缓冲区发送出去。
协议栈选择发送时间的参考因素主要有两个,分别是MTU和时间。
- MTU
最大传输单元。指的是一个网络包最大的长度,在以太网中,通常是1500字节 - MSS
除去头部后,网络包所能容纳的TCP数据最大长度 - 时间
协议栈内部有一个计时器。当经过一段时间后,协议栈就会将数据发送出去。
如果以MSS优先,可以尽量避免发送大量小包的问题,但是延迟会变大;如果以时间优先,则可以避免延迟问题,但是网络的使用率会降低。不过,TCP协议规格中并没有告诉我们怎样才能平衡,因此实际如何判断是由协议栈的开发者来决定的,也正是由于这个原因,不同种类和版本的操作系统在相关操作上也就存在差异。此外,协议栈允许应用程序指定发送时机,比如如果指定"不等待填满缓冲区直接发送",则协议栈就会按照要求直接发送数据。像浏览器这种会话型的应用程序在向服务器发送数据时,等待填满缓冲区导致延迟会产生很大影响,因此一般会使用直接发送的选项。
3.2、协议栈打包数据
当缓冲区的数据大于MSS时(应用程序提交的数据比较大),发送缓冲区中的数据会被以MSS长度为单位进行拆分,拆分出来的每块数据会被放进单独的网络包中。根据发送缓冲区中的数据拆分的情况,当判断需要发送这些数据时,就在每一块数据前面加上TCP头部,并根据套接字中记录的控制信息标记发送方和接收方的端口号,然后交给IP模块来执行发送数据的操作。到这里,数据就被发送出去了,但是数据的收发过程还没有结束。
3.3、ACK的作用
我们知道TCP协议是一个可靠的协议,具备确认包是否被成功收到和接收失败时重发的能力,因此在发送网络包之后,还需要对网络包确认。
TCP在拆分数据时,会先计算好每一块数据相当于从头开始的第几个字节,在发送这一块数据时,将算好的字节数写入TCP头部的序号字段中。接受方根据序号来确认数据是否有遗漏,如果没有遗漏则将接目前收到的数据长度加起来,计算出一共收到多少字节并将这个值写入TCP头部的ACK字段告知给发送方。这种方式称为确认响应,如此,发送方就能知道接收方收到了多少数据。
TCP数据收发是双向的,服务器也通过这种方式来保证客户端正确接收到了数据。
- 为了预防攻击,序号的初始值是随机生成的,而不是从1开始。
- 未被确认的网络包会保存在协议栈的缓存中以待重新发送。
-
ACK号的等待时间
ACK号的等待时间指的是发送方收到接收方确认ACK号的包的时间。根据设备的距离和网络的拥塞情况不同,ACK号的等待时间通常不固定。如果等待时间过长会导致较大的延迟,如果设置的时间较短则会重传多余的包,加剧网络拥塞。因此,TCP 采用了动态调整等待时间的方法,这个等待时间是根据 ACK 号返回所需的时间来判断的。具体来说,TCP 会在发送数据的过程中持续测量ACK号的返回时间,如果 ACK 号返回变慢,则相应延长等待时间;相对地,如果 ACK 号马上就能返回,则相应缩短等待时间。
-
滑动窗口
上述每发一个包就等待一个响应的方式可以成为"一来一回"的方式。这种方式下,接收方在接收到响应包前什么都不做,这无疑是很浪费时间的。在实际发送中,TCP协议采用滑动窗口的方式来提高发送效率。所谓滑动窗口方式指的是发送方在发送一个网络包后不等待ACK确认直接发送后续的包,直到填满接收方的缓冲区。滑动窗口的大小就是发送方能够不等待确认而直接发送包的大小。在TCP连接建立起来后,接收方会告知发送方窗口的大小,这个值通常是接收方缓冲区的大小。此后,随着接收方缓冲区对数据的处理,缓冲区的的大小会不断变化,滑动窗口的大小也会不断变化,接收方在每次发送响应包时将最新的大小告知发送方。(应用程序从缓冲区取数据后,会释放缓冲区的空间)。由此可见,如果接收方性能高,处理的快,那么发送方可以一直发送数据。
-
ACK号和滑动窗口的合并
理论上,ACK号的发送时机是接收到包之后立刻返回。而滑动窗口的发送时机其实是应用程序从缓冲区拿走数据(其实是操作系统拿的),释放缓冲区空间导致窗口大小发生变更时。因此接收方在需要发送ACK号和滑动窗口大小时,并不会立马发送,而是等待一段时间,看一看有没有把ACK号和滑动窗口放在一起发送以减少网络包的数量。当需要连续发送多个 ACK 号时,也可以减少包的数量,这是因为 ACK 号表示的是已收到的数据量,也就是说,它是告诉发送方目前已接收的数据的最后位置在哪里,因此当需要连续发送 ACK 号时,只要发送最后一个 ACK 号就可以了,中间的可以全部省略。当需要连续发送多个窗口更新时也可以减少包的数量,因为连续发生窗口更新说明应用程序连续请求了数据,接收缓冲区的剩余空间连续增加。这种情况和 ACK 号一样,可以省略中间过程,只要发送最终的结果就可以了。
3.4、接收响应消息
到这里,委托发送消息的操作就结束了。接下来,应用程序需要从协议栈接收到响应消息。应用程序在发送消息之后,会调用read程序从协议栈获取响应消息。如果此时还没有响应消息到来,则任务被挂起,如果响应消息已经到来,则协议栈会检查接收到的数据块和TCP的头部内容,检查是否有数据丢失,如果没有问题则返回ACK号。然后协议栈将数据块暂存到缓冲区,并将数据块按顺序连接起来还原出原始的数据,最后将数据交给应用程序。
4、从服务器断开连接并删除套接字
这一步主要分为两步步骤,即断开连接和删除套接字,我们先看断开连接。 协议栈的设计允许通信双方任何一方先发起断开流程。这里我们假设服务器先发起断开连接的操作。首先服务器的应用程序会调用close程序,然后协议栈会生成包含断开信息的TCP头部,具体来说就是将FIN为的比特设为1;接下来协议栈委托IP模块向客户端发送数据,同时服务器的套接字中也会记下断开操作的相关信息。当客户端收到服务器发来的FIN为1的TCP头部时,客户端的协议栈会将自己的套接字标记为进入断开的状态,然后向服务器返回一个ACK号表示已经收到了FIN为1的包。当客户端来取数据时,协议栈告知客户端来自服务端的数据已经全部收到了,客户端应用程序会调用close来结束数据的收发操作。此时客户端的协议栈会和服务器一样,生成一个FIN比特为1的TCP包,然后委托IP模块发送给服务器。一段时间之后,服务器就会返回ACK号。到这里,客户端和服务器的通信就全部结束了。这其实就是四次挥手的过程。

连接断开后,接下来需要删除套接字。不过套接字不会被立刻删除,而是需要等待一段时间。这段等待时间是为了防止误操作,举个例子:假设最后一个ACK号丢了,那么服务端会重新发送一个ACK包,在这个重新发送的包到达前如果客户端的删除的套接字的端口被一个新的套接字使用了,那么这个重新发送的包到达后就会使这个新的套接字进入断开操作。所以,在连接断开后一般并不会立刻删除这个套接字,而是需要等待几分钟(重传操作一般在几分钟内结束)。
至此,TCP传输数据的过程就结束了,接下来我们讨论IP和以太网是怎么传输数据的。