前言:当我们从浏览器输入一个网址并按下回车时,浏览器首先会访问DNS服务器将输入的网址进行域名解析(解析为具体的 ip 地址),之后会根据网址的含义来生成请求消息; 查询到 IP 地址之后,浏览器就可以将请求消息委托给操作系统的协议栈发送给 Web 服务器了 ,但是 操作系统中的协议栈是如何处理数据发送请求的?
从应用程序收到委托后,协议栈通过 TCP 协议收发数据的操作可 以分为 4 个阶段。
- 创建套接字
协议栈的内部结构如图所示:( TCP/IP 软件采用分层结构 ,上层会向下层逐层委派工作。 )
注:网络包:网络中的数据会被切分成几十字节到几千字节的小块,每一个小 数据块被称为一个包。
ICMP 用于告知网络包传送过程中产生的错误以及各种控制消息,ARP 用 于根据 IP 地址查询相应的以太网 MAC 地址。
IP 下面的网卡驱动程序负责控制网卡硬件,而最下面的网卡则负责完 成实际的收发操作,也就是对网线中的信号执行发送和接收的操作 。
- 套接字的实体: 本来套接字就只是一个概念而已,并不存在实体,如果一定 要赋予它一个实体,我们可以说这些控制信息就是套接字的实体 , 或者说存放控制信息的内存空间就是套接字的实体 。这个内存空间记录了用于 控制通信操作的控制信息,例如通信对象的 IP 地址、端口号、通信操作的 进行状态等。
- 套接字的作用: 套接字中记录了用于控制通信操作的各 种控制信息,协议栈则需要根据这些信息判断下一步的行动,这就是套接字的作用。
- 协议栈是根据套接字中记录的控制信息来工作的。
下面来看看 真正的套接字。在 Windows 中可以用 netstat 命令显示套接字内容 。
- 图中每一行相当于一个套接字,当创建套接字时,就会在这里增加一行新 的控制信息,赋予“即将开始通信”的状态,并进行通信的准备工作,如 分配用于临时存放收发数据的缓冲区空间。
调用Socket时的操作:
- 应用程序调用 socket 申 请创建套接字,协议栈根据应用程序的申请执行创建套接字的操作。 在这个过程中,协议栈首先会分配用于存放一个套接字所需的内存空间。 并写入表示这一 初始状态的控制信息。
- 接下来,需要将表示这个套接字的“描述符”告知应用程序 。 描述符相当于用来区分协议栈中的多个套接字的号码牌 。 由于套接字中记录了通信双方的信息以及通信处于怎样的 状态,所以只要通过描述符确定了相应的套接字,协议栈就能够获取所有 的相关信息,这样一来,应用程序就不需要每次都告诉协议栈应该和谁进 行通信了。
2.连接服务器( 客户端套接字向服务器套接字进行连接 )
- 连接:
- 创建套接字之后,应用程序(浏览器)就会调用 connect,随后协议栈会将本地的套接字与服务器的套接字进行连接。 这里的连接实际上是通信双方交换控制信息。
- 连接操作的目的:套接字刚刚创建完成的时候,里面并没有存放任何数据,也不知道通信的对象是谁 。在这个状态下,即便应用程序要求发送数据,协议栈也不知道数据应该发送给谁。 因为在调用 socket 创建套接字时,这些信息并没有传递给协议栈。因此,我们需要把服务器的 IP 地址和端口号等信息告知协议栈。
- 服务器上也会创建套接字, 但服务器上的协议栈和客户端一样,只创建套接字是不知道应该和谁进行通 信的。
- 此外,当执行数据收发操作时,我们还需要一块用来临时存放 要收发的数据的内存空间,这块内存空间称为缓冲区,它也是在连接操作 的过程中分配的。
- 负责保存控制信息的头部:
- 在连接阶段,由于数据收发还没有开始,网络包中没有实际的数据,只有控制信息。这些控 制信息位于网络包的开头,因此被称为头部。此外,以太网和 IP 协议也有 自己的控制信息,这些信息也叫头部。
- 客户端和服务器在通信中会将必要的信息记录在头部并相互确认。 正是有了这样的交互过程,双方才能够进行通信。
- TCP 头部格式:
| 字段名称 | | 长度 (比特) | 含 义 |
| —- | —- | —- | —- |
| TCP 头部 (20 字节 ~) | 发送方端口号 | 16 | 发送网络包的程序的端口号 |
| | 接收方端口号 | 16 | 网络包的接收方程序的端口号 |
| | 序号 (发送数据的顺序编号) | 32 | 发送方告知接收方该网络包发送的数据相当于所 有发送数据的第几个字节 |
| | ACK 号 (接收数据的顺序编号) | 32 | 接收方告知发送方接收方已经收到了所有数据的 第几个字节。其中,ACK 是 acknowledge 的缩写 |
| | 数据偏移量 | 4 | 表示数据部分的起始位置,也可以认为表示头部 的长度 |
| | 保留 | 6 | 该字段为保留,现在未使用 |
| | 控制位 | 6 | 该字段中的每个比特分别表示以下通信控制含义。
URG:表示紧急指针字段有效 ACK:表示接收数据序号字段有效,一般表示数 据已被接收方收到
PSH:表示通过 flush 操作发送的数据
RST:强制断开连接,用于异常中断的情况
SYN:发送方和接收方相互确认序号,表示连接 操作
FIN:表示断开连接 | | | 窗口 | 16 | 接收方告知发送方窗口大小(即无需等待确认可 一起发送的数据量) | | | 校验和 | 16 | 用来检查是否出现错误 | | | 紧急指针 | 16 | 表示应紧急处理的数据位置 | | | 可选字段 | 可变长度 | 除了上面的固定头部字段之外,还可以添加可选 字段,但除了连接操作之外,很少使用可选字段 |
- 通信操作中使用的控制信息分为两类:
- (1) 头部中记录的信息
- (2) 套接字(协议栈中的内存空间)中记录的信息
- 连接操作的实际过程:
- 这个过程是从应用程序调用 Socket 库的 connect 开始的。
- connect(< 描述符 >, < 服务器 IP 地址和端口号 >, …)
- 连接操作的第一步是在 TCP 模块处创建表示连接控制信息的头部。根据服务器 IP 地址和端口号, 客户端(发送方)的套接字就能准确找到服务器(接收方)的套接字,也就是搞清楚了我应该连接哪个套接字。
- 然后,将头部中的控制位的 SYN 比特设置为 1,可以认为它表示连接。此外还需 要设置适当的序号和窗口大小。
- 当 TCP 头部创建好之后,接下来 TCP 模块会将信息传递给 IP 模块并 委托它进行发送。
- 服务器的 TCP 模块会根据 TCP 头部中的信息找到端口号对应的套接字, 并在套接字中写入相应的信息,并将状态改为正在连接。
- 操作完成后,服务器的 TCP 模块会 返回响应,这个过程和客户端一样,需要在 TCP 头部中设置发送方和接收 方端口号以及 SYN 比特。 此外,在返回响应时还需要将 ACK 控制位设为 1,这表示已经接收到相应的网络包。 因为网络中经常会发生错误,网络包也会发生丢失,因此双方在通信时必须相互确认网络包是否已经送达,而设置 ACK 比特就是用来进行确认网络包是否已经送达的。
- 网络包返回到客户端后,通过 IP 模块到达 TCP 模块,并通过 TCP 头部的信息确认连接服务器的操作是否成功。 如果 SYN 为 1 则表示连接成功,这时会向套接字中写入服务器的 IP 地址、端口号等信息,同 时还会将状态改为连接完毕。
- 刚才服务器返回响应时将 ACK 比特设置为 1,相应地, 客户端也需要将 ACK 比特设置为 1 并发回服务器,告诉服务器刚才的响应 包已经收到。当这个服务器收到这个返回包之后,连接操作才算全部完成。
- 收发数据
将 HTTP 请求消息交给协议栈
- 当控制流程从 connect 回到应用程序之后,接下来就会进入数据收发阶段,应用程序调用 write 将要发送的数据交给协议栈;
- 协议栈并不会一收到数据后就会把数据马上发出去,而是先缓存起来,放在内部的发送缓冲区。应用程序交给协议栈发送的数据长度是由应用程序本身来决定的,不同的应用程序在实现上有所不同,有些程序会一次性传递所有的数据,有些程序则会逐字节或者逐行传递数据。
- 总之,一次将多少数据交给协议栈 是由应用程序自行决定的,协议栈并不能控制这一行为。 至于要积累多少数据才能发送,不同种类和版本的操作系统会有所不同,不能一概而论,但都是根据下面几个要素来判断的。
- 第一个判断要素是每个网络包能容纳的数据长度,协议栈会根据一个 叫作 MTU 的参数来进行判断。MTU 表示一个网络包的最大长度,在以太网中一般是 1500 字节。
- 注: MTU:一个网络包的最大长度,以太网中一般为 1500 字节。
MSS:除去头部之后,一个网络包所能容纳的 TCP 数据的最大长度。
起始帧分界符:Start Frame Delimiter,SFD。
FCS:Frame Check Sequence,帧校验序列。
- **另一个判断要素是时间。**
- 当应用程序发送数据的频率不高的时候,如 果每次都等到长度接近 MSS 时再发送,可能会因为等待时间太长而造成发送延迟,这种情况下,即便缓冲区中的数据长度没有达到 MSS,也应该果 断发送出去。为此,协议栈的内部有一个计时器,当经过一定时间之后, 就会把网络包发送出去。
对较大的数据进行拆分
- HTTP 请求消息一般不会很长,一个网络包就能装得下,但如果其中 要提交表单数据,长度就可能超过一个网络包所能容纳的数据量。这时, 发送缓冲区中的数据就会超过 MSS 的长度,这时我们当 然不需要继续等待后面的数据了,而是将发送缓冲区中的数据会被以 MSS 长度为 单位进行拆分,拆分出来的每块数据会被放进单独的网络包中。
应用程序的数据一般都比较大,因此 TCP 会按照网络包的大小对数据进行拆分。
使用 ACK 号确认网络包已收到
- 序号和 ACK 号的用法 : 这个返回 ACK 号的操作被称为确认响应,通过这样的方式,发送方就能够确认对方到底收到 了多少数据。
- 序号和 ACK 号的交互
- 通过“序号”和“ACK 号”可以确认接收方是否收到了网络包。
- 根据网络包平均往返时间调整 ACK 号等待时间(这个等待时间叫超时时间)
TCP 采用了动态调整等待时间的方法,这个等待时间是 根据 ACK 号返回所需的时间来判断的。具体来说,TCP 会在发送数据 的过程中持续测量 ACK 号的返回时间,如果 ACK 号返回变慢,则相应 延长等待时间;相对地,如果 ACK 号马上就能返回,则相应缩短等待 时间。
- 使用窗口有效管理 ACK 号
TCP 采用(b)这样的滑动窗口方式来管理数据发送和 ACK 号的操作。
- 虽然这样做能够减少等待 ACK 号时的时间浪费, 如果不等返回 ACK 号就连续发送包,就有 可能会出现发送包的频率超过接收方处理能力的情况。
- 如果数据到达的 速率比处理这些数据并传递给应用程序的速率还要快,那么接收缓冲区中 的数据就会越堆越多,最后就会溢出。缓冲区溢出之后,后面的数据就进 不来了,因此接收方就收不到后面的包了,这就和中途出错的结果是一样 的,也就意味着超出了接收方处理能力。我们可以通过下面的方法来避免 这种情况的发生。
- 首先,接收方需要告诉发送方自己最多能接收多少数据, 然后发送方根据这个值对数据发送操作进行控制,这就是滑动窗口方式的 基本思路。
如下图是滑动窗口的工作方式:
- 单从图上看,大家可能会以为接收方在等待接收缓冲区被填满 之前似乎什么都没做,实际上并不是这样。这只是 体现一种接收方来不及处理收到的包,导致缓冲区被填满的情况。实际上, 接收方在收到数据之后马上就会开始进行处理,如果接收方的性能高,处 理速度比包的到达速率还快,缓冲区马上就会被清空,并通过窗口字段告 知发送方。
- 实际上和序号、 ACK 号一样,发送操作也是双向进行的。
- ACK 与窗口的合并
- 要提高收发数据的效率,还需要考虑另一个问题,那就是返回 ACK 号和更新窗口的时机。
- 首先,发送方的数据到达接收方, 在接收操作完成之后就需要向发送方返回 ACK 号,而再经过一段时间 A, 当数据传递给应用程序之后才需要更新窗口大小。但如果根据这样的设计 来实现,每收到一个包,就需要向发送方分别发送 ACK 号和窗口更新这 两个单独的包。这样一来,接收方发给发送方的包就太多了,导致网络效率下降。
- 因此,接收方在发送 ACK 号和窗口更新时,并不会马上把包发送出 去,而是会等待一段时间,在这个过程中很有可能会出现其他的通知操作, 这样就可以把两种通知合并在一个包里面发送了。最后也能提高收发数据的效率。
- 接收 HTTP 响应消息
- 浏览器在委托协议栈发送请求消息之后,会调用 read 程序来获取响应消息。
- 然后,控制流程会通过 read 转移到协议栈:首先,协议栈会检查收到的数据块和 TCP 头 部的内容,判断是否有数据丢失,如果没有问题则返回 ACK 号。然后, 协议栈将数据块暂存到接收缓冲区中,并将数据块按顺序连接起来还原出 原始的数据,最后将数据交给应用程序。
- 从服务器断开连接并删除套接字
- 数据发送完毕后断开连接
收发数据结束的时间点应该是应用程序判断所有数据都已经发送完毕的时候。这时,数据发送完毕的一方会发起断开过程,但不同的应用程序会选择不同的断开时机。
这里以服 务器一方发起断开过程为例,如图:
客户端当收到服务器发来的 FIN 为 1 的 TCP 头部时, 客户端的协议栈会将自己的套接字标记为进入断开操作状态。然后,为 了告知服务器已收到 FIN 为 1 的包,客户端会向服务器返回一个 ACK 号。这些操作完成后,协议栈就可以等待应用程序来取数据了。
- 过了一会儿,应用程序就会调用 read 来读取数据 。这时,协议栈不会 向应用程序传递数据 ,而是会告知应用程序(浏览器)来自服务器的数据 已经全部收到了。根据规则,服务器返回请求之后,Web 通信操作就全部 结束了,因此只要收到服务器返回的所有数据,客户端的操作也就随之结束。
- 因此,客户端应用程序会调用 close 来结束数据收发操作,这时客户端的协议栈也会和服务器一样,生成一个 FIN 比特为 1 的 TCP 包,然后 委托 IP 模块发送给服务器。一段时间之后,服务器就会返回 ACK 号。到这里,客户端和服务器的通信就全部结束了。
- 删除套接字
- 和服务器的通信结束之后,用来通信的套接字也就不会再使用了,这 时我们就可以删除这个套接字了。不过,套接字并不会立即被删除,而是 会等待一段时间之后再被删除。
- 假设客户端先发起断开,则断开的操作顺序如下:
(1)客户端发送 FIN
(2)服务器返回 ACK 号
(3)服务器发送 FIN
(4)客户端返回 ACK 号
如果最后客户端返回的 ACK 号丢失了,结果会如何呢?这时,服务 器没有接收到 ACK 号,可能会重发一次 FIN。如果这时客户端的套接字已 经删除了,会发生什么事呢?套接字被删除,那么套接字中保存的控制信 息也就跟着消失了,套接字对应的端口号就会被释放出来。这时,如果别 的应用程序要创建套接字,新套接字碰巧又被分配了同一个端口号 ,而服 务器重发的 FIN 正好到达,会怎么样呢?本来这个 FIN 是要发给刚刚删除的那个套接字的,但新套接字具有相同的端口号,于是这个 FIN 就会错误 地跑到新套接字里面,新套接字就开始执行断开操作了。之所以不马上删 除套接字,就是为了防止这样的误操作。