目录
首先我们要知道,咱们自己编写的应用程序就是在应用层。其次呢,虽然应用层里面有一些贤臣现成的协议,但是实际工作中少不了自定义协议。
自定义协议,需要从两个角度入手:
第一点:明确交互过程要传递的信息有哪些
就拿点外卖这一件事情来说。我们用户在挑选外卖,涉及到的请求与响应:
【挑选商家】
请求:用户信息,位置信息...
响应:商家的信息,包括商家名称,商家的评分,商家的位置,商家的预览图等等...
【挑选某一家的外卖】
请求:用户信息,点击的商家信息....
响应:该商家的详细情况,包括哪些外卖可以点,每个菜都是多钱,以及销量如何等等...
第二点:如何将数据组织起来,有哪些形式
方式一:使用分隔符对不同的信息做区分:
方式二:使用固定长度来区分不同的信息:
方式三:使用 xml 格式来约定数据:
方式四:使用 Json 格式来约定数据:
方式六:还有一些其他的二进制的数据组织形式,例如 protobuffer、thrift 等等。
🍃前两种方式简单粗暴;
🍃像 xml 和 Json 都属于文本格式
优点:可读性高。缺点:效率低,占用的带宽多。
🍃二进制数据组织形式
优点:效率高,占用带宽少。缺点:可读性差。
上述几种数据约定方式,还是要根据实际情况来抉择,具体问题具体分析!!
UDP 特点:无连接,不可靠传输,面向数据报,全双工。(前面 TCP/UDP套接字编程博客中有详细讲解)
UDP 报文格式:
上图是教科书式报文格式(为了排版好看),真实的的格式其实是以下格式:
🍃1.源端口号/目的端口号:我们使用 IP 地址区分当前是哪台主机,一台主机上有多个应用程序,我们再用端口号区分。可以这样理解,源端口号 :发件人电话号码;目的端口号:收件人电话号码。
🍃2.UDP 长度:2个字节,能表示的最大数据是 65535,也就是 64KB,这是个非常大的缺陷。业务在传输数据的时候 ,随便一次数据传输,都可能逼近 64KB;对于大于 64KB 的数据,如果使用 UDP协议,就只能拆成多个数据报来进行传输,但这样又会引来一个新的问题:后发先至!!
所以 64 KB 的限制给我们的服务器客户端交互造成了不小的制约!!
🍃3.校验和:校验和就相当于,我们批量买货,老板将货送到后,我们进行 "点货"。
为什么需要校验和:因为 UDP 传输数据时,网络上的数据可能会受到干扰(0/1 电频这种无线,很容易受到干扰)。
- 因此 UDP 传输数据时,会针对要传输的数据,计算一个 "校验和",传输的时候,把数据连带校验和一起发送过去;接收的时候,针对收到的数据重新计算校验和吗,对比一下自己计算的校验和传输过来的校验和是否一样!!
- 如果校验和不同,就认为数据一定是传输过程中出错了;如果校验和相同,也不能 100% 认为数据一定没有错,可能正好有两个地方错一块去了,导致校验和没变,但是这种概率极小,站在工程的角度上是忽略不计的。
TCP 对数据传输提供的管控机制,主要体现在两个方面:安全和效率。这些机制和多线程的设计原则类似:保证可靠性的前提下,尽可能的提高传输效率。
可靠性是如何保证的?通过确认应答。
确认应答,要针对数据进行编号,然后才能明确,应答报文是在应答哪个数据。("后发先至"导致的) 于是 TCP 就引入了 "编号" 这一概念。
TCP 报文格式:
上图中的 32 位序号和 32 位确认序号就是 TCP 协议用来编号的。如果当前报文是一个普通报文,则确认序号不生效;如果当前报文是应答报文,则确认序号就表示应答的是哪个普通报文。
【问题】如何区分,普通报文和应答报文??
TCP 报文格式中有六个特殊的比特位:
其中第二位 ACK 就是用来表示是否为应答报文的。如果 ACK 为 0 ,就表示不是应答报文;如果 ACK 为 1,就表示是应答报文。所以有时候也把应答报文称为 ACK(acknowledge) 报文。
确认应答机制,也存在一定的概率没收到数据(没收到 ACK),此时就需要超时重传机制来解决了。网络的环境是非常复杂的,尤其是网络拥堵的时候,就可能导致丢包!
对于发送方,无法区分上述两种情况。因此,发送方能做的就是,达到一定的等待时间后,如果还没收到 ACK,就会重传!!
【问题一】重传数据后,是否还会再丢包?
答案是肯定的。假设每次传输数据的丢包概率是 10%(已经相当严重了),那么连续发两次数据发生丢包的概率就是 10% * 10% = 1% ,
【问题二】那么重传数据的超时时间,应该设为多少合适?
假设第一次丢包的时间是 t1,第二次丢包的时间是 t2。此处的等待时间间隔,会随着时间的推移,越来越长(t2 > t1)。如果两次数据都没发过去,那么说明单次发送丢包的概率就相当大了;连续重传后,随着丢包次数的增加,就可以断定很可能是网络上遇到了非常严重的故障,短期内恢复不了,那么你发送数据发送的再频繁,都无济于事!
所以,超时重传也不会无限制的重传下去。尝试几次之后,仍然无法传输过去,此时就会放弃尝试,就只能断开连接,尝试重连。如果重连还是连不上,就彻底放弃了。
TCP 是有连接的协议,所以需要建立连接和断开连接,也叫做 "三次握手" 建立连接,"四次握手" 断开连接。
🍔三次握手
三次握手,一定是客户端先发起的。
🍃虽然叫做 "三次握手" ,实则是四次交互。1.客户端先给服务器发送 syn 同步报文段;2.服务器返回确认应答(ACK);3.服务器给客户端发送 syn 同步报文段;4.客户端给服务器也回复一个 ACK。只不过 2,3 步骤可以合并成一次。
🍃此处为什么将 2,3 合并成一次:因为每次数据报文传输,都要经过一系列的封装分用,分成两个包来发就比一个包的代价大很多。(这就好比你在淘宝上同一家店下单了两件同样的衣服,发两次快递和发一次快递的区别)
🍃三次握手的时候,B 返回的 ACK 和 SYN 都是内核收到 A 的SYN 之后,立即返回!!
【问题一】三次握手的意义/初衷是什么?
🍃1.三次握手,可以认为是一种保证可靠性的机制!它就相当于 "投石问路",在正式通信之前,先确定好通信链路是否畅通。如果通信链路不畅通,后续大概率要丢包!!(高铁/地铁也类似,第一趟列车,都是空车先跑一趟)
🍃2.协商重要参数。例如前面所说的编号 1 - 1000,实际上可能不是从 1 开始,这就需要双方协商,到底从多少开始。
【问题二】为啥一定要三次握手?四次行不行?两次行不行?
三次握手是在验证通信双方的发送能力和接收能力是否正常。四次可以是可以,但没必要,三次就能完成的事情,握四次影响效率;两次一定不行,两次验证不了,结合下图理解:
🍔四次挥手
四次挥手,客户端和服务器都可以主动发起。
【问题】四次挥手中间两步为什么不合并 ?
回答:四次挥手的中间两个步骤不一定能合并!!
🍃1.三次挥手中,B 返回的 ACK 和 SYN 都是内核收到 A 的SYN 之后,立即返回!!
🍃2.四次挥手中,虽然 B 返回 ACK 也是内核的行为,操作系统内核收到 FIN 后,就会立即返回 ACK,但是接下来 B 的 FIN 是用户代码的行为。用户在代码中调用 scoket.close() 方法,才会触发 FIN !!结论:因此,B 发送 FIN 和 发送 ACK 之间就会有不可忽视的时间间隔,具体是多长间隔,这是取决于用户的,是不确定的,所以不能合并!!
🍁总结 "三次握手,四次挥手"
🍃三次握手:如果把建立连接,理解成入职,B 这边属于没工作状态,A 抛出橄榄枝,B 立即就同意了。(创建 Socket 对象,其实在实例化的时候,就是在建立连接)
🍃四次挥手:如果把断开连接理解成离职,A 给 B 发送 FIN(离职通知)的时候,很有可能 B 手头还有一些工作需要交接,B 啥时候发送 FIN,这就是代码层次的事情了,是不确定的,所以一帮不会立即断开。
上图中包含了三次握手,四次挥手以及中间数据传输流程,TCP 的状态转换。
🍁TCP的四个重要状态
1.LISTEN
服务器已经启动完毕,已经绑定端口成功!(别人可以给你打电话了,信号良好)
2.ESTABLELISHED
连接建立好了,可以进行后续通信。(电话已拨通,对方已接听,可以进行正常对话了)
3.CLOSE_WAIT
被动接收 FIN 的一方。表示自己收到了 FIN,也返回了 ACK,在自己发送 FIN 之前,处在的状态。也就是在等待代码中调用 close 方法,然后才发送 FIN。(如果服务器发现大量 CLOSE_WAIT,说明代码出 bug 了,哪里忘记了 socket.close())。
4.TIME_WAIT
主动发送 FIN 的一方,会进入 TIME_WAIT。主动的一方,收到对方发来的 FIN,并返回 ACK 之后,就会进入这个状态。
【问题一】为什么不是直接 CLOSED 状态,而是停留一段时间才进入??
为了防止最后一个 ACK 丢失,万一最后一个 ACK 丢失了,在 TIME_WAIT 状态下,还可以重传数据;但是一旦进入了 CLOSED,连接释放了,就无法进行重传数据了。
============================================
【问题二】那么 TIME_WAIT 停留的时间是多久呢?
停留 2MSL。(MSL 表示两个主机之间,数据从一端发送到另一端所花费的最大时间),停留两倍的 MSL,是因为,如果 ACK 丢了,主机 B 就还需要重新发一次 FIN,A 也需要回复一次 ACK,所以需要等两倍的 MSL。
滑动窗口是用来提高传输的效率的机制!但是我们要明白的一点是,可靠性和效率是不可兼得的,要想保证可靠性,就一定会影响到效率。我们的滑动窗口是在 可靠的前提下,尽可能的提高效率。滑动窗口的本质是把等待 ACK 的时间重叠起来,减少等待时间,从而做到提高效率。
滑动窗口在不等待的前提下,最多可以一次发 N 条数据,N 就代表滑动窗口的大小。此处的 N 越大,则同时批量发的数据就越多,传输效率就越高;但也不是 N 越大越好,因为我们的传输效率等于发送效率 & 接收效率 ,我们的接收效率是有上限的,如果发送的太快,接收不过来,发再多的数据,都会丢包,后面会说。结合下图理解使用滑动窗口传输数据的执行过程:
【问题】 在滑动窗口之下,丢包了怎么办?(确认应答不受影响,超时重传可能会被影响)
丢包有两种情况,1.数据已到达,ACK 丢了;2.数据报丢了。
【总结】
滑动窗口相比于没有滑动窗口的普通确认应答能提高效率,但是如果和无可靠性的传输相比(UDP),效率还是要差一些的,可以说 UDP 是滑动窗口的上线!!与其说它提高效率,不如说它是在补救普通确认应答的低效率。
流量控制本质上是对滑动窗口的制约。
前面说了 整体的传输效率 = 发送效率 & 接收效率,如果发送效率 > 接收效率,这个继续提高发送速率,就不能够提高整体的传输效率了,反而还会因为丢包,触发更多的数据重传,导致效率降低。而流量控制要做的就是让发送效率和接收效率步调一致!!
【问题一】发送速率好办,通过滑动窗口来衡量,那么接收速率如何衡量??
结合下面的例子进一步理解:
【问题二】我们知道接收速率是通过缓冲区剩余空间来衡量的,那接收方如何把接收缓冲区的剩余空间告知对方呢?16位大小能表示的最大数值只有 64KB,难道滑动窗口的大小,最大就是 64KB?
1.通过在 ACK 这个报文上带上缓冲区剩余空间这个信息返回给对方。
2.滑动窗口的大小并非最大只有 64KB,而是要乘上一个滑动因子计算出具体的大小。
流量控制不仅可以控制发送方的快慢,甚至还可以让发送方暂停发送数据,等空间腾出来了,再继续发。
流量控制是站在接收方的角度,来控制发送速率。但是整体的传输,其实不仅仅是发送速率和接收速率,还与中间的一系列用来转发的设备相关!!
【问题一】如何做实验?
🍃1.刚开始按照小的窗口进行发送。
🍃2.如果不丢包,说明网络中间环境畅通,就可以逐渐扩大发送滑动窗口的大小。
🍃3.放大到一定的程度,速率已经比较快了,网络就比较容易出现拥堵,继续放大,就慢慢会出现丢包!!当发送方发现丢包之后,就立即减小发送滑动窗口的大小。
反复在 2,3 两个步骤之间循环,这个过程,就达到了一个 "动态平衡"。既保证了发送速率不慢,又可以尽量少丢包,还能够适应网络环境的变化。(逐渐逼近网络环境能承载的极限)
【问题二】既然流量控制和拥塞控制都是在保证可靠性的前提下,控制滑动窗口的大小,来制约发送方的发送效率的,两个因素都能改变滑动窗口大小,那么滑动窗口的大小到底根据谁来改变呢?
如果是拥塞控制的窗口大,流量控制的窗口小,说明中间设备的转发能力强,接收端的代码处理的慢;如果是拥塞控制的窗口小,流量控制的窗口大,说明中间设备的转发能力弱,接收端的代码处理的快。发送方下一次发送的滑动窗口大小,取决于流量控制的窗口和拥塞控制的窗口中的较小值!!
上述分析都是定性分析,下图是定量分析。(经典策略)
TCP 实现的时候,滑动窗口大小的变化,也是有明确的策略的。
初始时候,拥塞控制窗口从一个很小的数字开始(网络环境很复杂,是否拥堵不确定,先用小窗口试试水),然后再指数增长,如果窗口大小,达到预设的阈值之后,就变为线性增长,当线性增长达到一定程度的时候,此时就可能会丢包!!这时候,就直接让窗口的大小回到一个很小的值,然后重复之前的指数增长 -> 线性增长这个过程,同时会把刚才线性增长的阈值,进行调整。
延时应答也是一个用来提高效率的机制,它是让滑动窗口在保证可靠性的前提下能够尽可能大一些。
延时应答的两个好处
🍃好处1:能够尽可能的让接收缓冲区变得更大。
此处的让接收缓冲区尽可能的变得更大并不是 100% 能起到作用(有时候能起到作用),就算没有延时应答的话,最多就是普通的应答。
上图的延时应答还是挺明显的,再来看一下不太明显的延时应答:
上图中的延时应答,就显得作用没那么明显了,因为在延时的同时,又有新的数据发送过来了,新的数据也会导致缓冲区变小,并且新来的数据导致缓冲区变小也是应该。总之,延时应答的存在还是有意义的。
🍃好处2:能够减少返回 ACK 的次数,进而提高效率。
上图中我们发现少了一个 1001 ACK,但是也不影响。由于延时应答,接收方还没发送 1001 ACK,就又收到了 1001 - 2000 的数据,所以他干脆就只发送 2001 ACK 就行了,且收到 2001 ACK 就表示前面的数据也都收到了(后面的会涵盖前面的)。类似快递发货,你在同一家店下单相同的两件衣服,发一个快递和发两个快递相比,肯定发一个快递效率高。
捎带应答是基于延时应答的策略,也是用来提高传输效率的。
客户端在给服务器发送请求的时候,服务器会返回一个 ACK 和 一个响应。由于 ACK 是内核立即返回的,而响应则是应用程序代码发送的,二者是不同的时机发送的。但由于延时应答,使得 ACK 的发送时机刚好和响应的发送时机重合了,捎带应答就可以让这个 ACK 搭上 发送响应的顺风车,一次发过去。前提是响应要发送的快,不然 ACK 可能就先发过去了。
捎带应答也是可能让 "四次挥手" 变成 "三次挥手" 的,也是和上面一样的原因:
面向字节流,指的是读写载荷数据的时候,是按照 "字节流" 的方式来读取的。传输层 TCP 数据报,本身仍然是一个一个 "数据报" 这样的方式来传输的,只是应用程序感知不到。
按照字节流的方式读数据,就可以灵活的进行读数据,可以一次读 M 个字节,分 N 次读。
面向字节流最核心的问题:粘包问题
上述 主机A 给主机B 传输数据,如果不做处理,一定会导致粘包问题,接收缓冲区里每个数据报的数据都混在一起了,B的应用程序读多少字节才是一个完整的数据,他不清楚,很可能会导致 发来的 aaa 是一个完整的数据报,结果读到了 aaab 一个半数据报,或者 aa 半个数据报,这样就会出问题。不仅仅是 TCP 会发生粘包问题,只要是面向字节流的,都有粘包问题!!
【问题】如何解决粘包问题?
使用自定义应用层协议,通过下面两种方式就可以明确从哪里到哪里是一个完整的应用层数据报,这样就可以解决粘包问题了。
1.使用分隔符;2.约定长度。
通过类似分号这种分隔符就可以确定从哪到哪是一个完整的数据报,也可以通过约定每个数据报多长,在读数据的时候,一次就读多少个数据。
1)主机关机
按照程序关机,会先杀死所有的用户进程(包括 TCP 程序),释放进程 PCB,进而释放文件描述符表(相当于调用 close),这个时候就会触发 FIN,开启四次挥手的流程!!
1.如果四次挥手挥完了,继续关机,不影响;
2。如果四次挥手没挥完,就关机了,对端重传 FIN 若干次,没有响应,也就放弃了,也不影响。所以这种异常和正常关闭没什么区别。
2)程序奔溃
和主机关机情况相同。
3)主机掉电(突然把电源)
如果是笔记本电脑,它内置了电源,那没什么事。如果是台式机,直接就没了。
针对台式机分两种情况讨论:
🍃1.接收方掉电:对方尝试发送数据,发现没有 ACK,就尝试重传,重传了几次,仍然没有 ACK,发送方就尝试建立连接,如果重新建立连接也不成功,就认为当前网络上出现了严重的问题,也就自然放弃了。
🍃2.发送方掉电:接收方在等待发送方发的数据。由于发送方没了,数据肯定就是发不过来了。但是接收方,区分不了对方是还没发送数据呢,还是对方出问题了。接受方如果一段时间没有收到数据,就会定期给发送方,发送一个 "心跳包",相当于接受方给发送方发送一个特殊的报文(ping),如果发送方只是还没发数据,那么他就会返回一个特殊的报文(pong),如果接收方没收到这个特殊的报文(pong),接收方就认为发送方挂了。
4)网线断开
和主机掉电情况相同。
🍁【TCP 总结】
🍃确认应答,超时重传,连接管理是一组保证可靠性的机制。
🍃滑动窗口是提高效率的机制。
🍃流量控制,拥塞控制是一组保证可靠性的机制。
🍃延时应答,捎带应答是一组提高效率的机制。
🍃面向字节流中的粘包问题,异常处理是其他方面的问题。
🍃1.如果比较注重可靠传输,优先考虑 TCP;
🍃2.如果传输的单个数据报比较大(UDP报文上限是 64KB),优先考虑 TCP;
🍃3.对于可靠性要求不高,对于性能要求高,就考虑 UDP。
如果是 "广播",像 CCtalk 这种软件,优先考虑 UDP,可以直接往广播 IP 上发消息。使用 UDP 的话,就在应用层实现可靠性;如果使用 TCP 的话,就在应用层实现广播。
【经典面试题】如何使用 UDP 实现可靠传输?
在应用层代码里面,参考 TCP 的策略来实现。(TCP 怎么做,我们就怎么做)
本篇文章就都这里了,谢谢观看!!