目录
编辑一、应用层
1、请求和响应
2、通用的协议格式
(1)xml
(2)json
(3)protobuffer
二、传输层
1、UDP
2、TCP
(1)TCP 协议段格式:
(2)可靠传输的实现机制:
确认应答机制(可靠性):
超时重传机制(可靠性):
连接管理机制(可靠性):
三次握手:
四次挥手:
滑动窗口机制(效率):
流量控制机制(可靠性):
拥塞控制机制(可靠性):
延时应答机制(效率):
捎带应答机制(效率):
面向字节流;
TCP 异常情况处理:
TCP 和 UDP 的对比
三、网络层
一、应用层
1、请求和响应
这一层,有很多现成的协议,也有很多时候,是需要程序员自己定义协议的
在开发设计程序的时候,要提前做好良好的规划
打开外卖软件,显示商家列表,列表中有很多项,每一项都包含了一些信息:商家的名称,图片,好评率,距离你的位置,评分......
针对这些操作,我们做出如下设计:
1、明确当前请求和响应中包含哪些信息(根据需求来的)
请求:用户身份,用户当前位置
响应:包含若干个商家信息
2、明确具体的请求和响应格式
示例一:
所谓的明确格式,就是看你按照什么样的方式,构造出一个字符串,后续这个字符串就可以作为TCP 或者 UDP 的 payload 进行传输
另一方面,服务器就可以对这个字符串进行解析,解析出逗号前面的是 userid,逗号后面的是 经度纬度
这个时候,我们就构造出了一个响应这样的字符串,客户端就可以按照这样的格式来进行解析了
网络上传输的数据,本质上就是字符串(准确的说,是二进制的 “字符串”),无法直接传输一个 Java 对象 这样的内容的
Java 写代码,往往都是各种对象
但是在最后发送数据的时候,就需要把对象转换成(二进制)字符串【序列化】
在收到数据的时候,也需要把(二进制)的字符串转换回对象【反序列化】
示例二:
使用 !分割每个商家,商家的每个信息使用 ; 来分割
示例三:
上述方式,就可以看到,请求和响应,具体的数据组织形式,是非常灵活的,程序员想怎么组织都行,只需要保证客户端和服务器这边使用的是相同的规则即可
2、通用的协议格式
虽然我们说,自定义的协议格式,是可以任意的,但是为了避免出现过于天马行空的设计,有些人就发明出了一些 “通用的协议格式” ,参考这些格式,就可以对咱们的协议设计产生重要的指导作用
由于有很多体现形式,这里我们就挑几个重要的介绍一下
(1)xml
是以成对的标签,来表示 “键值对” 信息,同时支持标签嵌套,就可以构成一些更复杂的树形结构数据
优点:xml非常清晰的把结构化数据表示出来了
缺点:表示数据需要引入大量的标签,看起来繁琐,同时也会占用不少的网络带宽
(2)json
json 是最流行的一种数据组织形式
本质上也是键值对,但是看起来比 xml 要干净不少
json 中,使用 { } 表示 键值对,使用 [ ] 表示数组
数组里的每个元素,可以是 数字,可以是 字符串,还可以是其它的 { } 或者 [ ]
优势:相对于 xml ,表示的数据简洁很多,可读性非常好,方便程序员观察结果,方便调试问题
劣势:终究是需要话费一定的带宽来传输 key 的名字
json 对于换行并不敏感,如果这些内容全部都放在同一行,也是完全合法的
一般在网络传输的时候,会对 json 进行压缩(去掉不必要的换行和空格),同时把所有数据都放到一行取,整体占用的带宽就更低了(影响到可读性)
(3)protobuffer
谷歌提出的一套,二进制的数据序列化方式
使用二进制的方式,约定某几个字节,表示哪个属性
最大程度的节省空间(不必传输 key ,根据位置和长度,区分每个属性)
优点:节省带宽,最大化效率
缺点:二进制数据,无法肉眼直接观察,不方便调试,用起来比较复杂(需要专门编写一个 proto 文件,描述数据哦什么样的)
这个主要用于对性能要求更高的场景
二、传输层
1、UDP
UDP 基本特点:无连接,不可靠传输,面向数据报,全双工
学习一个协议,当然要掌握协议的特性,还需要理解协议报文格式
报头里有啥?都是什么意思?
一次通信,涉及到五元组:
源端口,目的端口,源IP,目的IP,协议类型
合法的端口号,有效范围就是 0 - 65535,实际上 0 是不会用的
其中 1 - 1024(知名端口号) ,系统赋予了特定的含义,一般也不建议使用
当然,你也可以写个程序,使用 1024 之前的端口(需要你的程序有管理员权限)
网络传输数据过程中,受到外界干扰,数据是可能会出错的
网络数据本质上是 光信号/电信号/电磁波。
受到外界干扰之后,可能就会导致本来你要传输的数据发生了错误,比如 0 -> 1,1 -> 0
接收方收到数据之后,就需要去确认一下,这个数据是否是一个错误的数据,校验和就是简单有效的方法
实际的校验和,不仅仅是一个 “长度” ,而是根据数据的内容来生成的,当内容发生改变的时候,就能够感知到出错
UDP 的校验和具体是怎么实现的呢?
使用了一种简单粗暴的 CRC 校验算法(循环冗余校验和)
把 UDP 数据报中的每个字节,都依次进行累加,把累加结果保存在两个字节的变量中
加着加着可能就溢出了,但是溢出也无所谓
所有字节都加完了之后,最终就得到了校验和
传输数据的时候,就会把原始数据和校验和一起传输过去,接收方收到数据,同时也受到了发送端送过来的校验和(旧的校验和)
接收方按照同样的方法再算一遍,得到新的校验和,如果旧的校验和 和 新的校验和 相同,则可以视为数据传输过程中是正确的,反之则视为传输过程中数据出错了
但是,校验和相同,不一定代表数据相同,但是这种情况出现的概率比较低
也有一些其它的算法进行校验,可以达到更高的精确度,但是要付出更多的代价
2、TCP
(1)TCP 协议段格式:
TCP 特点:有连接,可靠传输,面向字节流,全双工
可靠传输是由内核实现的,写代码的时候感知不到(感知成本低,使用成本也低了)
(2)可靠传输的实现机制:
确认应答机制(可靠性):
确认应答是确保 “可靠性” 的最核心机制
当我们连续发送多条数据的时候,这多条数据可能会出现 “后发先至” 的情况:一个数据报是先发的,另一个数据报是后发的,但是后发的数据报却先到了
为什么会出现 “后发先至” 的情况?
网络上从 A --> B 中间的路径有很多,两个数据报,从 A -- > B 中间走的路线不一定相同
另外,每个节点(路由器 / 交换机)繁忙程度不一样,此时,这样的转发过程,也就会存在差异
如何解决上述问题?
针对数据进行编号
由于 TCP 是面向字节流的,不是按照 “条” 为单位来传输,所以我们针对字节进行编号
(1)、针对字节进行编号,而不是针对 “条”
(2)、应答报文也是要和收到的数据相关联的,而不是相等
只要知道这一串字节的开始编号,以及字节的长度,每个字节的编号自然也就知道了
只需要在 TCP 报头中,把这一串字节第一个字节的编号表示出来,再结合报文长度,此时每个字节的编号就确定了
确认序号的数值,就是收到的最后一个字节的编号再加一
之前的结构图中,32 位序号的部分就存储了这一串字节第一个字节的编号
数据则存储的是 TCP的载荷数据,包含若干字节
32位确认序号这个字段就是给应答报文使用的
如何区分出当前这个报文是普通报文还是一个确认应答报文?
看六个标志位,重点看第二个标志位:ACK
ACK = 0 表示这是一个普通的报文,此时只有 32 位序号是有效的
ACK = 1 表示这是一个应答报文,这个报文的序号和确认序号都是有效的
确认报文的序号和正常报文的序号,之间那没有任何关联,序号,是你自己这个主机发送的数据进行的编号
有时,也会使用 ack 来表示应答报文
确认应答,是 TCP 保证可靠性的最核心机制
超时重传机制(可靠性):
丢包 在网络上,很可能出现发一个数据,然后数据丢失的情况
路由器 / 交换机 可以理解成 “交通枢纽”,连接了非常多的设备
结构复杂,传输的数据量也是不确定的,有时候传输的数据多,有时候传输的数据少
如果设备太繁忙了,后面新来的数据等太久了,就可能被丢弃了
网络负载越高,就越繁忙,就越容易丢包
出现了丢包该如何处理呢?
进行超时重传
超时重传,相当于针对确认应答进行的重要补充
超时重传有上面两种情况
第一种是发的消息本身丢包了,第二种是应答报文丢包了
但是发送方无法区分是哪种情况
既然无法区分,那就都进行重传
但是在第二种场景下,B 同一条消息收到了两次,但是假设此时是转账 10 元,但是由于重传,导致转账了20元,所以此处就出现了问题
接收方在收到数据之后,就需要对数据进行去重,把重复的数据丢弃掉,保证应用程序调用 inputStream.read 的时候,读到的数据不会重复
如何进行去重?如何高效的判定当前收到的数据是否是重复的?
直接使用 TCP 的序号来作为判定依据
TCP 会在内核中,给每个 socket 对象都安排一个内存空间,相当于一个队列,也称为 “接收缓冲区” ,收到的数据都会被放到缓冲区里面,并且按照序号进行排序
按照序号排列,就可以保证就算出现后发先至的情况,应用程序这边读到的数据仍然是有序的
此时,就可以很容易的找到该新收的数据是否重复了
读取数据后,这个数据是否还会在队列中呢?
这里又涉及到生产者消费者模型了
收到数据后,接收方的网卡把数据放到对应 socket 对象的接收缓冲区(内核中)
应用程序调用 read ,就是从这个接收缓冲区消费数据,当 read 走了之后,就可以从队列中数据(一般情况下,read 读到就删除了)
为什么重传的时候,数据就能传过去?
丢包,本质上是一个概率性问题
假设丢包的概率是 10% ,传输成功的概率是 90%,那么连续两次丢包的概率就只有 1%
随着你重传次数的增加,总体能够传输成功的概率是更大的
等待超时时间是等多久呢?
我们不需要去特意记住这个数值
超时时间不是一个固定的值,会随着超时轮次的增加,而进一步增加
随着重传轮次的增加,等待时间也会越来越长
这是因为,当前认为,正常情况下第二次重传就有极大的概率重传成功,如果进行了多次重传都没有成功,说明当前网络本身的丢包率已经级高了,网络可能遇到了一些比较严重的故障
此时,进行频繁的重传属于白费力气,拉长一些时间间隔,也是在给网络恢复留有一个时间上的余地
但是超时重传的轮次也不是无限的,重传次数达到一定程度,此时就会尝试 “重置” TCP 连接
这个就涉及到 TCP 复位报文
RST = 1 时表示是一个复位报文,如果网络已经出现了严重故障,复位操作 / 重置操作,也无法成功,最终就只能放弃连接
连接管理机制(可靠性):
连接管理,说白了就是两件事:
1、建立连接(三次握手)
2、断开连接(四次挥手)
三次握手:
握手:handshake
发一个打招呼的数据(这个数据并不会携带业务信息),使用打招呼来触发特定场景
A 和 B 要完成建立连接的过程,就需要三次这样打招呼的数据
这样的四次交互完毕之后,连接就算建立好了,此时双方就已经保存了对端的信息了
看起来是 四次,但是中间的两次,可以合并成一次
为什么要合并呢?
合并之后,节省了封装和分用的过程,降低了成本,提高了效率,原则上能合并就合并
SYN 是申请建立连接的请求,“同步报文段”
如果 SYN = 1,就是同步报文段,就是一台主机要尝试和另一台主机建立连接
TCP 三次握手是怎么样的过程?
这张图就生动形象的表示了三次握手的过程
三次握手,第一次 SYN 一定是客户端发起的
这个代码就是开始进行三次握手,这个 new 操作完成了,三次握手也就完成了
三次握手,这是 内核 完成的工作,应用程序这里无法干预
同时,服务器这里针对三次握手配合,是不需要涉及到任何应用层代码的,只需要你这个进程是绑定了对应的 TCP 端接口,就可以在内核中自动的配合完成三次握手,无论你的代码是怎样写的
三次握手完成之后,客户端和服务器都形成了连接
此时,accept 就能够返回,从连接队列中取出队首元素,进一步的获取到其中的 socket 对象,来和对端通信
但是 accept 并不会参与三次握手的过程
单线程 TCP 服务器,服务器在处理第一个客户端的时候,虽然无法调用第二次 accept ,但是不影响内核的工作,内核已经和第二个客户端三次握手完成了,形成的对象是在连接队列里(socket)
也就是,连接已经形成了,但是在队列中暂时没人取,accept 的工作是把队列中的元素取出来
三次握手意义是什么,要完成什么目的?
三次握手,也是一种保证可靠性的机制
TCP 要想保证可靠传输,可靠传输的前提是网络路径畅通
TCP 的三次握手,就是要验证往网络通信是否畅通,以及验证每个主机的发送能力和接收能力
这就类似于地铁每天早上都会先跑一趟空车,就是为了防止某个线路上可能会出现了一些故障的情况发生
三次握手,为什么是三次呢?
恰好三次,就能验证号双方的发送和接收能力均正常
虽然两次握手可以验证完成设备通信能力的正确性,但是服务器这边无法知道这样验证通过的信息
四次可以,但是没有必要(把中间这次,拆成两次),一次就行了,能合并就合并,拆成两次降低了效率
三次握手,还能起到 “消息协商” 的效果
通信的时候涉及到一些参数,需要双方保持一致,通过协商,来确定参数集体是多少
TCP 通信过程中,其实有很多信息都需要协商
比如,双方的序号从几开始
这样做就是保证,两次连接,消息的序号能有比较大的差异,从而更好去判定出某个消息是否是属于这个连接的
例如:网络上传输的消息,可能后发先至,极端情况下,某个消息迟到了很久,当消息到达对端的时候,服务器和客户端已经断开了上一个连接,这是重新建立的连接
这个时候,就可以通过序号,明显识别出这个是上一个连接的消息,就可以丢弃了
综上,三次握手的初心就是两方面:
(1)投石问路,验证通信是否通畅,双方 发送 / 接收能力是否正常
(2)协商必要的参数,使客户端和服务器使用相同的参数进行消息传输
四次挥手:
连接,通信双方发各自在内存中保存对端的相关信息
但是如果不需要连接了,就得及时释放掉上述存储空间
四次挥手的流程,和三次挥手非常相似
三次挥手,必然是客户端主动发起的,四次挥手,可能是服务器主动发起的,也可以是客户端主动发起的(大多数)
FIN 是六个标志位中的一位
当 FIN = 1,就是结束报文段
经过上述四个步骤之后,连接就彻底不再使用了,双方就可以把各自保存对端消息的空间释放了
四次挥手为什么是四次?可以是三次嘛?
有的时候四次挥手确实可以三次完成,但是有的时候不行
FIN 的触发,是应用程序代码来控制的,调用 socket.close() 或者进程结束,就会触发 FIN
相比之下,ACK 则是内核控制的,收到 FIN 就会立刻返回 ACK
这个 close 什么时候执行到,不好说,得看你代码是怎么写的
上述的这个代码中,close 执行的很快,只要客户端已一断开,服务器就会立即感知到,并且结束循环,触发 close
但是万一服务器还需要做很多其它的收尾工作,close 执行的实际就会很慢
当 close 触发的快,是有可能和上一个 ACK 合并的
如果 close 触发的慢,就无法和上一个 ACK 合并
如果服务器,始终不进行 close ,会怎么样?
此时,服务器的 TCP 处于 CLOSE_WAIT 状态
站在服务器的角度,虽然这里的连接没有关闭,但是这个连接已经不能正常使用了
针对当前 socket 进行读操作,如果数据还没读完(缓冲区中还有数据),能正常读到;如果数据已经读完了,此时就会读到 EOF(对于字节流来说,返回 -1,如果是 scanner.hasNext,就会为 false)
针对当前 socket 进行写操作,直接就会触发异常
无论如何,这个连接已经没用了,关闭是唯一的选择
更极端的情况下,比如代码写出了 BUG ,close 就忘记写了
此时站在客户端的角度,这边迟迟收不到对方 FIN,也会进行等待
如果一直等都等不到,此时就会单方面放弃连接,客户端直接把自己保存的对端信息删了,释放了
释放资源,能双方都顺利释放是最好的,如果条件不允许,那也不影响客户端单方面释放
如果通信过程中,出现了丢包,怎么处理?
这里也是涉及到超时重传的
三次握手和四次挥手也是带有重传机制的
会尽可能地重传,如果重传仍然失败,连续多次,此时仍然会单方面释放连接
如果是第一组 FIN / ACK 丢失,A 直接重传 FIN 即可
如果是第二组 FIN / ACK 丢失,重点关注 ACK 丢失
在图中画圈的地方,此时 A 可以直接释放连接了嘛?
不可以,因为最后一个 ACK 还可能丢包
如果最后一个 ACK 丢失,B 就会重传一个 FIN
此时如果 A 已经把连接释放了,重传的 FIN 就无人能够进行 ACK 了
因此,就需要让 A 在发出去最后一个 ACK 之后,让连接再等一会(主要就是看等的过程中会不会收到对方重传的 FIN ,如果等了一段时间,对方还没有重传 FIN ,就认为 ACK 已经被对方收到了)
此时,A 才能正确释放连接
A 这边等待多久才能释放连接?
等待时间就是 网络上任意两点之间传输的最大时间 (MSL)* 2
超时重传的时间 必然是 <= MSL 的
如果超时时间达到 MSL 上线,此时 100% 包已经丢了,如果超时时间设置的更大,也就没有意义了
还有极端情况, 比如 A 在等 2MSL 时间的过程中,B 在反复重传 FIN 多次,这些 FIN 都丢了(理论上存在)
如果真出现这个情况,当前网络一定是出现严重故障了,这个时候是不具备 “可靠传输” 的前提条件的,因此 A 就单方面释放资源也无所谓了
滑动窗口机制(效率):
提高传输效率(更准确的说,是让 TCP 在可靠传输的前提下,效率不要太拉跨)(亡羊补牢)
使用滑动窗口,不能使 TCP 比 UDP 快,但是可以缩小差距
没有滑动窗口,能够保证可靠性,但是大量时间,都消耗在等 ACK 上了
使用滑动窗口,就是为了缩小上述等待时间
一次性发出一组数据,发出这一组数据的过程中,不需要等待 ACK ,就直接往前发
此时,就相当于只使用 “一份等待时间” ,来等待 4 个 ACK
把一次发多少数据,不用等 ACK 这样的大小,称为 “窗口”
窗口越大,批量发送的数据就越大,效率就越高
但是窗口不能无限大,如果使无限大,相当于完全不必等 ACK 了,此时就和不可靠传输差不多了
如果无限大,接收方也不一定能处理的来,中间的网络设备也不一定能承受的住
当前, A 给 B 批量发送了 1001 - 5000 这样的数据,就需要等 4 个对应的 ACK
这 4 个 ACK 的到达顺序,也是有先有后的
当 2001 到达主机 A 的时候,A 是否要继续往下发下一条消息呢?还是说 A 需要等待 5001 到了,才能继续发下一条消息?
收到 2001 这个 ACK 之后,A 就立即发送 5001 - 6000 这个数据,此时 A 等待的ACK 就是 3001 - 6000
还是等待四条 ACK ,但是往后挪了一个格子
直观上看起来,就是窗口往后滑动了一步
滑动窗口,是一个形象的比喻,本质上就是批量发送数据
这样就可以缩短等待时间,比之前能提高一定的效率(但仍然不会比 UDP 更高)
现在按照这种批量的方式传输,中间丢包了怎么办?
对于 TCP 来说,提高效率,必然不应该影响到可靠性
两种情况:
(1)数据丢了
(2)ACK 丢了
1、ACK 丢了
ACK 如果丢了,不用做任何的处理,也是正确的
2001 确认序号,表示 2001 之前的数据都受到了,也包含 1- 1000
虽然 A 没有收到 1001 这个 ACK ,但是 2001 这个 ACK 涵盖了 1001 的含义
除非是所有的 ACK 都丢了(网络出现重大故障),否则,只是丢了一部分 ACK ,对于可靠传输没有任何影响
2、数据报丢了
此时必须要进行重传
在 1001 - 2000 丢失之后,2001 - 3000 这个数据到达了 B,B返回的确认序号仍然是 1001
B 再向 A 索要 1001 这个数据
虽然 A 后续给 B 的数据都能够顺利传过去了,但是只要是 1001 这个数据没有,B 始终都会像 A 索要 1001 这个数据(返回的 ACK 确认序号,都是 1001)
当 A 连续几次都收到了来自于 B 的索要 1001 的数据,A 就明白了,1001 是丢了,A 就会重新传输 1001 - 2000 这个数据
当重传的 1001 到达 B 之后,B 返回的 ACK 就是 7000 了
如果接收缓冲区,这一块是少了的,返回的ACK 就会始终索要 1001 这个数据报,一旦 1001 这个数据报被补上了, 此时 1001 - 2000 后面的数据都不必重新传输了(都在缓冲区里呆着)
接下来就看后面的数据是否还有哪里缺失,如果有缺失,索要对应的数据,如果没有缺失,直接索要缓冲区最后一条数据的下一条即可
此时,就相当于是使用最小成本,来完成了这个重传数据的操作
(只是把丢的数据重传了,其它的数据都没有重复操作)
我们称这种为快速重传:是超时重传结合滑动窗口下产生的变形操作,本质上还是超时重传
滑动窗口,也不是说使用 TCP 就一定会涉及到
如果通信双方大规模传输数据,肯定是滑动窗口(快速重传)
如果通信双方传输数据规模比较少,这个时候就不会滑动窗口了(超时重传)
流量控制机制(可靠性):
作为滑动窗口的一个补充
滑动窗口,窗口越大,传输效率越高,但是窗口也不能无限大,如果窗口太大了,就可能使接收方处理不过来了,或者是使传输的中间链路出现处理不过来
这样就会出现丢包,就得重传了,窗口并没有提高效率,反而还影响了效率
流量控制,就是给滑动窗口踩刹车:避免让窗口过大,导致接收方处理不来
这就是一个生产者消费者模型:
A 这边的生产速度很快,B 这边的消费速度跟不上
接收缓冲区就会越来越多,最终就满了,满了之后继续发送数据,就会出现丢包了
因此,流量控制,就是根据接收方的处理能力,来限制发送方的发送速度(窗口大小)
如何衡量接收方的处理速度?
此处就使用 接收缓冲区剩余空间大小 来作为衡量标准
如果剩余空间越大,应用程序消费数据的速度就越快
此处,就会直接把接收缓冲区的剩余空间大小,通过 ACK 报文反馈给发送方,作为发送方下一次发送数据,窗口大小参考依据
拥塞控制机制(可靠性):
总的传输效率,是一个 木桶效应,取决于最短板
把数据从 A 传输到 B 的过程中,要经过很多的 交换机 / 路由器,此时就构成了比较长的传输链路
中间如果某个环节,转发能力特别差,此时 A 的发送速度就不应该超过这里的阈值
具体怎么去衡量中间设备的转发能力呢?
此处,我们并不会针对中间设备的转发能力进行量化,而是采取 “实验” 的方式进行动态调整,产生出一个合适的窗口大小
把中间的设备都看成一个整体
(1)使用一个较小的窗口传输,如果传输通畅,就调大窗口
(2)使用一个较大的窗口传输,如果传输丢包(出现拥堵),就调小窗口
这样做,也可以非常好的适应网络环境的动态变化
拥塞窗口:拥塞控制机制之下,所采用的窗口大小
TCP 中,拥塞控制具体是这样展开的:
1、慢启动:刚开始进行通信的时候,会使用一个非常小的窗口,先试试水
因为如果网络拥堵,而一上来又搞了一个很大的流量,就会让本不富裕的网络带宽雪上加霜
2、指数增长:在传输通畅的过程中,拥塞窗口就会指数增长(*2)
指数增长的速度是极快的,所以不能不加限制,否则就会出现非常大的值
3、线性增长:指数增长当拥塞窗口达到一个阈值之后,就会从指数增长,转换成线性增长(+ n)
这里的指数增长和线性增长,都是按照传输的轮次
比如:现在给定窗口大小是 4000 之后,我发了 4000这么多数据出去之后,这一轮就完了,当收到 ACK 之后,继续往下发数据就进入到了下一轮
线性增长,也是增长,就会使发送速度越来越快,快到一定程度,接近网络传输的极限,就可能会出现丢包了
4、拥塞窗口回归小窗口:当窗口大小增长过程中,如果传输发生丢包,认为当前网络出现拥堵了,此时就会把窗口大小调整成最初的小窗口,继续回到之前 指数增长 + 线性增长 的过程,另外,此处也会根据当前出现拥堵的窗口大小,去调整阈值
拥塞窗口,就是在这个过程中,不断发生变化,不断重新调整的过程
这样的调整,就可以非常好的适应多变的网络环境
当然,这里也是有不少的性能上的损失的
每次回到 慢开始 这里,都会是传输速度大打折扣
因此,拥塞控制这里后面还诞生了一些优化算法(尽可能把小窗口的时间缩短)
实际发送方窗口 = min(拥塞窗口,流量控制窗口)
拥塞控制 和 流量控制 共同的限制了滑动窗口机制,可以使滑动窗口在可靠性的前提下,提高传输效率
延时应答机制(效率):
是一种提高传输效率的机制
如何在条件允许的基础上,尽可能地提高窗口大小?
需要在返回 ACK 的时候,拖延一点时间,利用这个拖延的时间,就可以给应用程序腾出来更多的消费数据的时间,接收缓冲区的剩余时间,就会更大了
如果立即返回 ACK ,此时返回窗口的大小,就是 3 kb
但是稍等 500ms 之后,此时再返回 ACK ,就有可能在这 500ms之内,应用程序又消费掉了 2kb ,此时返回窗口的大小,就是 5kb 了
此处 ,通过延时应答到底能提高多少速度,还是取决于接收方应用程序的实际的处理能力
那么所有的包都可以延迟应答嘛?
数量限制:每 N 个包就应答一次(针对滑动窗口)
时间限制:超过最大延迟时间就应答一次(针对非滑动窗口)
延时应答减少了可靠传输对于传输效率的影响
捎带应答机制(效率):
在延时应答的基础上,引入的一个进一步提高效率的机制
延时应答,是让 ACK 传输的时机更慢,捎带应答使基于延时应答,让数据进行合并
客户端和服务器之间的交互,是一问一答的形式
为什么说四次挥手可能是三次?
主要就是延时应答和捎带应答起到的效果
数据报从两个合并成一个,效率会有明显的提升
主要是因为这里每次传输数据都是需要封装和分用的
能合并的原因:
一方面,时机上是可以同时的
另一方面,ACK 数据本身不需要携带载荷,和正常的数据也不冲突,完全就可以让这一个数据报,既能携带载荷,又能带有 ACK 信息(ACK 标志位,窗口大小,确认序号)
面向字节流;
在面向字节流这样的情况下,会产生一些问题
粘包问题:
这里的 “粘” 指的是 “应用层数据报”
通过 TCP read / write 的数据,都是 TCP 数据报文的载荷,也就是应用层数据
发送方一次性是可以发送多个应用层数据报的,但是接收的时候,如何区分从哪里到哪里是一个完整的数据报呢?
换句话说,发送的是两个包,但是读成了一个或者一个半包,这个时候就会产生问题
此处,正确的做法是合理的设计应用层协议
需要站在应用层的角度来解决这个问题
1、应用层协议中,引入分隔符,区分包之间的边界
2、应用层协议中,引入 “包长度” , 区分包之间的边界
1、使用 \n 来作为包之间的分隔符
使用什么符号作为分隔符都是可以的,只要确保分隔符的字符不会在正式的报文中存在即可
2、使用包的长度来进行区分
通过上述过程,就完成了整体的读取和解析的过程
粘包问题,不仅仅是 TCP 才有的,只要是面向字节流的机制(文件),也有同样的问题
解决方式都是一样的,要么使用分隔符,要么使用长度
尤其是在自定义应用层协议的时候,就可以使用这样的思想来解决问题了
相比之下,前面介绍的这几种方案:
xml / json 都是通过分隔符来区分的
protobuffer 则是通过长度来区分的
TCP 异常情况处理:
网络本身就会存在一些变数,导致 TCP 连接就不能正常工作了
1、进程崩溃
进程崩溃 --> PCB 没了 --> 文件描述符表被释放了 -->相当于调用 socket.close() -->崩溃的这一方就会发出 FIN ,进一步的触发四次挥手,此时连接就正常释放了
soncket 在系统内核中也是一个文件,也会被放到文件描述符表中
此时 TCP 的处理和进程正常的进出,没什么区别
2、主机关机(正常步骤的关机)
正常关机,就会先尝试干掉所有的进程(尝试终止进程),就和刚才所说的崩溃的处理是一样的
主机关机会有一定的时间,在这个时间之内,四次挥手可能挥完了(正好),如果没有完,也没关系
3、主机掉电(拔电源,没有反应空间)
电脑突然就黑了,此时当然没有任何可以操作的空间了,此时 A 无法给 B 发送任何的 FIN
(1)如果 B 正在给 A 发消息(接收方掉电)
这个情况好办,B 发的消息,就没有 ACK 了,B 就会触发超时重传,重传仍然失败,就会触发 复位报文(RST = 1),尝试重置连接,重置操作仍然失败,此时就会继续单方面释放连接了(B没有什么负面影响)
(2)如果此时 A 正在给 B 发消息(发送方掉电)
这个情况稍微复杂一点
B 在等 A 的消息,A 突然就不发消息了,B 不知道 A 等会是否会继续发,B 就会进入阻塞等待
此处就涉及到 “心跳包”
B 这边虽然是接收方,也会周期性的给对方发起一个不携带任何业务数据(载荷)TCP 数据报,
发送这个包的目的,是为了触发 ACK ,就是确认一下 A 是否正常工作 / 确认网络是否畅通
如果 A 这边不能正确返回 ACK ,就说明 A 这边是挂了的
心跳包 和 我们之前说的 “流量控制” 窗口探测报文 是一个东西
虽然 TCP 中已经有心跳包的支持,但是还不够,我们往往还需要在应用层,应用程序中重新实现心跳包(TCP 的心跳包,周期太长了)
4、网线断开
相当于主机掉电的升级版本
A 这边发送的情况,就是主机掉电的第一种情况了
B 这边发送的情况,就是主机掉电的第二种情况了
TCP 和 UDP 的对比
TCP 的优势在于可靠性,适用于绝大部分的场景
UDP 的优势在于效率,适用于机房内部和主机之间的通信(机房内部,带宽比较充裕,不太容易遇到拥堵丢包的情况,又希望主机之间的通信比较快)
典型场景:竞技类游戏,既需要可靠传输,又需要效率,此时使用 TCP 和 UDP 都不合适
有时候也会使用其它更加合适协议,比如像 KCP 这样的协议(其它的所谓 “传输层协议” 并非真的工作在传输层,往往是在应用层实现的类似于传输层的机制,同时在底层是基于 UDP 实现可靠传输)
三、网络层
(1)4 位版本号:用来表示 IP 协议的版本
现有的 IP 协议只有两个版本,IPv4,IPv6,其它版本可能只是在实验室中存在,并没有真正大规模的商用
(2)4位首部长度,设定和 TCP 一样
IP 报头可边长的,IP 报头又是带有选项的
此处的单位也是 4 字节
(3)8 位服务类型
(真正只有 4 位才有效果)
最小延时:传输一个数据报的时间尽量短
最大吞吐量:一定时间内传输的数据尽量多
最高可靠性:在传输过程中最不容易触发丢包
最小成本:在传输过程中消耗的硬件资源最低
四种形态是互斥的,只能切换到一种形态
虽然 IP 协议支持这个机制,实际上开发中很少会真的应用到,一般都是系统级别的深度定制化
(4)16 位总长度
这个长度指的是 : IP 报头 + 载荷的长度
总长度 - IP 报头长度 = 载荷长度(TCP 报文的总长度)
TCP 报文总长度 - TCP 报头长度 = TCP 载荷长度
这里的 16 位总长度,确实也涉及到 64kb 的问题,但是 IP 协议自身支持 “拆包组包” 机制,这里的 64kb 只是约束了一个 IP 数据报
如果需要携带比较长的数据的时候,IP 协议会自动地把一个数据拆分成多个数据报,接收方在进行分用的时候,也会把多个数据报合并成一个
(5)16 位标识,3 位标识位,13 位片偏移
这三个,描述了 整个IP 数据报拆包组包的过程
当 IP 数据报需要携带比较长的数据的时候,就在 IP 协议这一层触发 拆包 操作
把一个打包拆成多个小包
多个小的 IP 数据报都会带有 IP 报头,载荷是 TCP 数据报的几个部分
16 位标识:拆出的这多个包,16位标识是相同的
13 位片偏移:不同的,前一个包的片偏移更小,后一个更大,通过片偏移就可以区分包的先后顺序
3 位标识位:其中有一位是不用的,还有一位表示是否允许拆包,剩下一位表示结束标记(标识当前的包是否是最后一个)
(6)8 位生存时间 TTL
单位是 次
初始情况下,TTL 会有个数值(32 / 64/ 128)
每次经过一个路由器转发,TTL 就会 -1,当TTL = 0,就会被丢弃
正常来说。TTL 足以支持数据报到达网络的任意一个位置,如果确实出现 0 了,基本可以认为该 IP 不可达
(7)8 位协议
描述了上层,传输层使用的哪种协议
(8)16 位首部校验和
校验数据是否正确的,只需要校验首部即可
因为载荷部分,要么是 TCP ,要么是 UDP ,人家自己已经自己检验过了
(9)32 位源地址,32 位目的地址
IP 协议中最最重要的部分