TCP 特点:可靠传输,有连接,面向字节流,全双工。(后三个特性,我们在网络编程代码中完全可以感知到)
对于可靠传输来说,是内核实现的,写代码的时候,是感知不到的。(感知成本低,其使用成本也就降低了)
先面我们来仔细讲解一下可靠传输的核心机制是什么???
确认应答,这是保证“可靠性”最核心的机制,确认应答是用来解决什么的呢??
我们先来观察下面这个问题。
当连续发多条数据的时候,这多条数据,可能会出现"后发先至"。
后发先至:一个数据报,是先发的另一个是后发的,后发的反而先到了。导致出现问题。
分析“后发先至”原因:网络上从A->B中间的路径有很多,从A->B走的路线不一定相同!!另外,每个节点(路由器/交换机)繁忙程度不一样。此时,这样的转发过程,就也会存在差异,就和等红灯一样。信息抵达的顺序存在差异。
解决“后发先至”问题的根本->确认应答机制。
想要给数据加上编号,该怎么加呢??
由于 TCP 是面向字节流的,所以在传输数据时是针对字节进行编号的,而不是按照"条"为单位来传输。
当主机A 给主机B 发送消息时,A发送过去的数据为1000个字节,按照字节编号,最后一个字节的编号为1000。当主机B收到 A 发过来的数据后,要做出响应,返回一个确认收到的序号(简称 确认序号) 1001,含义就是:1001编号之前的数据我已经收到了,下一次从1001开始发数据。
应答含义:1001 序号之前的数据,我这面都收到了。接下来你应该从 1001 开始发。
在 tcp 报头中,只要知道这一串字节的开始编号,以及数据的长度,就能确定每一个字节的编号。TCP 数据流中的每个字节的编号存储在32位序号中。
确认应答信息:期望收到的下一个数据段的序号,也就是收到的最后一个字节的编号再加一,存在32位确认序号中。
当另一个主机发给我们一个 TCP 报文,该如何判断当前这个报文是普通报文,还是一个确认响应报文??
这里的应答报文序号和普通报文序号,之间没有关联关系,都是各自论各自的。
序号,只是你自己这个主机,发送的数据进行的编号。
注:主机 A 和主机 B 之间,每发送一个数据,均是一段 TCP 协议格式,里面包含端口号、序号、确认序号、标志位等……
丢包。在网络上很可能出现,发一个数据,然后丢了。
为什么会发生丢包??
网络通信之间,是由一些 路由器/交换机 组成的,这些 路由器/交换机 就相当于一个“交通枢纽”。结构复杂,传输的数据量也是不确定。这一会传输的数据比较少,但过一会数据就很多了。如果设备太繁忙了,后面新来的数据等太久了就可能被丢弃了。网络负载越高,越繁忙,就越容易丢包。
发生丢包该如何做??
我给女神发短信,短信发丢了。我是收不到应答的。如果我收不到应答,我就会去尝试重新发送,这在某种意义上来说就是重传。
超时重传,相当于针对确认应答,进行的重要补充。
丢包的发生,有两种情况:
发送方无法区分是哪种情况!!既然无法区分,就都重传(发送方,答应方都重发)!!
同样的消息发送两次,这里我们考虑到对数据进行去重。当再次发送时,接受方收到数据后,需要对数据进行去重,保证应用程序,调用 inputStream.read 时,读到的数据不会出现重复。
如何去重呢??如何高效的判定当前收到的数据,是否是重复的呢?
直接使用 tcp 的序号来作为判定依据。TCP 会在内核中,给每个 socket 对象都安排一个内存空间,相当于一个队列,也称“接受缓冲区”。收到的数据,都会被放到接受缓冲区里,并按照序号排列好顺序。此时,就可以很容易的判断出接受到的数据是否重复了。
注:A 主机给 B 主机发送数据,该数据的编号会被放在接受缓冲区中。开始 read,B 主机返回响应,此时当 A 接受到响应数据时,缓冲区响应的编号才会被删除(这里是否被删除可以自定义的),否则会被一直储存。
这里的超时重传,为啥重传一下,数据就能过去??
丢包本质上是一个概率性的问题,假设丢包的概率为 10%,传输成功的概率为 90%。连续传输两次的概率为 1%。随着你重传次数的增加,总体能够传输成功的概率,是更大的。
“超时”重传,超过多长时间再重传呢??
超时时间不是一个固定的值,会随着超时轮次的增加,而进一步增加。
随着重传轮次的增加,等待时间也会越来越长。正常情况,第二次重传就有极大的概率,重传成功。再不济,第三次重传,更大的概率能成功。
要是这几次都没成功,说明当前网络本身的丢包概率已经极高了。网络怕是遇到了一些比较严重的故障。此时,进行频繁的重传,就是白费力气!!
结论:超时重传的轮次,也不是无限的。重传次数达到一定程度,也就放弃了。此时就会触发 复位报文 (RST字段为1),尝试重新连接。如果重置仍然失败,此时发送方就会进行单方面释放连接了。
最核心的就是两个部分:
为什么叫做 三次/四次“握手”呢,握手有什么含义??
因为实质上,在建立连接和断开连接的过程中,其传输的数据并不会带有一些实际意义上的数据,只是用来打招呼的数据。因此把 建立连接/断开连接 称之为 N次握手。
握手:handshake,一定是客户端主动发起的。
A 和 B 完成建立连接的过程,就需要“三次”这样的打招呼的数据交互。
上图中主机之间的连接需要四次交互,交互完毕之后,连接就算建立好了。此时双方就已经保存了对端的信息了。
那为什么说是三次握手呢??
看起来是四次握手,其实中间这两次,能够合并成为一次!
为啥合并??
封装和分用。合并之后,节省了封装和分用的过程。降低了成本,提高了效率。原则上来说,能合并就合并。
TCP三次握手是怎样的过程呢??
这里又要提到标志位了,其中 SYN 是申请建立连接的请求“同步报文段”,如果 SYN=1,就是“同步报文段”,意思是一台主机就要尝试和另一台建立连接。ACK 上文说过是应答报文。
三次握手的 意义/初心 到底是什么呢??
投石问路,保障网络通信的通畅,以及检验每个主机的发送能力和接收能力是否正常。
“消息协商”,协商必要的参数,使客户端和服务器使用相同的参数进行消息传输。
TCP通信过程中,有很多信息是需要进行协商的。比如双方的序号从几开始 (一般不会从 0/1开始)
序列号是TCP协议中用来标识每个数据段的一个值,通过协商初始序列号,可以保证数据的可靠传输。
网络上传输的消息,可能后发先至(先发后至)极端情况下,某个消息,迟到了,迟到了很久~~
当消息到达对端的时候,服务器和客户端已经断开了上一个连接,这是重新建立的连接了这个时候,就可以通过序号,明显的识别出这个是上一个连接的消息,就可以丢弃了。
四次挥手的流程,和三次握手,十分相似。四次握手不一定是客户端还是服务器主动发起的,都有可能,大多还是客户端主动发起的。
断开连接:在已连接,通信的双方,各自在内存中保存对端的相关信息。如果不需要了,就得及时的释放上述存储空间。
问题一:四次握手,能不能合并成三次呢??
四次挥手有的时候确实是可以三次完成的,但是有的时候不行。中间这两次,不一定能合并。
FIN的触发,是应用程序代码,来控制的。调用 socket.close(),或者进程结束,就会触发FIN 。
相比之下,ACK则是内核控制的。收到 FIN就会立即马上返回ACK。
万一服务器还需要做很多其他的收尾工作,close执行的时机就会很慢。
close 触发的快,有可能和上一个 ack 合并的。如果 close 触发的慢,此时就无法和上一个 ack合并了。
问题二:如果服务器,始终不进行 close,会咋样?客户端的连接就始终不关闭嘛?
此时,服务器的TCP状态,就处于 CLOSE_WAIT 状态。
站在服务器的角度,虽然这里的连接没有关闭,但是这个连接其实已经不能正常使用了。
针对当前 socket 进行读操作,如果数据还没有读完(缓冲区还有数据),是能正常读到的。如果数据已经读完了,此时读到的就是 EOF(对于字节流来说,返回-1。如果是 scanner,hasNext 就会是 false)
针对当前 socket 进行写操作,直接就会触发异常。
如果极端情况下,服务代码出 BUG了,忘记写close()了。站在客户端角度,迟迟收不到对方的 FIN 请求,也会进行等待。如一直都等不到,此时就会 宣布单方面放弃连接(客户端直接把自己保存的对端的信息就删掉/释放了)。
问题三:如果通信中出现丢包了,该如何处理??
这也是涉及到超时重传的,三次握手/四次握手 也都是带有超时重传机制的。尽可能重传,如果重传仍然失败,连续多次,此时仍然会单方面释放连接。
如下图,站在A的角度,当收到这个 FIN 之后,并且发出去 ACK了此时 A 就视为四次挥手已经结束了。此时 A 就可以直接释放连接了嘛??还不可以!!
因为最后一个 ACK 还可能丢包,如果最后一个ACK丢失,B就会重新传过来一个FIN。此时如果 A 已经把连接释放了,重传的 FIN 就无人能够进行 ACK 了。
因此,就需要让 A 在发出去最后一个 ACK 之后,让连接再等一会。(主要就是看等的过程中会不会收到对方重传的 FIN)如果等了一定时间之后,对方还没有重传 FIN,就认为 ACK 已经被对方收到了。此时 A 才能正确释放连接。
那这里到底需要等待多长时间才能释放呢?等待时间就是 :网络上任意两点之间传输数据的最大时间 * 2,把这个时间定义为 MSL.
还有极端情况。比如, A在等 2MSL 时间的过程中,B在反复重传FIN多次,这些FIN都丢了。(理论上存在)如果真出现这个情况当前网络一定是出现严重故障了。这个时候,是不具备“可靠传输”前提条件的。因此,A就单方面释放资源,也无所谓了。
滑动窗口可以提高 TCP 传输效率.(更准确来说,是让 TCP 在可靠性传输下,效率不会太拉胯)
滑动窗口,不能使 TCP 传输效率比 UDP 快,但是可以缩小差距。
滑动窗口相比于传统的确认应答有什么不同??
正常的确认应答是可以保障传输可靠性的,但是大量的时间,都消耗在等 ACK 上了。
因此使用滑动窗口,就是为了缩短上述等待时间,进行批量发送。
滑动窗口的原理是什么??
一次性发出一组数据,发这一组数据的过程中,不需要等待 ACK 就可以直接往前发。
此时,就相当于使用“一份等待时间”等四个 ACK。
窗口越大,此时批量发送的数据就越多,效率就越高。但是窗口不能无限大。如果是无限大,相当于完全不必等 ack,此时就和不可靠传输差不多了。
操作系统内核为了维护这个滑动窗口,就需要开辟一个发送缓冲区,来记录当前还有那些数据没有应答;只有应答过的数据,才能从缓冲区中删掉。
A给B发送一组数据,当B返回一个ACK,A则继续发送一个数据,使发送缓冲区的窗口大小始终不变。
滑动窗口,是一个"形象的比喻",实际上本质就是批量发送数据。这样就可以缩短等待时间,比之前能提升一定的效率(缩短不是没有,仍然需要花时间等待,传输效率仍然不会比 UDP 更高)
在保障可靠性传输的情况下,批量传输中,出现丢包怎么办??
丢包分为两种情况:
ACK 丢了
ACK 如果丢失了,不用做任何的处理,也是正确的!!除非是所有的 ACK 都丢失了(网络出现重大故障),否则只是出现一部分 ACK 的丢失,对于可靠性传输没有任何影响。
此时,就相当于是使用最小的成本,来完成了这个重传数据的操作。(只是把丢的数据重传了,其他的数据都没有重复操作),这种方法就叫做:快速重传。
滑动窗口总结:滑动窗口也不是说,只用 TCP 就一定涉及。如果通信双方大规模传输数据,肯定就是滑动窗口。如果通信双方传输数据规模较小,这个时候就不会用到滑动窗口了(仍是之前提到的超时重传)
滑动窗口越大,其传输效率越高。但是凡事都有个极限,因此窗口也不能无限大。如果窗口太大了,就会导致接收方接收不过来。或者是传输的中间链路出现处理不过来的情况。这样就会出现丢包,就得重传了。因此我们知道,窗口过大并没有提高效率,反而还影响了效率。
流量控制,就是根据接收方的处理能力,来限制发送方的发送速度(窗口大小)
如何衡量接收方的处理速度??
使用接收缓冲区剩余空间大小来作为衡量指标。
如果剩余空间越大,应用程序消费数据的速度就越快。
接收缓冲区的大小如何反馈给发送方??
此处,就会直接把接收缓冲区的剩余空间大小,通过 ack 报文反馈给发送方,作为发送方下一次发送数据,窗口大小参考依据。
拥塞控制是一种网络流量管理机制,用于确保在网络中的数据传输过程中不会发生拥塞。当网络中的流量过大,超过了网络的容量时,就会发生拥塞,导致数据传输的延迟增加、丢包率增加等问题。拥塞控制的目标是通过调整数据发送速率,使得网络中的流量保持在一个可控的范围内,从而避免拥塞的发生。
如果中间某个环节 (路由器/交换机),转发能力特别差,此时A的发送速度就不应该超过这里的阈值。
对于我们程序猿来说,如何去衡量中间设备的转发能力呢??
此处,并不会针对中间设备的转发能力进行一个量化,而是把中间设备看成一个整体,采取“实验”的方式,来动态调整,产生一个合适的窗口大小。
- 使用一个较小的窗口传输。如果传输通畅,就调大窗口。
- 使用一个较大的窗口传输。如果传输丢包 (出现拥堵),就调小窗口。
拥塞窗口:在拥塞控制机制下,采用的窗口大小。
TCP 中,拥塞控制具体是这样展开的:
慢启动:刚开始进行通信的时候,会使用一个非常小的窗口,先试试水。
这里采用小窗口的原因:如果网络本来就拥堵,一上来又搞了一个很大的流量过来,就使本来不富裕的网络带宽,雪上加霜了。
指数增长:在传输通畅的过程中,拥塞窗口就会指数增长 (*n)。
指数增长速度是非常快的,不能不加限制,否则就会出现非常大的值。
线性增长:指数增长当拥塞窗口达到一个阈值之后,就会从指数增长,转换成线性增长 (+n)。
线性增长较指数增长慢了些,但也是增长,会使发送速度,越来越快。快到一定程度,接近网络传输的极限,就可能出现丢包了。
拥塞窗口回归小窗口:当窗口大小增长过程中,如果传输出现大量丢包,就认为当前网络出现拥堵了。此时就会把大窗口调整成最初的小窗口,继续回到之前的 指数增长 + 线性增长 的过程了。
此处也会根据当前出现丢包的窗口大小,调整阈值(指数增长->线性增长)
在实际发送的时候,不仅仅看拥塞窗口,还有流量控制窗口。实际发送方的窗口= min (拥塞窗口,流量控制窗口)
因此实际发送方窗口,不光要考虑接收方的处理能力,更要考虑中间节点的处理能力。
是提高传输效率的机制,围绕滑动窗口琢磨的。是否有办法,在条件允许的基础上,尽可能的提高窗口大小呢??
只需要在返回 ack 时,拖延一点时间。利用拖延的时间,就可以给应用程序留出更多的消费数据的时间。因此接收缓冲区的剩余空间就更大了。
此处,到底通过延时应答,能提高多少速度,还是取决于接收方应用程序的实际的处理能力。(机会给你了,能不能把握,这个就是另当别论了!!!)
那么所有的包都可以延时应答嘛??肯定也不是!
具体的数量和超时时间,依操作系统不同也有差异。
在延时应答的基础上,引入第一个进一步提高效率的方式。
在实现 TCP回显服务器 的时候,我们已经非常深刻的体会到了,什么是面向字节流。
在面向字节流的情况下,还会产生一些其他问题———粘包问题。
什么是粘包问题??
这里粘包,粘的是应用层数据报。通过 TCP read/write 的数据,都是 TCP 报文的载荷,也就是应用层数据。
发送方一次性是可以发送多个应用层数据报的。但是接收的时候,如何区分,从哪里到哪里是一个完整的应用数据报??如果没设计好,接收方就很难区分,甚至会产生bug !!
如何解决粘包问题??
这个问题的正确处理方法,是合理的设计应用层协议。这件事在传输层本身已经无解了。
站在应用层角度的解决方式:
粘包问题,不仅仅是tcp 才有的。只要是面向字节流的机制(文件)也有同样的问题。解决方案也都是一样。要么使用分隔符,要么使用长度。
尤其是在自定义应用层协议的时候,就可以使用这样的思想来解决问题了。
相比之下,前面介绍过的xml/json 都是通过分隔符来区分包的。protobuffer 则是通过长度来区分的。
网络本身就会存在一些变数。导致 tcp 连接不能继续正常工作了。
进程崩溃
进程崩溃了-> PCB就没了-> 文件描述符表也就被释放了->相当于调用 socket.close()-> 崩溃的一方就会发出 FIN,进一步的触发四次挥手,此时连接也就正常释放了。和 TCP 正常处理断开连接没啥区别。
主机关机(正常关机的步骤)
正常关机,就会先结束所有进程(强制终止进程)。因此就和刚才说的进程崩溃的处理是一样的。主机关机会有一定时间,在这段时间内,四次挥手是能挥完的。如果没挥完,也没事,如下图:
主机掉电/网线断开
此时主机 B 正在给 A 发消息
此时,A直接掉电了,就不会给B返回ACK。因此,B就会触发超时重传,如果重传失败,就会触发 复位报文(RST 字段为1,尝试重新连接).若果重置操作仍失败,此时B就会进行单方面释放连接了。
此时 A 正在给 B 发消息
B 正在等待 A 的消息。A突然不发消息了。B也不知道 A 是等会就能发了,还是说就一直不发了。因此 B 就会进行阻塞等待,具体等多久不知道,再次期间 B 会给 A 发送心跳包,以确认 A 是否还能正常工作,如果不能返回 ACK,则说明 A 是挂了。
此处就涉及到"心跳包",B这边虽然是接收方,也会周期性的给对方发起一个不携带任何业务数据(载荷) tcp数据报。发起这个包的目的,就是为了触发 ack。就是确认一下 A 是否正常工作/确认网络是否畅通。(心跳包和咱们前面说"流量控制"窗口探测报文,是一个东西)