应用层,对应着应用程序,这也是程序员打交道最多的一层。
调用系统提供的 网络API 写出的代码都是属于应用层的
应用层有很多现成的协议,但是更多的,还是需要程序员根据实际场景,自定义应用层协议(如:网络传输的数据要怎么使用,数据是什么样的格式,里面包含哪些内容)
自定义协议,要约定好两方面的内容:
服务器和客户端之间要交互哪些信息
数据的具体格式
客户端按照上述约定发送请求,服务器按照上述约定来解析请求;
服务器按照上述约定构造响应,客户端也按照上述约定解析响应。
客户端和服务器之间往往要进行交互的是 “结构化数据”
而网络传输的数据是 “字符串” 或是 “二进制 bit流”
约定协议的过程:就是把结构化数据转成字符串/二进制比特流的过程
结构化数据 转换成 字符串/二进制比特流,这一过程称为 “序列化”
字符串/二进制比特流 转换成 结构化数据,这一过程称为 “反序列化”
结构化数据:数据以 结构体 或 类 的形式表现,包含很多属性
为了让程序员更方便地去约定协议格式,业界给出了几个比较好用的方案,可以直接拿过来套用:
xml
缺陷:冗余信息较多(标签占的字节反而比数据还多)
json(当下一种主流的数据组织格式)
json虽然比xml节省了带宽,但还是有重复的key
值。如响应中的 id和name,如果有一百个“商家”,id和name就会出现一百次
protobuffer(比json更节省带宽,效率最高的一种方式)
只是开发阶段定义出这里都有哪些资源,描述每个字段的含义。
程序真正运行时,实际传输的数据是不包含这样的描述信息
缺陷:这仅仅是对程序的运行效率高,这样的数据是按照二进制的方式来组织的,并不方便程序员来阅读
所以虽然 protobuffer 运行效率更高,但是使用并没有比 json 更广泛
只是对于那些对性能要求非常高的场景,才会使用 protobuffer
运输层,虽然系统内核已经实现好了,但也需要重点关注(TCP/IP,HTTP),使用的 socket API 都是运输层提供的
标识一台主机上进行通信的应用程序(应用程序的 “身份证”)
端口号:2个字节的整数。1 - 1024 都属于系统保留给一些知名的服务器来使用(HTTP-80;HTTS-443)
UDP用户数据报 = 首部 + 数据部分
首部格式如图所示,仅有 4 个字段,每个字段占2个字节,共 8 个字节。
![[Pasted image 20240126185906.png]]
可以看见:协议报头中使用 2 个字节表示端口号,因此,端口号的取值范围就是 0 - 65535
长度:就是表示数据载荷部分有多长(经过以下详解,UDP数据报能发送的数据大小也就接近64KB)
↑一旦数据报的长度超过64KB,那么就可能会导致数据出现 “截断” (截断:数据本来是完整的,但一截断后,后面部分的数据就丢掉了)
对于 UDP 数据报本身来说,Length 字段为 16 位,理论限制为 65535 字节(2^16 - 1),那么能传输的数据为 65535 - IPHeader(20) - UDPHeader(8) = 65507字节。(接近于64KB)
其次,对于网络层,以太网规定 MTU 上限为 1500 字节,如果按照 MTU = 1500 计算,那么 UDP 能传输的数据报上限为 MTU(1500) - IPHeader(20) - UDPHeader(8) = 1472 字节。
- 如果 UDP 数据报小于等于 1472 字节,则正常发送不用分片
- 如果 UDP 数据报超过1472字节,那么移交网络层进行分片并在接收方进行重组
检验和:验证数据在传输过程中是否正确(即差错检测)。
UDP 中使用的就是CRC循环冗余校验码,当然还有一些更高精度的校验和算法:md5算法/sha1算法。
TCP报文段由 首部 和 数据载荷 两部分组成
源端口号(16位) 和 目的端口号(16位) 和 UDP 是一样的
序号(32位):占32位bit,取值范围 [0,2^32 - 1],序号增加到最后一个后,下一个序号就又回到0。指出本TCP报文段数据载荷的第一个字节的序号
如图:这是一个TCP报文段,它由首部和数据载荷两部分构成。
数据载荷中的每个字节数据都有 “序号”,如图所示。但要注意:它们是 字节数据的 “序号”,而不是数据本身的内容!
对于本例来说,首部中 “序号” 字段应填入的十进制值为 “166”,用来指出数据载荷的第一个字节的序号为166
确认号ack(32位):占32位bit,取值范围 [0,2^32 - 1],确认号同序号,也是增加到最后一个,下一个确认号就又回到0。确认号字段的值用来指出期望收到对方下一个TCP报文段的数据载荷的第一个字节的序号,同时也是对之前收到的所有数据的确认
ACK:标明该报文段是否是应答报文(1表示是,0表示否)
注:(小写ack是确认号字段,大写ACK是六位标志位其中之一)
比如:若 “确认号” 为 n,则表明到 “序号” n - 1 为止的所有数据都已正确接收,期望接收序号为 n 的数据
只有当 “确认标志位ACK(如图)” 取值为 1 时,确认号字段才 “有效”;取值为 0 时确认号字段 “无效”
TCP规定:在连接建立后,所有传输的TCP报文段都必须把ACK置为1
数据偏移(4位):占4bit,并以 4 字节为单位。用来指出TCP报文段的数据载荷部分的起始处 距离 TCP报文段的起始处有多远
该字段实际上是指出了 TCP报文段的 首部长度,所以也可以将这个字段称为 “首部长度”
首部固定长度为 20 字节,因此数据偏移字段的二进制最小值为 0101
加上最大扩展首部 40 字节,首部固定长度位 60 字节,因此数据偏移字段的二进制最小值为 1111
举例:
假设该TCP报文段首部的 ”首部长度“ 取值为二进制的 0101,那么首部长度就是20字节
因为二进制0101的十进制值为5,而 “首部长度” 字段以 4字节 为单位,因此 5 乘以 4字节 等于 20 字节
如果TCP报文段首部的 “首部长度” 取值为二进制的 1111,那么首部长度就是 60字节
因为二进制1111的十进制值为15,而 “首部长度” 字段以 4字节 为单位,因此 15 乘以 4字节 等于 60字节
保留(6位):保留字段占 6bit,保留为今后使用,目前应置为0
窗口(16位):窗口字段占 16bit,以 字节 为单位。指出发送本报文段的一方的接收窗口。 窗口值作为接收方让发送方设置其发送窗口的依据,这是以接收方的接收能力,来控制发送方的发送能力,称为 “流量控制”
注:发送窗口的大小,还取决于拥塞窗口的大小(即在接收窗口和拥塞窗口中取较小者)
校验和(16位):校验和字段占 16bit,检查范围包括TCP报文段的首部和数据载荷两部分。用来检查整个TCP报文段在传输过程中是否出现了误码
与UDP类似,在计算校验和时,要在TCP报文段的前面加上 12字节 的 “伪” 首部
同步标志位SYN:在TCP连接建立时用来同步序号。
syn就是 synchronized
,但这里的同步和线程那边的同步不一样,这里的 SYN 表达的语义就是:我想和你建立连接
如图所示,这是TCP通过 “三报文握手” 建立连接的过程
TCP客户进程发送的 TCP 连接请求报文段,首部中的同步标志位SYN被置1,表明这是一个TCP连接请求报文段。
TCP服务器进程发送的TCP连接请求 “确认” 报文段,首部中的同步标志位SYN被置1,确认位ACK也被置1,表明这是一个TCP连接请求确认报文段
终止标志位FIN:用来释放TCP连接
如图为TCP通过 “四报文挥手” 释放连接的过程
不管是TCP客户进程还是TCP服务器进程,它们所发送的TCP连接 “释放报文段”,首部中的 终止标志位FIN 都被置1,表明这是TCP连接释放报文段
复位标志位RST:用来复位TCP连接
当RST = 1 时,表明TCP连接出现了异常,必须释放连接,然后再重新建立连接
注:RST 置 1 还可以用来拒绝一个非法的报文段 或 拒绝打开一个TCP连接
推送标志位PSH:接收方的TCP收到该标志位为 1 的报文段会立即将缓冲区中的所有数据推送给应用程序,而不必等到接收缓存区都填满后再向上交付
URG 和 紧急指针字段:用来实现紧急操作。URG取值为 1 时紧急指针字段有效;取值为 0 时紧急指针字段无效
紧急指针(16位):紧急指针字段占 16bit,以字节为单位,用来指明紧急数据的长度。
当发送方有紧急数据时,就可将紧急数据 “插队” 到发送缓存的最前面,并立刻封装到一个TCP报文段中进行发送。
紧急指针会指出本报文段数据载荷部分包含了多长的紧急数据,紧急数据之后是普通数据。
接收方收到紧急标志URG为1的报文段,就会按照紧急指针字段的值,从报文段数据载荷部分取出紧急数据(也就是说这个报文段有紧急数据也有普通数据,先把紧急数据取出来)并直接上交应用程序,而不必在接收缓存区中排队
TCP报文段首部除了 20字节的固定部分,还有最大40字节的选项部分。增加选项可以增加TCP的功能
最大报文段长度MSS选项:TCP报文段数据载荷部分的最大长度
窗口扩大选项:为了扩大窗口(提高吞吐率)
时间戳选项:有以下两个功能
选择确认选项:用来实现选择确认功能
填充:由于选项的长度可变,因此使用 “填充” 来确保报文段首部能被4整除(因为数据偏移字段,也就是表示首部长度的字段,是以4字节为单位的)
TCP的初心:就是为了解决 “可靠传输” 的问题。
当然,网络通信的过程是非常复杂的,没有谁能够保证发送方发送的数据能 100% 地到达接收方。
这里所说的 “可靠性” 指:发送方能够知道接收方是否收到。
而可靠传输的最关键部分,就是TCP的确认应答机制
超时重传,可以认为是 确认应答 的补充
在编程中,TCP Socket
在内核中有一个 接收缓冲区(一块内存空间),发送方发来的数据,是要先放到接收缓冲区中,然后应用程序调用 read() / scanner.next()
才能读到数据。这里的 “读” 操作其实就是在读取接收缓冲区
当数据到达接收缓冲区后,接收方首先会判断这个数据是否已经在之前出现过。而这个判断的依据,就是数据的 “序号”
接下来介绍一个超时重传的机制,它就用到了用序号来分辨重复与不重复的数据 —— 停止-等待协议SW(Stop and Wait)
机制原理:
发送方每发送完一个TCP报文段后,就 停止 发送下一个报文段,必须等待来自接收方的 确认报文ACK 或 否认报文NAK。收到这两个报文,发送方会做不同的应对:
若收到确认报文ACK,则继续发送下一个报文段
若收到否认报文NAK,则立刻重传该报文段
- 这一过程像两个人通信,当一方给另一方说话时,对方一定要回复一下说”我收到了你的消息“
但这个机制会有以下三个问题:
发送方给接收方发送报文段时,该报文段在传输过程中丢失了
解决方案:添加一个超时计时器,如果超时,则重传该报文段
TCP为了保证无论在任何环境下都能进行高性能的通信,因此会动态计算这个最大超时时间:
- Linux中(BSD Unix和Windows也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的 整数倍
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传
- 如果仍然得不到应答,等到 4*500ms 进行重传,以此类推,以指数形式递增
- 累计到一定的重传次数,TCP认为 网络 或者 对端主机 出现 异常,强制关闭连接
ACK 或 NAK 在传输过程中丢失
ACK 或者 NAK 在传输过程中丢失,势必会造成 超时重传,那么发送方就会发送重复的数据,接收方如何判断数据是否是重复的呢?
解决方案:给发送方的报文段加上序号,由于停止-等待协议的特性,只要保证每发送一个新的报文段的发送序号与上一个不同即可。因此采用一个比特来编号就足够了
在实际编程场景中,并不是按照一个比特位来编号的。
应用程序读取数据的时候,是按照序号的先后顺序连续读取的,一定是先读序号小的数据,再读序号大的数据(可以把接收缓冲区的这个队列想象成带有优先级的阻塞队列),此时
socket
API 中就可以记录上次读的最后一个字节的序号是多少。比如:上次读到的最后一个字节的序号是 3000,而新收到的数据包的序号是1001,那么说明这个 1001 数据包一定是已经读取过的了,可以直接将这个新的数据包判定为 “重复的包” 直接丢弃
接收方丢弃重复的数据包,再次给发送方发送ACK,以免再次超时重传(“我再发送一个ACK过去告诉你我已经收到这个数据包了,你别再发了,赶紧地发下一个”)
ACK 没有丢失,仅仅是 “迟到”,导致超时重传,然后发送方收到两份ACK
ACK因为某些原因 “迟到” 了,导致发送方对 0号数据包 的超时重传(0号数据包发送了两次)。
在重传 0号数据包 的过程中,发送方收到了 “迟来的ACK”,于是发送方认为对端接收到了 0号数据包,然后发送 1号数据包。
接收方收到 重传的0号数据包,将该重复的数据丢弃,并又发送了一份ACK,告诉发送方我已经收到了该数据,不要再发了
问题就来了:第二份ACK,本意是告诉发送方,你的0号数据包重复了,不要再发了。但是这一过程中,1号数据包已经发送出去了,发送方就会认为:这第二份ACK,是1号数据包的确认报文ACK
解决方案:给ACK或NAK 编号
总结:对数据包进行编号这件事,是很有意义的!
可以理解为:TCP在内核中的接收缓存区有两个功能:
接收缓冲区可以认为是一个 “优先级队列” ,以序号作为优先级的参考依据
能对数据包去重(不再接收重复的数据)
可以对收到的数据进行排序,按照序号来排序。确保应用程序 读到的数据 和 发送的数据 顺序是一致的!
按序号来决定数据的进入顺序这件事情,有点像我在驾照考试,我的序号是37,我第一个到考场,没用~ 必须要等前面的36个人到,等他们按照序号一次进入候考室,我才能进去。
这件事跟数据交付给应用程序一样的,如果后来的数据能够先进入应用程序,那么就可能会对程序处理逻辑造成影响
注1:上述谈到的 ack、重传、保证顺序、自动去重等等功能,都是TCP内置的,我们使用TCP的API时,像调用 outputStream.write()
这么一个表面上很简单的代码,上述我们谈到的功能就全部自动生效了
注2:如果使用UDP,UDP的API并没有内置这些功能,所以在写的时候就需要程序员自行考虑了
如图所示,TCP连接有以下三个阶段:
在我们写程序时,比如在客户端执行 socket = new Socket(serverIP, serverPort)
这一语句,就是在建立连接。 这一操作,只是调用 Socket
的API,真正连接建立的过程,是在 操作系统内核 里完成的。
确认应答、超时重传、连接管理 给 可靠传输 提供了一个良好的条件
但是凡事有利必有弊,可靠传输的代价就是:传输效率比较低
在确认应答机制下,每次发送一个数据必须得收到 ACK 后才能发送下一个数据,这就导致有很大一部分的时间是花在了等待 ACK 上了
而滑动窗口的提出,就是为了解决上述问题的:滑动窗口就可以在保证可靠传输的基础上,提高效率(这里的提高效率,其实是降低损失,而不是增加速度)
虽然通过这个机制提高了效率,但这效率也不可能高于UDP
如图:滑动窗口的核心操作就是:批量传输,把多次请求的等待时间,使用同一份时间来等,即减少了总的等待时间
丢包会分为两种情况:
数据包都到达了,ACK丢了
这种情况并不要紧,可以通过后续的ACK来确认
数据包丢了
当某一段报文段丢失后,服务器就会一直发送 ack = 1001 的ACK,告诉客户端:”你丫的,我只收到了 1001 之前的数据,我要的是 1001 之后的数据,你在干神魔?“
如果发送端主机连续三次收到同一个 “1001” 的 ACK,就会将对应的数据重新发送
这里的重传机制,不是按照 “超时” 来重传的,而是按照 “次数”,做到了 “针对性” 的重传,哪个丢了就重传哪个,整体的效率没有额外损失,这种重传称为 “快速重传"
这个时候接收方收到 1001 之后,再次返回的ACK就是 ack = 7001 了(因为 2001 - 7000 的数据接收方已经收到,被放到了接收方操作系统内核的接收缓冲区了)
虽然说滑动窗口能让更多的数据等待同一块时间,但窗口大小可以无限大吗?显然是不能的,要知道,TCP安身立命的本钱就是可靠传输,任何提升效率的行为都不应该影响到可靠性
接收方的接收缓冲区满了,如果继续发送数据,接受方只能丢弃这些数据。这和生产者消费者模型理念是一致的。
这也是为什么接收方发送的ACK会带有 rwnd 的值(rwnd代表接收方能接收的窗口大小)
当然,还会出现下述情况:接收方的接收缓冲区满了,发送的ACK中 rwnd 置为 0,此时发送方不能再发送数据。过了一会儿后,接收方接受缓冲区又有了一些存储空间,然后发送 ACK,将 rwnd 置为 300,但这个ACK在中途丢失了,这就会造成 ”A一直在等待B发送非0窗口的通知“ 而 ”B也在一直等待A发送的数据“ 的局面,导致死锁!
当然,这也有解决办法:TCP为每一个连接设有一个持续计时器,只要TCP连接的一方收到对方的 0窗口 通知,就启动持续计时器,若超时,就发送一个 0窗口探测报文,仅携带 1字节 的数据。(TCP规定:即使接收窗口为0,也必须接收0窗口探测报文段、确认报文段以及携带有紧急数据的报文段)
上述的流量控制是站在**接收方的角度来约束发送方发送速率的。**
输入负载:代表单位时间内输入给网络的分组数量
吞吐量:代表单位时间内从网络输出的分组数量
理想状态下的拥塞控制网络,在吞吐量达到饱和(由于硬件的限制,吞吐量是有上限的)之前,网络吞吐量是应该等于所输入的负载
小于理想曲线,说明网络中出现了堵塞,数据在中间某一网络结点需要排队等待发送
所以总的原则是:发送窗口的大小,还取决于拥塞窗口的大小。即在接收窗口和拥塞窗口中取较小者。
拥塞窗口是如何试出来的呢?具体有以下4种算法:
在介绍算法之前,先介绍执行算法的前提:
- 发送方维护一个叫做拥塞窗口cwnd的变量,其值取决于网络的拥塞程度,是动态变化的(靠这个变量来维持窗口大小的合理动态平衡)
如何维护cwnd?
只要网络没有出现拥塞,拥塞窗口就大一些;但只要出现拥塞,拥塞窗口就小一些(网络通畅,我这窗口就大一些,让能发送的数据更多一些;出现拥塞了,我发再多数据,接收方也可能收不到啊(因为拥塞导致丢包),所以窗口就适当小一些)那么如何判断网络是否出现拥塞呢?
只要数据包发生超时重传,就判断网络出现了拥塞
- 发送方将拥塞窗口作为发送窗口swnd,即 swnd = cwnd
- 维护一个阈值(ssthresh)
当 cwnd < ssthresh 时,使用慢开始算法
当 cwnd > ssthresh 时,使用拥塞避免算法
当 cwnd = ssthresh 时,使用慢开始也可以,拥塞避免也可以
注:
当传输过程中发生了超时重传,即判断网络很可能出现了拥塞,随后进行以下工作:
- 将 ssthresh 值更新为发生拥塞时,cwnd值的一半
- 将 cwnd 值降为 1,然后重新执行慢开始算法
上述的注,也存在这样一种情况:个别报文段只是在网络中丢失,实际上网络并未发生拥塞,那么按照上述规则,就会降低传输效率,于是又提出了两个新的算法,在新算法中,就不会让cwnd值降为1并重新执行慢开始算法
快重传
接收方给发送方发送连续3个重复确认,发送方一旦收到,就立即将相应的报文段立即重传,而不是等超时计时器(这一算法目的是为了让发送方尽快重传,如果确认报文段能在一定时间内顺利到达发送方,也从侧面证明网络实际上并没有拥塞)
快恢复
发送方一旦收到3个重复确认,就知道网络中并没有拥塞,于是不启动慢开始算法,而是执行快恢复算法,快恢复算法方案:
延时应答的作用:基于滑动窗口,尽可能再提高一些效率。
延时应答:结合滑动窗口以及流量控制,能够通过延时应答ACK的方式,让窗口值尽可能的大一些,即接收方收到数据之后,不会立即返回ACK,而是等一会再返回ACK。
因为接收方需要时间去消化数据,等一会,就是在给接收方提供更多的时间将数据上交给上层应用程序,这样接收缓冲区的空余空间就会相对比较大,一次性能够接收的数据也就更多,窗口值也就能更大
当然也不可能给所有的包都执行延时应答策略:
捎带应答:基于延时应答,提高传输效率
延时应答本质上是修改窗口值的大小
而捎带应答,走的就是另一条方向:尽可能地把能合并的数据包合并起来
也就是说,执行捎带应答策略,后续每次传输请求和响应,都可能触发捎带应答,把接下来要传输的业务数据 和 对上次数据的ACK 合二为一
注:是有可能触发捎带应答,要看两个数据包发送的时机。(延时应答+捎带应答,四次挥手就可能合并成三次挥手)
创建一个TCP的 socket
,同一时间会在内核中创建一个 发送缓冲区 和一个 接收缓冲区:
- 调用
write()
时,数据会先写入 发送缓冲区 中;- 如果发送的字节数太长,会被拆分成多个TCP的数据包发送;
- 如果发送的字节数太少,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者到了合适的时机,才会发送出去;
- 接收数据时,数据也是从网卡驱动程序到达内核的接收缓冲区,然后应用程序调用
read()
从接收缓冲区中拿数据- 另一方面,TCP的一个连接,一方既有发送缓冲区,也有接收缓冲区,则说明该端既可以写数据,也可以读数据,这就叫 “全双工”
由于缓冲区的存在,TCP程序的 “读” 和 “写” 也就不需要一一匹配,如:
- 写100个字节数据时,可以调用1次
wirte()
写100个字节,也可以调用 100 次write()
,每次写 1 个字节- 读100个字节数据时,也是同理
多个应用层数据混淆这一情况,就称为 “粘包”。粘包问题不是TCP独有,而是只要是面向字节流的,就存在这个问题。
解决 “粘包问题” 的方案:
通过特殊符号作为分隔符,见到分隔符,就视为一个数据包结束。(在TCP写的回显服务器,就是使用这一方法)
就像有些网站取名不让你使用一些特殊的字符,可能就是出于这个原因
指定一个包的长度。 在包头位置,加上一个特殊的空间来表示一个完整数据包的长度即可
注:UDP协议并没有这个问题,因为UDP传输的的基本单位就是一个UDP数据报,在UDP这一层就将数据分开了。
上述我们谈到的,数据在传输过程中基本上都很顺利,最多不过是 “滞留” “丢包” 两种问题,并且重传之后就能够收到,所以问题不大。
但如果一直丢包一直重传,但就是收不到,怎么办?甚至说网络直接出现了故障,这又怎么办?
- 断电是接收方。发送方就会突然发现没有收到ACK了,这就会重传数据,重传几次发现还是收不到ACK,发送方就会尝试 “复位连接” (相当于清除原来TCP中的各种临时数据,重新开始连接。)
- 断电的是发送方。这在 [[6.2 TCP释放连接过程]] 最后一章中提到过,接收方会发送 “心跳包” 来检测发送方是否正常,如果发送方 “没心跳” 了,接收方也会尝试复位,不成功则单方面释放连接