前言:
前期掌握了网络的基础原理,可以简单编写代码完成网络编程。对于我们程序员来说,网络的相关的协议是掌握的重中之重,但是对于5层协议来说,传输层与网络层的协议更是重点。
接下来开始从传输层相关的UDP和TCP深度剖析:
UDP协议:
对于UDP协议来说,他的特点是:无连接、不可靠传输、面向数据报、全双工。
在代码中有体现出无连接(并没有保存对方信息),面向数据报(DatagramPacket),全双工(发送request,收到response)的特征。
理解UDP的"报文格式"和"数据格式"是关键,上述是UDP的报文格式:
1.16位源端口号(端口号是由2个字节组成,一般1-1024端口号被称为知名端口号)
2.16位目的端口号。
3.16位UDP的长度,从这也可以看出,一个UDP报文最长就是64KB(2^16/1024),此处的报文长度包括数据长度+报头长度。
4.16位UDP检验和,用于检验一个报文发出去后是否完整等。
不可靠传输:
对于UDP的不可靠传输来说,究竟为什么是不可靠传输,在这里不可靠出书指的是:
不可靠: 没有确认机制, 没有重传机制 ; 如果因为⽹络故障该段⽆法发到对⽅, UDP协议层也不会给应⽤层返回任何错误信息;
对于确认机制和重传机制在TCP协议中都有体现 ,后面说到TCP相关协议再说。
校验和:
关于校验和是我们对于UDP的一个比较陌生的一点,为了搞清楚UDP的校验和,首先要搞明白UDP的校验机制,如何判定数据在传输过程中有没有出现错误。
在网络传输过程中由于外界环境干扰可能会出现"比特翻转"的现象,也就是一个信号传过去之前的其中一个比特位可能是0,在传输途中变成成了1。
此时UDP为了确认传输后的数据没有发生错误,引入了CRC(循环冗余)校验。
将UDP数据报中的数据进行遍历,取出两个字节数据依次累加,此时可能会溢出,但是不影响,最后拿到2个字节的整数。基于这些数据和拿到checksum校验和。
之后数据传过去以后,接收方需要再次遍历拿到的数据,再次进行累加计算出一个新的cheksum校验和,最后拿着新的校验和与发送给过来的校验和进行一个比对。如果不一致就需要告诉发送端重新发送数据。
TCP协议:
特征:有链接、可靠传输、面向字节流、全双工。
对于TCP的报文格式和数据格式就比较复杂了,分析起来是有点难度的,但是作为专业的程序员是不能放弃的,一定要认真分析一遍.
首先大概了解一下这些数据的相关含义:
源/⽬的端⼝号: 表⽰数据是从哪个进程来, 到哪个进程去;
32位序号/32位确认号: 后⾯详细讲;
4位TCP报头⻓度: 表⽰该 TCP头部 有多少个 32位bit(有多少个4字节); 所以TCP头部最⼤⻓度是15 *
4 = 60字节
6位标志位:
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提⽰接收端应⽤程序⽴刻从TCP缓冲区把数据读⾛
RST: 对⽅要求重新建⽴连接; 我们把携带RST标识的称为复位报⽂段
SYN: 请求建⽴连接; 我们把携带SYN标识的称为同步报⽂段
FIN: 通知对⽅, 本端要关闭了, 我们称携带FIN标识的为结束报⽂段
**6位保留位:**该报六位也是为了以备不时之需,当TCP需要新增新的属性,或者某个属性的空间不够用了,可以随时扩容。( 可扩展性)
16位窗⼝⼤⼩: 后⾯再说
16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含
TCP⾸部, 也包含TCP数据部分.
16位紧急指针: 标识哪部分数据是紧急数据;
40字节头部选项: 暂时忽略;
可靠传输:
在TCP协议中,是如何体现出可靠传输的,与UDP中的不可靠传输形成对比。
主要是通过"确认应答"和"超时重传"两个机制保证。
确认应答:
当主机A给主机B法一条请求后,主机B收到应答后,需要告诉A我收到了,所以此时就有了应答机制(acknowledge)。
当然如果一次传输的请求比较多的话,有可能会造成应答顺序错乱的现象。为了结局上述的问题,TCP将每个数据报进行了编号,可以区分数据的先后顺序。
每⼀个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下⼀次你从哪⾥开始发
其中
**32位序号:**表示TCP数据报中数据载荷的第一个字节的编号。
**32位确认序号:**表示在应答报文中针对上一次收到的数据报载荷最后一个字节序号再+1的数字。
注:
序号只是对数据报中的载荷按字节进行排序!
此时又会出现一个新的疑问?这个序号只有32位,那么也就是利用TCP传输数据的时,每个数据报只能携带4GB大小的内存,也就是对端最多只能收到4GB大小的数据?
答案肯定不是的,首先我们要清楚TCP传输数据是按字节流为单位传输数据,当然每次传输时是连着在数据报一起传过去的,但是TCP的数据报携带的信息是天然可拼接的,也就是对端接受得到的信息只是字节流数据而已。
如果序列号的大小最后超过4GB,那么此时就再从0开始编号!
后发先至问题:
那么此时有了序号先帮我们确认数据传到了,但是如果存在传多组数据,第一组的序号时1-1000,第二组是1001-2000,第三组是2001-3000,这些数据传过去,但是读数据的时候,顺序就有可能不一样了:
此时如果想要read发过来的数据,顺序有可能就会乱序。
此时是这样的,操作系统内核会提前准备一段接收缓冲区,收到的数据就会先在接收缓冲区进行排序,紧接着只有等开头的数据到了才开始读。
超时重传:
此时解决了数据先发后至的问题以后,当然还有一个严重的问题,也就是如果再网络传输过程中,如果发生丢包的情况该如何处理,首先丢包这个情况是必然存在的,
1.有可能是传输过程中受到环境干扰"比特翻转"。
2.或者是某个路由器单位时间只能传N个包,但是此时是网络高峰期,传输不过来了,此时路由器会选择将超出的包丢弃。
当然次时涉及到丢包就有两种情况:
1、主机A发送数据给B之后, 可能因为⽹络拥堵等原因, 数据⽆法到达主机B;
2、如果主机A在⼀个特定时间间隔内没有收到B发来的确认应答, 就会进⾏重发; 但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了;
无论是上面哪种情况,发送方都会视为丢包,此时会有一个设定好的"应答时间",如果时间超过应答时间,此时发送方就需要重传数据。
但是对于第二种情况,对于接收方相当于收到了两份相同的数据。那么读的时候不会出现问题吗?
当然不会,此时数据先到接收缓冲区进行排序和去重操作,此时就避免了上述了第二种情况了。
超时重传的时间设定:
如果真遇到丢包的情景,此时进行超时重传的时间应该怎么设置才合理呢?
首先我们需要清楚,当遇到丢包的情况时,有可能是环境中不可避免的因素造成的,此时重传是有意义的,但是此时如果是网断了,此时重传多少次也会变得无意义。
所以重传的时间设定是这样的:
TCP为了保证⽆论在任何环境下都能⽐较⾼性能的通信, 因此会动态计算这个最⼤超时时间.
也就是每一次重传的时间会增长,但是会存在一个最大重传次数。
因此确认应答机制和超时重传机制共同构建了TCP的可靠传输机制!!
连接管理:
因为TCP的其中一个特点是有连接,那么TCP是如何保证通信双方一开始建立了连接?
建立连接:三次握手
断开连接:四次挥手
三次握手:
在正式通信之前,双方肯定是要先建立连接,那么是如何建立连接的呢?
大致的建立连接的示意图,这里的syn = synchronized,该单词的原意就是同步的意思,在多线程中,我们用synchronized当作锁去使用,但是在这里意思就是统一的意思。
这里的发送信号当然也是发的是数据报,但是该数据报是不带载荷的数据报!达到投石问路的效果。
1.此时也就是相当于发送数据报报头中的syn置为1的数据报。
之后接收端收到后也需要发送一个"应答报文"+"同步报文"。
但是此时这两个报文可以在同一时间同一个数据报中发送的,也就合成了一个数据报。
2.此时也就是相当于发送数据报报头中的syn和ack置为1的数据报。
之后发送方如果收到了接收方的ack+syn报文,此时应该告诉接收方此时已经收到了。
3。此时也就是相当于发送数据报报头中的ack置为1的数据报。
三次握手就是上述的三个过程。注意上述的握手syn是客户端先发起的!!
3次握手的意义:
1.初步验证通信链路是否畅通,这是可靠传输的"前提条件"。
2.确认通信双方的发送能力和接收能力是否正常。
3.让通信双方在正式通信之前,对关键参数进行协商。
那么如果3次握手变成2次握手,或者4次握手可以吗?
2次握手:不可行,无法完成通信双方对自身的发送能力和接受能力的验证。
4次握手:可行,但是太麻烦了,没有必要。
四次挥手:
当连接断开前,需要四次挥手之后才可以真真断开!
此时发送也是一个不带载荷的数据报,而且数据报中的fin(finish)位置为1后发送给对端。
只有双方都确认ack(acknowledege)了,最后连接才可以断开(删除对方的IP和端口)。
对于三次我握手来说,四次挥手可以不可以也合并为3次挥手呢?(也就是将ack+fin合并)
答案是大概率不能,因为对于三次握手来说,中间的两次SYN+ACK都是在系统内核中完成,都是由操作系统内核完成的,也就是当收到SYN之后同时发送SYN和ACK。但是,对于四次挥手来说,ACK是由系统内核完成的,但是FIN是触发,则是通过应用程序,调用close/进程退出,来触发。
TCP状态转换:
再进行3次握手和4次挥手的过程中,涉及到一些状态。
接下来几个重要的状态需要重点关注:
1.LISTEN:
服务器进入状态,服务器把端口绑定好,相当于进入LISTEN状态,此时服务器就进入了初始化状态,随时等待客户端的连接。
2.ESTABLISH:
客户端和服务器都进入状态,TCP连接建立完成(保存了双方的信息),接下里就可以进行业务逻辑的通信了。
3.CLOSE_WAIT:
被动断开的一方会进入这个状态,也就是先收到fin的一方。等待代码执行close方法。
(如果发现服务器这端存在大量CLOSE_WAIT状态的TCP连接,说明代码可能出现bug了,有可能没有写close)
4. TIME_WAIT:
主动断开连接的一方会出现这个状态,此处的TIME_WAIT按时间来等待,等到达时间了也就是释放连接了.
(没有立即释放,是为了防止最后传给服务器的ACK丢包!)。
如果此时B没有立即收到ACK,此时B会再次给A重发FIN,继续等待B的ACK,等待一段时间发现没有收到FIN,说明B已经收到了ACK,此时可以释放了。
为什么TIME_WAIT的时间是2MSL?
MSL是TCP报⽂的最⼤⽣存时间, 因此TIME_WAIT持续存在2MSL的话(1MSL是30s)
就能保证在两个传输⽅向上的尚未被接收或迟到的报⽂段都已经消失(否则服务器⽴刻重启, 可能会
收到来⾃上⼀个进程的迟到的数据, 但是这种数据很可能是错误的);
同时也是在理论上保证最后⼀个报⽂可靠到达(假设最后⼀个ACK丢失, 那么服务器会再重发⼀个
FIN. 这时虽然客⼾端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK);
滑动窗口:
TCP在实现可靠传输的前提下,当然也想要保证效率要高,所以引入了滑动窗口。
(这里TCP即使引入了滑动窗口来提高效率,但是只是一个亡羊补牢的做法,传输的效率当然是不如UDP)。
没有引入滑动窗口传输如下:
引入滑动窗库传输如下:
改进方案就是从"发送一个等大一个"改进为"发送一批等待一批"。
批量发送的数据大小称为"窗口大小",注意这的数据的大小单位是字节,不是条。
此时如果一次性发送窗口大小如上的请求,那么需要考虑以下问题:
当收到第一个ack之后,是继续等待剩下的3个ack,还是继续发送下一条数据?
答案是继续发送下一个数据,也就是如果收到1001-2000的ack之后,紧接着发送5001-6000的请求。此时确保了窗口大小不变,还是4份数据。
丢包问题:
在利用滑动窗口提高传输效率的同时,还有可能出现"丢包"问题。
情况一:
数据报到达,但是ack丢失。
上述ack丢失的情况不需要进行处理,因为只需要确保后续的有ack收到,那么之前的数据报也就可以摸默认收到了。
例如:
此时2001的ack丢失,但是后续收到了3001的ack,那么此时就可以确定1001-2000的数据报和2001-3000的数据报都收到了。
情况2:
数据报直接丢失:
如上图此时如果1001-2000的数据报丢失,此时接收方收不到数据了(序号是连续的),只能一直向A发送方索要序号为1001数据,此时一直重发,直到收到了序号1001的数据报。
但是在重发的时候,由于是滑动窗口,所以此时起的数据还在继续发送。
上述的过程被称为"快速重传"。
流量控制:
刚刚提到滑动窗口,可以提高传输效率,但是这个窗口的大小是越大越好吗?
首先,我们要明确,通信是双方的事情,传输方发送的数据,必须保证在接收能够处理的过来的前提下传输。
当数据从发送方发送过去时,数据报首先是会进入"接收缓冲区",类似于一个阻塞队列,该队列类似于一个"生产者消费者模型",如果发送方发送的数据特别快,消费的特别慢,此时会使则色队列装满,超出的数据会直接"丢弃",会造成丢包的现象。
此时就需要接收方能够反向控制流量的大小(窗口大小),制约发送速度。
接收方就会在发送ACK报文的同时,告诉发送方"接收缓冲区"剩余空间的大小。之后下一次发送的窗口大小就会通过TCP协议中的16位窗口大小进行设置。
在TCP协议中,有一项是"窗口大小",这里指的就是发送方窗口大小的设置:
这个窗口大小只有当ack == 1时才有效。也就是是接收方收到数据之后给发送方的一次反馈。
此时窗口大小是16位,难道窗口最大就是2^16/1024 = 64KB吗?
当然不是,此时在选项中,可以设置一个特殊的选项"窗口扩展因子"。
发送方的窗口大小 = 窗口大小<<窗口扩展因子;
1.在进行滑动窗口之前,发送方会先发来一条请求,此时接收方发送ask报文,其中就需要告诉发送方接收缓冲区的剩余量,此时发送方就开始了滑动窗口。
2.当接收方这边的接收缓冲区剩余量为0时,此时发送方就不能再进行发送数据,但是发送方不知道要等多久,所以此时发送方就会主动发一条窗口探测包来探测一下接收缓冲区的剩余空间,这个包是不携带载荷的。
之后会收到接收方的答复。
3.当然接收方这边如果接收缓冲区有剩余位置了,也会主动向发送方发起窗口更新通知。
拥塞控制:
流量控制是站在接收方的视角来限制发送方的速度。
拥塞控制是站在传输链路的视角来限制发送方的速度。
此时假设接收方和发送方都非常给力,但是中间传输过程是要经过不同的链路。
1.中间节点非常多。
2.每次传输走的线路都不一样。
3.中间哪个节点遇到瓶颈都不好说。
4.中间节点传输数据不止有A的数据。
针对中间传输链路效率的问题,此时可以通过"实验"的方式找到最适合的传输速度。
TCP引入"慢启动"机制,也就是宪法少量的数据探探路,摸清当前的网络拥堵状态,再按照多大的速度传输数据。
过程:
1.刚开始传输数据,拥塞窗口会非常小,用一个很小的速度发送数据。
2.不丢包,增大窗口,指数增长。
3.增长到一定的阈值,即使没有丢包,也会停止指数增长,变成线性增长。
4.线性增长,会使传输速度越来越快,会出现丢包现象。
上述过程如下图所示:
延时应答:
再流量控制时,是使用接收方的缓冲区剩余空间进行控制窗口大小。
此时就存在这样一个问题,如果让接收方立即返回ack应答,此时窗口大小可能会有点小,因为此时可能接收方(服务器)都没有处理多少请求。
但是此时如果延迟100ms,可能服务器就有可以多处理两个任务,此时可能又会多出来2KB的空间,此时再返回ack数据报,此时窗口就可以大一点了。
在这里需要强调一下来连接管理中四次挥手时,接收方返回fin和ack是否能合并的问题。
当有延时应答的机制以后,ack和fin是有可能同时发送的。ack返回的时间可能更晚!
捎带应答:
再返回业务数据的时候,将ack携带一起发过去。
捎带应答可以说是在延时应答的基础上做到的。
面向字节流:
创建⼀个TCP的socket, 同时在内核中创建⼀个 发送缓冲区 和⼀个接收缓冲区;
调⽤write时, 数据会先写⼊发送缓冲区中;
如果发送的字节数太⻓, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区⾥等待, 等到缓冲区⻓度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从⽹卡驱动程序到达内核的接收缓冲区;
然后应⽤程序可以调⽤read从接收缓冲区拿数据;
另⼀⽅⾯, TCP的⼀个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这⼀个连接, 既可以读数据,也可以写数据. 这个概念叫做 全双⼯
由于缓冲区的存在, TCP程序的读和写不需要⼀⼀匹配, 例如:
写100个字节数据时, 可以调⽤⼀次write写100个字节, 也可以调⽤100次write, 每次写⼀个字节;
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以⼀次read 100个字节, 也可以⼀次read⼀个字节, 重复100次;
粘包问题:
⾸先要明确, 粘包问题中的 "包" , 是指的应⽤层的数据包.
在TCP的协议头中, 没有如同UDP⼀样的 "报⽂⻓度" 这样的字段, 但是有⼀个序号这样的字段.
站在 传输层的⻆度 , TCP是⼀个⼀个报⽂过来的. 按照序号排好序放在缓冲区中.
站在 应⽤层的⻆度 , 看到的只是⼀串连续的字节数据.
那么应⽤程序看到了这么⼀连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是⼀个完整的应
⽤层数据包.
那么如何避免粘包问题呢? 归根结底就是⼀句话, 明确两个包之间的边界.
解决方案:
对于定⻓的包, 保证每次都按固定⼤⼩读取即可; 例如上⾯的Request结构, 是固定⼤⼩的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
对于变⻓的包, 可以在包头的位置, 约定⼀个包总⻓度的字段, 从⽽就知道了包的结束位置;
对于变⻓的包, 还可以在包和包之间使⽤明确的分隔符(应⽤层协议, 是程序猿⾃⼰来定的, 只要保证分隔符不和正⽂冲突即可);