网络是怎样连接的?

网络连接的整体过程大致就是客户端网络应用程序 发送请求,委托操作系统的 socket 库 和 协议栈(包括 TCP/UDP/IP) 将请求信息一层一层封装成 网络包,通过网卡转化成 光电信号,再由网线或者光纤通过路由器一段一段送达目标服务器,服务器同样由网卡转化光电信号为 数字信号,再由协议栈 层层拆包,送达服务器程序;类似的,服务器也会将响应消息这样送达客户端,完成一次请求响应。
客户端整体结构

整个架构分层是这样的:
(1)应用层在最上面发起请求,构造请求消息; (2)然后调用操作系统提供的 socket 库(socket 库就是操作系统提供的一组 API,主要用于网络数据传输),包括 DNS 解析器(它本身就属于 socket 库的一部分); (3)再来是调用操作系统提供的协议栈,上面部分包括 TCP 和 UDP 协议栈;协议栈下半部分是 IP 协议,把网络包发送给通讯对象的操作就是 IP 协议负责的。IP 协议中还包括 ICMP 协议和 ARP 协议; (4)IP 下面是网卡驱动程序,它负责控制网卡硬件,完成实际的收发操作。具体来说就是把数字信号转化成电信号或者光信号通过网线发送出去,对接收到的电信号或者光信号,又将其转化为数字信号。
应用程序客户端(浏览器)发送、接收消息

其中浏览器和操作系统之间,需要对请求域名进行 dns 查询,找到对应的 ip(这个 ip 可能是 ng 的 ip 或者实际 server 的 ip),然后调用协议栈封包发送到网卡,从而到互联网,继续反向操作拆包等。
dns -- 应用程序 socket 库阶段
socket 库可以理解成 tcp/udp 协议的抽象,http、ftp 等是具体的实现

DNS 服务器地址可以在客户端上指定,比如谷歌的 8.8.8.8,一台指定的 DNS 服务器肯定无法总是及时的更新每天都在增加和注销的域名与对应 IP 的二维表,所以查询 DNS 服务器的过程是一个接力的过程,这台找不到了就到另一台找,找到为止。
DNS 请求底层用的是 UDP 协议,原理可以大致理解为:客户端广播了一条消息,我想要知道 www.baidu.com 这个域名对应的 IP,哪台 DNS 服务器知道的话就给我反馈一下。
委托协议栈发送消息 -- 应用程序 socket 库阶段
知道了 HTTP 请求消息的目标服务器域名对应的 IP 后,终于可以委托操作系统内部的协议栈向这个 IP 发送消息了。所谓协议栈就是操作系统层面的分层设计,简单来讲就是应用层(浏览器、微信、postman)用各种协议标准生成请求消息,并从 DNS 服务器拿到目标服务器 IP 后,开始调用操作系统的另几组通用 API 集合,达到发送数据的目的。这几组 API 集合就包括我们耳熟能详的 TCP 协议、UDP 协议、IP 协议等,但它们并不是并列在同一层面的,而是有一个调用反调用顺序(类似压栈出栈),所以叫协议“栈”。

这里有个重点:当这个协议栈调用到 TCP 协议这一层时,首先需要客户端跟请求 IP 对应的服务器建立一个连接,这样才能保持与其一对一发送、接收数据,并且它设计了一套保持连接、等待主动断开连接的机制,所以 TCP 连接是可靠的、长连接的。
那么这个连接是怎么创建和断开的呢?仍然需要调用操作系统提供的 socket 库里的一组 API,在客户端和服务器之间创建一组套接字,相当于建立一个管道,然后把数据从这个管道里来回接收、发送,直到管道正常断开。如图:

这个管道可以理解为客户端和服务器各自的一个端口,比如客户端的端口 1111 跟服务器的端口 2222 建立了连接,那么在断开之前,这两个各自的端口都是占用状态。而且管道是双向的,既可以从客户端的 1111 向服务器的 2222 发送数据,也可以反过来。断开套接字管道需要一方发起断开请求,客户端和服务器端 任意一方都可以合理发起。
这些建立连接、发送数据、接收数据、断开等操作都是 socket 库提供的 API 做到的。
套接字各个阶段对应的 API
(1)客户端创建套接字就是简单的调用 socket 库中的 socket() 函数,然后该 API 会返回一个描述符(ID),唯一标识在客户端本地的一个套接字;
(2)客户端再调用 socket 库中的 connect() 函数,传入要连接的服务器 IP 和端口、以及刚刚在本地生成的那个套机字 ID,就把本地的这个套接字和服务器 IP、端口对应的一个套机字连接上了管道。这时双方就可以收发数据了;
(3)套接字是双向的,所以假设发送数据的是客户端,那么发送数据就需要调用 socket 库的 write() 函数,把数据发送出去;
(4)作为接收数据的服务器方,应用程序会调用 socket 库的 read() 函数,来把客户端通过套接字发送过来的数据取到并使用;
(5)当需要断开套接字时,客户端或者服务器都可以率先发起断开请求,调用 socket 库的 close() 函数,最终断开并删除套接字,并等待新的连接。

TCP/IP 协议到转化为电信号
创建套接字(tcp 和 udp 套接字格式不同)
套接字的实体就是 通信控制信息,套接字本身是个概念,落实到计算机上就是一块存放控制信息的内存空间,记录了通信对象的 远程 IP 地址、端口号、 通信操作(建立连接、读、写等) 的状态。
大家可以仔细想想,如果让你设计一个套机字机制,你会怎么做?套接字的作用是把本地(客户端)的一个端口和服务器的一个端口关联起来进行通信,那么本地的套接字肯定要存放远程通信机器的 IP 和端口;通信本身有建立连接、读、写、申请断开等操作,那么维护一个状态变量是必要的。同理,远程的那个通信服务器也应该维护一个这样的控制信息,因为是相互通信的。所以,实现很困难,但原理简单。
重要的控制信息的头部
控制信息 除了 IP、端口、连接状态外,还有很多必要的信息。
还要有一系列头部字段(可以认为是键值对)来进行标识,方便控制和处理各个数据包。此外,以太网和 IP 协议也有自己的头部,各个协议的头部设计不一样,但思路都是一样的。
TCP 协议发送的数据包除了发送的头部本身外,还有数据包

协议栈的内部结构

整个架构分层是这样的:
- 调用操作系统提供的协议栈,上面部分包括 TCP 和 UDP 协议栈;
- 协议栈下半部分是 IP 协议,在互联网上传送数据时,数据会被
切割成一个又一个网络包。而把网络包发送给通讯对象的操作就是 IP 协议负责的。IP 协议中还包括ICMP 协议和ARP 协议,ICMP 用于告知网络包传送过程中产生的错误和各种控制信息,ARP 用于根据 IP 地址查询相应的以太网 MAC 地址;
连接
创建套接字后,浏览器会调用操作系统 socket 库的 connect(), 随后协议栈会将本地的套接字和服务器的套接字进行连接。连接 实际上是通信双方交换控制信息。客户端定位到服务器的 IP 和端口,把自己的 IP 和端口号通过数据包发送给服务器,服务器那边的套接字保存该信息,完成了控制信息交换。好比客户端给服务器(此时客户端是知道服务器的 IP 和端口的)说“我想跟你开始通信,我的 IP 是 xxx.xxx.xxx.xxx,端口是 1111”,二者的双向关系就建立了。
整体连接交互图

连接操作(TCP 的三次握手)
(1)客户端申请建立连接,通过将头部的控制位字段 SYN 值设置为 1 来代表连接 ,意思是告诉服务器:我想要和你建立连接了。此外还需要设置适当的 序号 和 窗口大小。总结为 SYN=1、窗口、序号
(2)服务器的 TCP 模块根据收到的 TCP 头部信息中的端口号找到对应的准备好连接的套接字,然后把客户端的 IP 和端口等信息写入该套接字;服务器 TCP 模块会返回响应,这个过程和客户端一样,需要在 TCP 头部设置 SYN 为 1,此外还要设置 ACK 控制位值为 1,表示“我已经收到你的数据包了”。这样做是因为网络传送常常有错误,有丢失,因此双方必须对每个数据包都进行互相确认是否已送达。这样保证了 TCP 是可靠的。 总结为 SYN=1、窗口、序号 +ack
(3)网络包返回客户端,通过 IP 模块传送给 TCP 模块,客户端根据 TCP 头部的信息确认 连接服务器的操作是否成功。如果 SYN 为 1 表示连接成功,客户端将套接字连接状态改为连接完毕;客户端也需要将 ACK 改为 1,然后发回给服务器,告诉服务器刚才的响应包已经收到。服务器收到这个返回包后,连接操作才算完成。 总结为 ack
收发数据包操作
当套接字连接成功后,就进入数据收发阶段了。
应用程序调用 socket 库的 write()函数将要发送的数据交给协议栈,write() 函数需要指定发送数据的长度(数据太长包会很大,所以可以考虑切割,但数据长度是由应用程序指定的)。协议栈并不关心应用程序传来的数据是什么,所以在协议栈看来,要发送的数据就是一定长度的二进制序列。
对较大的数据进行拆分
一个网络包的长度是有上限的,这个最大长度叫做 MSS(最大分段大小 Maximum segment size),如果到达或超过这个上限,那么发送缓冲区的数据就会立刻拆成 MSS 大小的块发送出去。
注:Maximum transmission Unit 最大传输单位
HTTP 请求的消息一般不会很长,一个网络包就能装得下。但如果其中要提交表单数据,长度就可能超过一个网络包所能容纳的数据量,比如在博客上发表一篇长文。这样的大数据会被以 MSS 为单元拆块,拆出来的每个数据都会被放进单独的网络包中,每个数据块前面加上 TCP 头部,并根据套接字中记录的控制信息标记发送方和接收方的端口号,然后交给 IP 模块来执行发送数据的操作。

序号 ack 校验是否收到包和丢包
TCP 具备确认对方是否成功收到网络包,以及当对方没收到时进行重发的功能,因此在发送网络包后,还需要进行确认操作。
首先,TCP 模块在拆分数据时,会先算好每一块数据相当于从头开始的第几个字节,比如在博客发表的那篇长文,被拆成 20 个包,每个包是从该篇文章的第几个字节开始的。
接下来,在发送这一块数据时,将算好的字节数(序号 start)写在 TCP 头部,上文说的 “序号” 字段就派上用场了。
这样接收方就能够检查收到的网络包有没有遗漏,如果没有遗漏,接收方会将目前为止接收到的数据长度加起来,计算一共收到了多少个字节,将这个数值写入 TCP 头部的 ACK 号中发送给对方,这样发送方就能够确认对方到底收到了多少数据。
防丢包重发
协议栈并不是一收到数据就马上发出去,而是将数据存放在内部的发送缓冲区中,并等待应用程序的下一段数据。TCP 为了确认对方是否收到了数据,在得到对方确认之前(响应 ACK 号),发送过的包都会保存在发送缓冲区里,如果对方没有返回某些包对应的 ACK 号,那么就重新发送这些包,这一机制非常强大!
而由于 TCP 是一个双向的连接,即任意一方都能主动发送数据,所以反过来讲,服务器也是客户端,客户端也是服务器,那么在建立连接的过程中,双方就可以把自己的随机初始值序号告诉对方
序号并不是从 1 开始的
在实际的通信中,序号并不是从 1 开始的,而是随机一个 初始值。因为每次都从 1 开始,容易被黑客猜测到然后攻击。但如果初始值是随机的,对方就搞不清序号到底从哪里开始计算,因此需要在建立连接过程中就把这个随机的初始值告诉对方。还记得我们讲连接过程中有一个 SYN 值为 1,另外有个序号初始值吗?这里就派上用场了。
使用滑动窗口(seg)优化 ACK 号
如果 TCP 每发送一个包就等待 ACK 的到来,然后再发第二个包,这样的逻辑是最容易理解的。但是这个等待的过程算是资源的浪费,为了减少浪费,TCP 采用了滑动窗口来管理数据发送和 ACK 号的操作。
所谓 滑动窗口,就是在发送一个包之后,不等待 ACK 号返回,而是直接发送后续的一系列包,这样,等待 ACK 号的这段时间就被充分利用起来了。

虽然这样做能够减少等待 ACK 号的时间浪费,但如果不等待 ACK 号就连续发送包,就有可能出现发送包的频率超过接收方处理能力的情况。
原理是:当接收方的 TCP 收到包后,会先将数据存放到接收缓冲区,然后接收方需要计算 ACK 号,将数据块组装起来还原成原本的数据传递给应用程序。如果这个过程比发送方发包的速率慢,那么接收方的接收缓冲区就会溢出,也就收不到后面的包了。
所以,最好是接收方能每次告诉发送方自己能接收多少数据,这样发送方就可以根据这个值来调整(段)发送的长度,这个 动态的长度数据(段)就是 窗口。窗口是 TCP 调优很重要的一个头部字段。
接收 HTTP 响应消息
这里先讲客户端在得到服务器响应后,如何从 TCP 模块拿到数据并展示和渲染响应。
- 浏览器委托协议栈发送请求后,会调用 socket 库的 read() 函数,尝试获取响应消息;
- 然后 TCP 会尝试从接收缓冲区取出数据并传递给应用程序,如果接收缓冲区并没有数据,协议栈会将应用程序的委托暂时挂起,等待服务器响应到达后再继续执行;
- 如果响应数据已到达,请注意,这里协议栈会将接收到的数据复制到应用程序指定的内存地址中,比如 Java 有自己的 JVM 内存管理。
断开操作(TCP 四次挥手)
当数据传输完毕,套接字连接也就应该断开了。
但申请断开的一方根据各种应用的逻辑而不同,比如 HTTP 1.0,当 web 服务器返回响应消息给浏览器后,服务器就会发起断开过程。但 HTTP 1.1 之后有改动了。总之,协议栈的设计上允许任何一方先发起断开过程,为什么?因为这个连接一直就是双向的,即客户端和服务器本身的位置是相对的。
我们就以 服务器 响应客户端后 发起断开 操作为例,这里也就是热点的四次挥手:
(1)服务器先调用 socket 库的 close() 函数,然后服务器的协议栈会生成包含断开信息的 TCP 头部,具体来说就是将 FIN 值设为 1。然后协议栈委托 IP 模块向客户端发送数据,同时服务器端的套接字中记录断开操作的相关信息。 总结 FIN=1
(2)客户端收到来自服务器的 FIN 为 1 的 TCP 头部,协议栈会将自己的套接字标识为进入断开操作状态,然后为了告知服务器已收到 FIN 为 1 的包,客户端向服务器返回一个 ACK 号。 总结 ack
(3)过了一会儿,客户端的应用程序就会来调用 socket 的 read()来读取数据。这时协议栈会告诉应用程序(浏览器)来自服务器的数据已经全部收到了,此时应用程序会调用 socket 库的 close() 来结束数据收发,这时客户端也会发起一个 FIN 为 1 的 TCP 包,委托 IP 模块发送给服务器。 总结 FIN=1
(4)服务器返回 ACK 号,删除套接字,通信结束。 总结 ack
为什么要四次挥手
等待 2MSL(最大段寿命 Maximum Segment Life),确保有足够的时间让对方收到 ACK,避免新旧连接混淆。

浙公网安备 33010602011771号