1. UDP 协议结构
报文格式:
UDP 的报文分为报头,正文/载荷(完整的应用层数据包),其中报头部分又分为四个部分,每一个部分都是固定的四个字节,分别存储源端口,目的端口,UDP 报文长度(报文长度 = 报头长度 + 载荷长度),校验和(检验和),每一个部分都是固定的两个字节存储,由于是两个字节存储 UDP 报文长度,所以最大值就是 65535 ,也就是 64KB ,这个时候就会出现一个问题,如果要表示的内容不止是 64KB ,就需要换用 TCP 来表示了
关于校验和:由于网络传输过程中是比较容易出现错误的,传输的电信号/光信号/电磁波等信息容易受到环境的干扰,使这里的传输信号发生转变,校验和的目的就是能够“发现”或者“纠正”这些错误,同时,如果只是发现错误,那么校验和携带的信息就可以很少,如果想要纠正错误,就需要再携带额外的信息(消耗更多的带宽)
在 UDP 协议中使用的简单有效的校验和是 CRC 校验和(循环冗余校验):对 UDP 数据报整个进行遍历,分别取出每一个字节,往一个字节或是两个字节的变量上进行累加,即使溢出之后也继续加,主要关注的是校验和的结果是否会在传输中改变
如果说传输的数据,在网络通信中没有发生任何改变,此时计算出来的就是 checksum1 == checksum2 反之,如果不相等,就代表数据传输中数据发生了改变,就会丢弃这次传输
此外还可能会发生传输过程中校验和的信息也发生改变了,也就是传输过程中校验和变成了 checksum3,此时接收方重新计算校验和得到了 checksum4 ,这种情况下两个校验和大概率是不相等的,所以影响也不大,还有可能出现两组不同的数据计算出相同的校验和,这种概率也是非常低的,所以上面这两种极端情况一般不考虑
MD5 算法:
本质上是一个“字符串 hash 算法”,特点:
- 定长:无论输入多长的字符串,得到的结果都是固定长度(适合做校验算法)
- 分散:输入的内容只要发生一点改变,得到的结果也是相差很大的(适合做哈希算法)
- 不可逆:根据输入的内容计算 md5 对计算机来说是不复杂的,但是如果根据 md5 的值来计算原始值,理论上是不可以的(适合做加密算法)
2. TCP 协议
2.1. 协议结构
2.2. 确认应答
在之前提到过 TCP 的核心机制是确认应答,可以确认对方是否收到数据,在数据传输的过程中,如果有多条请求,并且返回对应的响应,但是此时可能会出现这样的问题:最先发送的请求可能并不会最先收到响应,也就是收到响应的顺序会不一样。
针对这样的问题的解决方案就是给每一个字节都进行编号(TCP 的传输是面向字节流的),并且编号是连续且递增的,按照字节编号这样的机制就称为“TCP 的序号”,在应答报文中,针对之前收到的数据进行对应的编号,称为“TCP 的确认序号”
上面的 32 位序列号和确认序列号就是这种,由于序号是递增的,知道了第一个字节的序号,后续每一个字节的序号都能知道
假如 TCP 发送了的数据标记为了 1~1000,那么确认应答的序号应该是收到的数据最后一个字节序号的下一个序号,也就是1001,表示小于 1001 序号的数据都收到了
并且之后的六位标志位中的第二位(ack)就会设为 1(默认是0)
2.3. 丢包
丢包的原因:
- 数据传输过程中发生了 bit 翻转,收到这个数据的接收方/中间的路由器等,计算校验和发现不匹配,就会把当前数据包丢掉,不再交给应用层
- 数据传输到某个节点(路由器/交换机)时,当前节点负载过高,例如某个路由器单位时间内只能发送n 个包,但是遇到了高峰期,单位时间内需要发送的包超过了 n ,后续传输过来的数据就可能被路由器丢掉了
2.4. 超时重传
TCP 对抗丢包的方法:其实丢包是不可能避免的,TCP 感应到丢包之后就会再重新发一次数据,第二次再发生丢包的概率就会减小很多,TCP 感应丢包是通过应答报文来区分的,收到应答报文之后就说明没有丢包,没有收到应答报文就说明数据丢包了,但是也不能排除当时没收到后续收到了的情况,所以就需要设置一个时间限制,在时间限制内来判断是否丢包,不过还有一个特殊情况:
第一种就是正常的数据没有发送到丢包了,第二种是数据没有丢,但是 ack 丢了,不过无论是哪种情况都会认为是丢包并且进行数据重传,这时就会出现一个问题,第一种情况是没问题的,数据丢了重新传,但是第二种情况数据没有丢,再次发送就意味着主机2收到了两份同样的数据,如果是转账的请求,让你转两次账肯定也不合理
针对上述问题 TCP 也进行了处理,接收方会有一个接收缓冲区,收到的数据会先进入缓冲区中,后续再收到数据就会根据序号在缓冲区中找对应的位置,如果发现当前序号 1~1000 已经存在了,就会把新收到的数据丢弃了,以此来确保读取到的数据是唯一的
重传的时间设定:
这里的时间不是固定的,而是动态变化的,例如发送方第一次重传,超时时间为 t1,如果重传之后仍然没有 ack ,还是继续重传,第二次重传超时时间为 t2,,t2 是大于 t1 的,每多重传一次,超时时间的间隔就会变大
经过一次重传之后,就能让数据到达对方的概率显著提示,反之,如果重传几次都没有顺利到达,说明网络的丢包率已经达到了一个很大的程度
重传也不会无休止的进行,当重传到达一定次数的时候,TCP 就不会尝试重传了,就认为这个链接已经G了,此时先进行“重置/复位 连接”,发送一个特殊的数据包“复位报文”,如果网络恢复了,复位报文就会重置连接,使通信继续进行,如果网络还是有问题,复位报文没有得到回应,此时 TCP 就会单方面放弃连接
确认应答和超时重传这两个核心机制共同构建了 TCP 的“可靠传输机制”
2.5. TCP 的三次握手
三次确保了客户端和服务器之间建立连接,发送不携带业务数据(没有载荷,只有报头)的数据包
客户端发送同步请求,也就是标志位中的第 5 位,然后服务端也回应发送同步信息和确认应答,客户端再确认应答,虽然说看上去是四次交互,但是中间服务器的 syn + ack 合并了,一起发送到客户端,也就是三次握手
TCP 进行三次握手的原因:
- 验证通信路径是否畅通。
- 验证通信双方的发送能力和接收能力是否正常,客户端第一次发送 syn 可以确定客户端的发送能力和服务器的接收能力正常,然后服务器发送 syn + ack 告诉客户端的发送能力和服务器的接收能力正常,然后客户端就知道了自己的接收和发送能力都正常,再发送 ack ,服务器也确认了自己的发送能力正常
- 让通信双方在进行通信之前,对通信过程中需要用到的一些关键参数进行协商(例如确定起始序号,TCP 通信时,起始数据的序号就是通过三次握手协商确定的,每次建立连接 TCP 的其实序号都不同,并且差别很大,这样做是为了避免上一次的数据如果“迷路了”,在下一次 TCP 连接时出现误判,如果发现不是属于此次起始范围的数据就丢弃)
2.6. TCP 的四次挥手
- 客户端完成数据发送任务后,发送一个带有 FIN(终止)标志位的数据包,用来关闭客户端到服务器的数据传送。此时客户端进入 FIN_WAIT_1 状态,表示客户端不再向服务器发送数据,但仍可以接收服务器发送的数据。
- 服务器收到客户端的 FIN 包后,发回一个 ACK 数据包给客户端,确认序号为收到的序号加 1。此时服务器进入 CLOSE_WAIT 状态,表明服务器还有数据可能需要发送给客户端,客户端收到这个 ACK 后进入 FIN_WAIT_2 状态,继续等待服务器的 FIN 报文。
- 当服务器端确定数据已发送完成,则向客户端发送 FIN 报文,告诉客户端自己也要断开连接了,然后服务器进入 LAST_ACK 状态,等待客户端的确认。
- 客户端收到服务器的 FIN 报文后,回复一个 ACK 报文给服务器,确认号为收到的序号加 1,随后客户端进入 TIME_WAIT 状态。服务器收到这个 ACK 报文后,连接正式关闭,进入 CLOSED 状态。客户端在经过 2 倍的 MSL(报文最大生存时间)后,也进入 CLOSED 状态。
状态说明:
LISTEN:服务器进入的状态,服务器把端口绑定好之后相当于进入了该状态,等待客户端发生请求
ESTABLISHED:客户端和服务器都会进入的状态,表示 TCP 已经建立好连接了
CLOSE_WAIT:被动断开连接的一方(先收到 FIN)会进入这个状态,等待代码执行 close 方法
TIME_WAIT:主动断开连接的一方会进入这个状态,按照时间来等待,达到一定时间后等待结束(原因:防止最后一个 ACK 丢包),时间就是 2 倍的 MSL(报文最大生存时间)
2.7. 滑动窗口
在之前介绍的可靠传输是发一次应答一次,效率并不高,所以 TCP 就在保证可靠传输的前提下,也能有一个不错的效率,引入了滑动窗口,发送方可以在未收到确认应答的情况下连续发送多个数据包,不必每发一个包就停下来等待确认,大大减少了数据传输的等待时间,提高了传输效率。
当发送方发送的数据被接收方正确接收并确认后,发送窗口会向前滑动,即发送窗口的左边界会向右移动,同时右边界也可能根据接收方通告的接收窗口大小而向右移动。也就是当收到 2001 ack ,说明 1001~2000 的数据已经得到应答了,然后立即发送 5001~6001 的数据,此时等待的 ack 范围就是 2001~6000 窗口大小还是 4000
q:滑动窗口的前提是可靠性,如果说在滑动窗口传输中出现了丢包该怎么办?
这种情况其实是不需要做任何处理的,批量发送数据,ACK 只是丢其中的一部分,而确认序号表示的是收到的数据最后一个字节的下一个序号,也就是确认序号之前的数据都收到了,虽然 1001 的 ACK 丢了,但是 2001 到达了,还可以证明 2001 之前的数据都到了,后一个 ACK 也就涵盖了前一个 ACK 的意义
在上图中,B 收到的数据是1~1000,2001~3000···,其中 1001~2000 的数据丢了,此时 B 收到2001~3000 时返回的不是 3001,而是 1001,也就是 B 希望接下来收到的是 1001~2000 的,但是一直没有收到,后续 A 发送的 3001~4000,4001~5000···收到的 ACK 都是 1001 当主机 A 连续多次收到相同的 1001 后就意识到丢包了,就会重新传输 1001~2000 的数据包,传输之后由于 2001~7000 的数据在之前已经发过了,1001~2000 相当于是补全了之前的空缺,接下来索要 7001 开头的数据即可
上述过程快速地识别出是哪个数据包丢失了,并且针对性的重传,其他到达的数据无需重传,这个过程称为“快速重传”,快速重传可以认为是滑动窗口搭配下的超时重传。
如果单位时间内发送的数据量比较小,就会按照之前的确认应答,超时重传发送,数据量多了之后就会按照滑动窗口,快速重传
2.8. 流量控制
滑动窗口的窗口大小对于传输数据的性能是直接相关的,但是窗口肯定也不能无限大,在之前提到过,内核中的内存空间,每一个 socket 对象都是有一个接收缓冲区的,无限大的话,接收方可能无法处理如此大量的数据,造成缓冲区溢出,从而丢失数据,而且,网络的中间设备(如路由器等)可能无法承受如此巨大的数据量,导致网络阻塞,进而影响传输的可靠性
这时就需要通过“定量”的方式来看接收缓冲区剩余空间的大小,如果空闲空间越大,就认为应用程序处理速度比较快,就可以让发送方发的快一点,设置一个更大的窗口,如果空闲空间越小,就认为应用程序处理速度比较慢,就可以让发送方发的慢一点,设置一个更小的窗口
TCP 中接收方收到数据的时候,就可以把接收缓冲区剩余空间大小通过 ACK 数据报的方式反馈给发送方,发送方就可以依据这个数据设置发送窗口的大小了
但是 ACK 数据报是不携带业务信息的,这时就用到了上面的 16 位窗口大小的属性
16 位窗口大小就体现了刚才提到的接收方缓冲区的剩余空间,这个属性只有在 ACK 报文中(ACK 为 1)才有效
此处的 16 位表示的范围是 64KB ,但也并不意味着发送方窗口的大小最大就是 64KB,在选项中还可以设置一个特殊的选项“窗口扩展因子”
发送方的窗口大小 = 窗口大小 << 窗口扩展因子
那发送方不发送数据这个状态要持续多久呢,通过 ACK 来确认接收缓冲区的剩余空间的话,不发数据那么就没有 ACK,就会一直等吗
过了重发超时的时间如果还没有收到窗口更新的通知之后,发送端就会发送一个窗口探测的包(不携带业务数据,载荷是空的)来触发 ACK,以此来查询接收区缓冲区还剩多少
接收方也会在接收缓冲区不为 0(消费了一定数据)的时候主动触发一个“窗口更新的通知”这样的数据报
2.9. 拥塞控制
流量控制是站在接收方的视角来限制发送方的速度的,拥塞控制是站在传输链路的视角来限制发送方的速度的
例如从 A 传输数据到 B 的过程中,拥塞控制中把中间传输的节点看作一个整体,不关心内部的细节,如果当前节点的负荷已经很高了,此时 A 再以很快的数据发送数据,就会丢包
流量控制可以精准的使用接收方的缓冲区剩余空间来进行衡量,而拥塞控制考虑中间节点的情况,就需要从多方面来看了,中间节点的数量很多,每次传输的路线也不一样,中间那个节点遇到瓶颈了也不能确定,并且中间节点也不止 A 一个数据,还有很多其他设备的数据,这就很难从一个方面来考虑解决办法了
所以就可以采用一个“测试”的方法
- 首先通过“慢启动”来控制,在连接建立初期,发送方的发送窗口大小非常小
- 发现不丢包之后,以指数增长的方式逐渐增加发送窗口的大小。
- 增长到一定程度,达到某个阈值之后,此时即使没有丢包,也会停止指数增删,变成线性增长
- 线性增长也会持续使发送速度越来越快,达到某个阈值之后就会丢包
一旦出现丢包,接下来就需要减少发送的速度,减小窗口的大小,此时有两种处理方式:
经典方案:回归慢开始的初始值,然后指数级增长,再线性增长
当前方案:回归到新的阈值上,线性增长,并且之后也不会指数级增长
流量控制和阻塞控制,都是在于对“可靠传输”进行补充,这两个机制同时作用,最终实际的发送窗口的大小取决于二者的最小值。
2.10. 延时应答
当接收方收到数据后,不是立即发送确认应答(ACK),而是等待一段时间再发送。这样做的目的是让接收方有更多的时间来处理数据,从而有可能在发送 ACK 时,将接收窗口的大小设置得更大一些,以达到更高的效率
2.11. 捎带应答
正常情况下,ACK 和响应是不同的时机,无法合并,但是 ACK 涉及到上面讲的“延时应答”,这样就会使 ACK 返回的时间往后拖,这样一延时,就可能赶上接下来发送响应数据的操作了,于是就可以在发送响应的时候,把刚才的 ACK 的信息捎带上。
延时应答和捎带应答都提升了 TCP 的性能。
2.12. 面向字节流
在之前已经提到过,TCP 传输数据时面相字节流的,所以就会涉及到“粘包问题”,粘的是 TCP 携带的载荷(应用层数据包)
由于 TCP 是面相字节流的,所以此处的读操作怎么读都可以,不过读出来的效果就可能和原来的数据包不一样了,无法区分各个数据包的边界,针对“粘包问题”,有以下两种解决方案:
- 指定分隔符(适合于文本类的数据)。在之前写的回显服务器小案例的时候采用的就是发送请求响应时使用 println 进行写数据,读取请求响应时,专门使用 scanner.next 按照 \n 来解析,需要确认数据内容的正文中不能包含分隔符,如果传输的数据是纯文本数据的话,此时使用 \n 等就不太合适,可以使用 ASCII 中靠前的“控制字符”。
- 指定数据的长度(适合于二进制数据)。例如每个应用层数据包,开头的几个字节用来表示数据包长度
UDP 由于是面向数据报的传输,每一次传输都是一个完整的数据报,所以也不涉及到上述问题
2.13. 异常处理
- 进程崩溃。在 Java 中的体现就是抛出异常,但是没有被 catch 到,最终异常到了 JVM 这里,JVM 进程就崩溃了,当进程崩溃之后,进程中的 PCB 就会被回收,PCB 中的文件描述符表里对应的所有文件,也会被系统自动关闭,其中针对 socket 文件,也会触发正常的关闭流程(四次挥手)
- 主机关机。正常流程点击关机按钮,此时操作系统就会先关闭所有的进程,关闭的过程中,同样会触发四次挥手,这时就会出现两种情况:
- 四次挥手非常快,已经完成之后,关机动作才完成
- 四次挥手没来得及挥完,关机就完成了:
面对这种情况 B 这里的 FIN 没有收到 ACK ,就会触发超时重传,重传一定次数之后,就主动放弃连接
- 主机掉电。也有两种情况:
- 接收方掉电
A 给 B 发送的数据不会再有 ACK 了,A 就会触发超时重传,重传多次之后 A 尝试重置连接(RST),重置连接也没有 ACK,A 就会单方面释放连接
- 发送方掉电
A 发着发着就不发了,B 就会给 A 发送一个探测报文(不携带业务逻辑,为了触发 ACK),连续多个探测报文都没有 ACK,就可以认为 A 挂了,这样的探测报文是周期性的,同时这个报文是用来探测对方“生死”的,就称为“心跳包”。TCP 内置了心跳包,由于 TCP 内置的心跳包周期比较长,应用程序这一层也会自行实现一些心跳包,达到更快速的“保活机制”。
- 网线断开。这和主机掉电是类似的
对于 A 来说:A 收不到 ACK 就会触发超时重传,然后重置连接,最后单方面释放
对于 B 来说:B 就会发送心跳包,也收不到 ACK,最后单方面释放