之前,我们介绍了基本的网络原理和网络编程的相关知识,接下来会根据TCP/IP协议栈,具体的介绍这里的关键协议和知识。这部分知识虽然是理论为主,但是这是我们作为程序员的基本内功,非常重要。
本文我们重点介绍TCP和UDP协议。
目录
UDP协议
UDP协议
无连接、不可靠传输、面向数据报、全双工。 这是UDP协议的特点,十分重要。
这个就是UDP协议的报文结构了。UDP会把通过socket send方法拿来的数据在前面拼装上几个字节的报头(相当于字符串拼接(二进制)),组成传输层的UDP数据报。
UDP报头就包含了一些特定的属性,携带了一些重要的信息。不同的协议,功能不同,报头中带有的属性也不同。对于UDP报头来说,一共是8个字节,分成四个部分,每个部分2个字节。
|-------------------------------------|
| 源端口号/目的端口号:表示数据从哪个进程来,要到那个进程去 |
| UDP长度:表示整个数据报(UDP首部+UDP数据)的最大长度 |
| 校验和:验证传输的数据是否正确 |
也就是说,源端口号、目的端口号、UDP长度、UDP校验和都分别只会占两个字节的长度。
但是2个字节表示的范围是0到65535,换算成单位就是64KB,所以一个UDP数据报最多只能传输64KB的数据。
这个数据对于今天的网络环境来说,是非常非常小的~手机上随便拍个照片都有几个MB。
那如果应用层数据报超过了64KB,怎么处理呢?
1.需要在应用层,通过代码的方式针对应用层数据报进行手动的分包,拆成多个包通过多个UDP数据报进行传输。(本来send一次,现在send多次了)
但是这个方法就得写很多很多的代码,还得进行很多很多的测试,就要处理很多很多的bug,对于程序员来说并不友好。
2.不用UDP,用TCP,因为TCP没有这样的限制。
校验和:验证传输的数据是否正确
网络传输的过程中,可能会受到一些干扰,比如电场、磁场、高能射线。在这些干扰下就可能出现"比特翻转"的情况。
比如有一些1变成0,有一些0变成1。11110000 -->>11110001,如果网络数据报本来是0开启,但是最后一位变成1了,就会识别成关闭了~这一些的现象是客观存在的,不可避免。我们只能做到及时识别当前的数据是否出现问题。
因此就引入了校验和来进行辨别~
针对数据内容进行一系列的数学运算,得到一个比较简短的结果(比如说2字节),如果数据的内容是一定的,那么校验和也就是一定的。但是如果数据变了,得到的校验和也会变。
针对网络传输的数据来说,有几个知名的生成校验和的算法:
1.CRC 循环冗余校验
2.MD5 定长、冲突概率小、不可逆
3.SHA1
TCP协议
TCP是更重要的协议,并且相对于UDP来说复杂很多很多很多。
**有连接、可靠传输、面向字节流、全双工。**这是TCP协议的特点。
这其中有一些是和UDP是类似的,比如源端口、目的端口,检验和~
但是还有很多复杂的东西,这需要我们进一步了解TCP的后续工作机制后才能理解。
首部长度
一个TCP报头,长度是可变的,不是像UDP一样固定8个字节。因此,首部长度就描述了TCP报头有多长。
另外,选项之前的长度一共是20字节,首部长度减去20就是选项这部分的长度。
此处的首部长度的单位不是1字节,而是4字节。也就是说,如果首部长度值是5,那么表示整个TCP报头是20字节,选项部分为0,也就是没有选项。
如果首部长度值是15,表示整个TCP报头是60字节,选项相当于是40字节。
保留(6位)
C语言中,有一类单词叫做关键字,还有一类单词叫做保留字。保留字意思就是现在还没用,但是说不定以后要用,所以在这里先占一个位置。此处TCP的保留6位,也是为了以后的拓展来考虑的。
为什么会有保留位呢?
对于网络协议来说扩展升级是一件成本极高的事情。比如UDP协议,报文长度是2,一个包最大64KB,实在是太小了。
为什么不能把UDP协议升级一下,让它支持更大的长度呢?比如把报头长度用4个字节表示?
理论是可行的,但是实际操作起来困难重重。全球上百亿能上网的计算机/路由器/手机,这一些设备的操作系统中,就是支持2字节长度的UDP的,要想进行升级,就得让这些设备的操作系统都能够同步升级成支持4个字节的UDP。
但是如果引入了保留位,此时升级操作系统的成本就会低不少。如果后续TCP引入了一些新的功能,就可以使用这些保留位字段。
确认应答
TCP的特点之一是可靠传输,那可靠传输是怎么做到可靠的?
可靠传输意味着发送方把消息发送出去了,接收方能够知道收没收到消息。比如丢没丢包之类的。
TCP进行可靠传输,最主要的就是靠这个确认应答机制:
A给B发送了一个消息,B收到之后就会返回一个应答报文(ACK),此时A收到应到报文之后,就知道刚才的数据已经顺利到达B了。
考虑更复杂一点的情况:
A发第二条消息,不需要等待B的回应 ,而B收到消息会立刻回应。但是网络环境多变,可能存在后发先至的情况。这个情况,收到消息的顺序可能是存在变数的。
就会出现这种情况,明明想要表达的是吃麻辣烫,不吃饺子,但是在A眼中却是吃饺子不吃麻辣烫,就造成了错误。
正常顺序:
错误顺序:
如何解决上述问题呢?办法很简单,只要给传输的数据和报文都进行编号就行了。当我们引入序号之后,即使顺序上乱了,也可以通过序号来区分当前应答报文是针对哪个数据进行的了。
任何一条数据都是有序号的,确认序号只有应答报文有。普通报文中的确认序号没有意义。
具体编号方式:
TCP是面向字节流的,TCP的序号也是按照字节来进行编号的。
假设现在有一条1000字节的数据要发送,从1开始编号,第一个字节序号就是1,第二个字节序号就是2.但是由于这1000个字节都属于同一个TCP报文,TCP报头里就只记录当前第一个字节的序号。此处的序号是1。
此时要发送第二条数据,第二个TCP数据报的字节序号就相当于是1001。如果长度是1000,此时最后一个字节序号就是2000。由于1001-2000都是属于一个TCP数据报,报头里只需要填写1001就行了。
每个TCP数据报报头填写的序号只需要写TCP数据的头一个字节的序号即可~当本次连接断开后,序号才会重置。
确认序号的取值,是收到数据的最后一个字节的序号+1。并且确认序号还能表示<1001的数据都确认收到了,A接下来应该从1001这个序号开始继续发送。
TCP的可靠传输能力,最主要就是通过确认应答机制来保证的。通过应答报文,就可以清楚的让发送方知道传输是否成功,进一步的引入了序号和确认序号,针对多组数据进行了详细的区分。
超时重传
网络环境是多变的,并且受多种因素的影响。传输的过程中,如果丢包了该如何应对?
对于TCP来说,丢包有两种情况:
1.发送的数据丢了
2.返回的应答报文ack丢了
但是发送方看到的结果,就是没有收到ack,区分不了是哪一些情况。这两种情况都认为是已经丢包了。丢包是一个概率性事件,通常情况下,丢包的概率是比较小的,因此重新发送一下这个数据报还是有很大可能传输成功的。
因此TCP就引入了重传机制。在丢包的时候,就需要重新发送一次相同的数据。
在这有一个时间阈值,发送方发送了一个数据之后就会等待ACK,此时开始计时。如果在时间阈值之内也没有收到ACK,不管ACK是不是正在路上,还是已经丢了,都认为是丢包了。
超时重传:超过一定时间还没响应就重新传输
那么,如果超时的时间如何确定?
最理想的情况下,找到一个最小的时间,保证 "确认应答一定能在这个时间内返回"。
但是这个时间的长短,随着网络环境的不同,是有差异的。
如果超时时间设的太长,会影响整体的重传效率;
如果超时时间设的太短,有可能会频繁发送重复的包;
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
但如果是一些特殊场景,比如支付信息丢包了要重传,要是没处理好岂不是会扣两次钱?
TCP对于这种重复传输的数据,有特殊处理去重的。
TCP存在一个接收缓冲区,存在于接收方操作系统内核中的内存中,TCP使用这个接收缓冲区,对收到的数据进行重新排列,使应用程序读到的数据保证正确和有序。
由于去重和重新排序机制的存在(去重和排序都依赖TCP报头的序号),发送方只要发现ACK没有即使达到,就会重传数据。即使重复了,顺序乱了,接收方都能够处理好。
小结:小结:可靠传输是TCP最核心的部分。TCP的可靠传输就是通过确认应答+超时重传来进行体现的。其中确认应答描述的是传输顺利的情况,超时重传描述的是传输出现问题的情况。这两者相互配合,共同支撑整体的TCP可靠性。
连接管理
TCP是有连接的协议,A这里需要有一个空间储存B的ip和端口,B这里需要有一个空间储存A的ip和端口,当这两部分信息都被维护好饿了之后,此时连接也就有了。此时也把保存这部分信息的这个空间(数据结构)成为连接。
接下来我们要介绍的是整个网络原理中最高频的面试题:TCP的建立连接过程**(三次握手)** 和断开连接过程**(四次挥手)**。
建立连接:三次握手
首先A问B去不去吃麻辣烫,B回答好啊好啊。此时双方就有了一个认同:B今天去吃麻辣烫。
之后B问A去不去吃麻辣烫,A回答好啊好啊。此时双方也都知道了:A今天去吃麻辣烫。
此时才是真正的沟通好了,相当于连接建立完毕。此处就把这个过程中的每次通信,都称作是一次"握手"。
但是不是进行了四次"握手"吗?为什么协议中是三次握手呢?因为B的两次交互,是可以合并成一次交互的。因此就构成了三次握手。
为什么要合并?
必须要合并。对于B来说,发两次消息和发一次消息,对于某些成本来说,是可以节省的。资源消耗也更少。
如果是两次握手行不行?
不行。如果少了最后一次握手,B就不知道A的情况,这就要谈到三次握手的另一个作用:
验证双方各自的发送能力和接受能力是否正常。之后三次握手后才能验证双方的发送能力和接受能力都是没有问题的。
所以三次握手的意义就随之而出:
1.让通信双方各自建立对对方的"认同"。
2.验证通信双方各自的发送能力和接受能力是否OK。
3.在握手的过程中,双方来协商一些重要的参数。(TCP通信的过程中,有些数据通信通信双方要想互同步,此时就需要有这样的交互过程,正好可以利用三次握手来完成数据的同步。)
在TCP中,有客户端和服务器端一说。客户端是主动的一方,服务器是被动地一方。一定是客户端先向服务器发起"建立连接请求"。同一个程序,在不同的场景下可能是作为服务器也可能是作为客户端。
客户端主动给服务器发起的建立连接请求,称为"SYN",同步报文段。服务器端的回复称为"ACK"。
除此之外,TCP还有很多很多的状态,这个状态比之前的线程状态复杂很多很多。但是我们需要记住几个常见的状态:
断开连接:四次挥手
四次挥手和三次握手非常类似,都是通信各自双方向对方发起一个断开连接的请求,再各自给对方一个回应。
通过四次的通信交互,这样连接就彻底断开了。并且把断开连接的请求称作"FIN",对方的回应称作"ACK"。
那为什么建立连接只要三次,断开要四次呢?能不能把断开连接的中间两次B的通信交互合并为一次呢?
在特殊情况下可以,如果两个数据发送的时机相同才能合并,如果是不同的时机,就合并不了。
三次握手的中间两次能够合并,是因为这是同一时机发送的,因为三次握手的这三次交互过程是纯内核中完成的,应用程序感知不到也干预不了。服务器的系统内核收到SYN之后,会立即发送ACK也会立即发送SYN,这样就能合并成一次通信。
但是FIN的发起并不是由内核控制的,而是由应用程序调用socket的close方法或者锦城推出才会触发FIN,但是ACK是由内核控制的,收到FIN之后立刻返回ACK。这样两者之间就会隔了一个时间差。这个时间差就意味着这两次通信不能合并。
四次挥手中涉及到两个重要的TCP状态:
1.CLOSE_WAIT
这个状态出现在被动发起断开连接的一方,等待关闭的状态。也就是收到主动断开连接的一方的请求后,等待close方法关闭socket的那段时间。
2.TIME_WAIT
出现在主动发起断开连接的一方。四次挥手中,被动断开连接的一方发送FIN给主动断开连接的一方后,主动方发出去ACK的瞬间就开始了这个状态。这个状态是为了保持当前的TCP连接状态不要立即释放。
这个状态的存在是为了应对最后一条ACK丢包的情况。如果最后一个ACK丢包了,站在被动方来说,不知道是因为ACK丢了还是因为FIN丢了,所以统一视为FIN丢了,统一进行重传操作。
既然被动方可能要重传FIN,主动方就需要针对这个重传的FIN进行ACK响应,所以使用TIME_WAIT状态保留一定的时间,就是为了能够处理最后一个ACK丢包的情况,能够在收到重传的FIN之后进行ACK响应。
正常情况下TIME_WAIT会等待一段时间,如果没有收到重传的FIN,此时就会认为通信没有出现丢包,于是就彻底的释放连接。
但是如果最后一个ACK和被动方重传的FIN都丢了,就会认为没收到重传的FIN,歪打正着也彻底释放连接了。
TIME_WAIT具体要保持多长时间呢?
一般来说叫做2MSL。如果A经历了2MSL还没有收到重传的FIN,就会认为上个ACK正常到达了。
MSL是TCP报文的最大生存时间,因此TIME_WAIT持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发 LAST_ACK)。
滑动窗口
确认应答,超时重传,连接管理,都是给TCP可靠性提供支持。引入了可靠性,其实已经付出了代价:传输效率。UDP虽然完全没有可靠性,但是传输效率要比TCP高。TCP也会要尽可能的提高传输效率。但是再怎么提高,也不可能比UDP这种完全不考虑可靠性的效率高。但是至少可以让自己的效率不那么的低下。
滑动窗口本质上就是降低了确认应答中等待ACK消耗的时间。
刚才我们讨论了确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送 下一个数据段。这样做有一个比较大的缺点,就是性能较差。尤其是数据往返的时间较长的时候。
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多 个段的等待时间重叠在一起了)。
滑动窗口的本质是不等待的批量发送一组数据,然后使用一份时间来等待着一组数据的多个ACK。并且把不需要等待就能直接发送最大数据的量,称为"窗口大小"。这个图中窗口大小就是4000。
当批量发送了窗口大小这么多的数据之后,发送方就需要等待ACK了,但是并不是等待所有的ACK到达之后再继续往下发,而是到了一个ACK之后,就继续往下发送一条。
收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据。所以也就称作滑动窗口。
丢包的处理
1.ACK丢了
其实ACK丢了不需要做任何的处理。
在发送方没有丢包的情况下,假设接收方"下一个是1001"这条ACK丢包了,但是发送方接收到了"下一个是2001"这个ACK。这种情况并不会有什么影响,因为只要接收方发出"下一个是2001",就代表着接收方把1-2000全部都接收完了。
也就是说,下一条ACK其实会涵盖上一条ACK的信息。
2.发送方消息丢了
由于1001-2000丢包了,接下来2001-3000到达主机B之后,B给A返回的ACK确认序号仍然是1001。
当某一报文段丢失后,发送端会一直收到序号为1001的确认应答,这个确认应答好像在提醒发送端"我想接收的是从100开始的数据"。
因此,在窗口比较大,又出现报文段丢失的情况下,同一个序号的确认应答将会被重复不断地返回。而发送端主机如果连续3次收到同一个确认应答", 就会将其所对应的数据进行重发。这种机制比之前提到的超时管理更加高效,因此也被称作高速重发控制。
流量控制
这是一种干预发送的窗口大小的机制。
基于上面滑动窗口的知识,窗口越大,传输效率就越高。一段时间等待的ACK就越多。但是窗口也不能无限放大。有这些原因:
1.完全不等ACK的话,可靠性是否有保障?
2.窗口太大会消耗大量的系统资源。
3.发送速度太快,接收方处理不过来。
接收方的处理能力就是一个很重要的约束依据,发送方发的速度不能超过接收方接收的速度。
流量控制需要做的就是这个,根据接收方的处理能力,协调发送方的发送速率。
用一个简单的方法,就是直接看接收方接收缓冲区的剩余大小。具体如何实现呢?
接收端主机(A)向发送端主机(B)通知自己可以接收数据的大小,也就是通过ACK报文把这个值返回给A方,A就根据这个值来决定接下来发送的速率是多少。
TCP首部中,专门的字段来通知窗口大小。接收主机将自己可以接收的缓冲区大小放入这个字段中通知给发送端。这个字段的值越大,说明网络的吞吐量越高。
但是是不是意味着窗口大小最大的就是64KB呢?
TCP为了让窗口更大,在选项部分引入了窗口拓展因子,比如窗口大小已经是64KB,拓展因子里写了一个2,意思就是左移两位变成了256KB。
所以接收方缓冲区剩余空间是一直在动态变化的,每次返回ack的窗口大小都在变化,发送方也是在动态调整。
窗口大小为0后,发送方就会暂停发送,此时B会定时发送窗口探测报文,这个报文不携带具体的业务数据,只是为了触发ACK查询窗口大小。
拥塞控制
光靠上面的流量控制是不够的,拥塞控制也需要考虑进来。
流量控制描述的是接收方的处理能力,拥塞控制描述的是传输过程中中间节点的处理能力。
之前考虑的A的发送速率是根据B的处理能力来决定的,但是没考虑导致中间结点,网络传输遵循木桶效应,需要考虑到最慢的那个地方。
拥塞控制,本质上是通过实验的方式,来找到一个合适的窗口大小(合适的发送速率)。
第0轮,窗口大小是1,以非常慢的速度发送(此处1不是1字节,而是1个单位),此时发现传输顺利没有丢包就扩大窗口。
第一轮窗口大小为2,扩大了一倍。初始阶段,由于窗口大小比较小,每一轮不丢包都会使窗口大小扩大一倍,并且是以指数增长的形式扩大的。
当增长速率达到阈值之后,此时指数增长就成为了线性增长。增长的前提是不丢包,一旦发现传输的过程中丢包了,说明此时的发送速率已经很接近网络的极限了,一次性把窗口大小缩小成很小的值,并且重复刚才指数增长和线性增长的过程。
拥塞窗口不是一个固定的值,而是一直动态变化的,最终拥塞窗口和流量控制的较小值,就是发送方实际的发送窗口。
延迟应答
延迟应答是提升效率的机制,在滑动窗口的基础上进行的。
延迟应答采取的方式就是在滑动窗口下ACK不再每一条数据都返回了,比如隔一条返回一个ACK。
这样等待的时间里,接收方的应用程序就可以把缓冲区中的数据拿出去,剩余的空间就更大了。
捎带应答
捎带应答也是提高效率的一种方式,服务器客户端程序,最典型的模型就是"一问一答",因为ACK是内核立刻返回的,而信息的发送是业务上的响应,是应用程序发送的,所以一般这不是在同一个时机,但是TCP存在延迟应答,可能会让业务数据把这个ACK一起发送过去。这样就能提高效率。
面向字节流
TCP是面向字节流的,并且存在接收缓冲区。接收缓冲区把接收到的多个数据都放在这里,应用程序read的时候,读到哪里才算是一个完整的呢?而不是这条信息读了一半另一条信息读了一半。
解决方案:
1.约定好分隔符
2.约定每个包之间的长度
异常情况
在传输的过程中出现了不可抗力,该要怎么处理呢?
具体有四个情况:
1.进程崩溃
进程崩溃了,对应的PCB就没有了,所以对应的文件描述符表也就相当于释放了,类似于socket.close()。此时内核会完成四次挥手,仍然是一个正常断开的流程。
2.主机关机
主机关机先要杀死进程,然后才正常关机。也会和上面一样触发四次挥手。
3.主机掉电
这种情况是来不及四次挥手的。
假如是接收方掉电了,发送方仍然在继续发送数据,发送完数据需要等待ACK,但是等待不到,进入超时重传,重复后尝试重置TCP连接,最后放弃连接。
假如是发送方掉电了,接收方发现没消息了,但还不知道是发送方挂了还是发送方还没发,接收方需要周期性的给发送方发送一个消息,确认对方是否还正常。这个消息也被称作心跳包。
4.网线断开
跟上述情况一样。
至此,UDP和TCP的介绍告一段落~TCP是一个非常复杂的协议,不仅仅只有这是个特性,这些是TCP很核心的特性。要想更深度的了解TCP,可以去翻阅RFC标准文档,翻阅操作系统内核源码。这才是了解TCP的最优方式。