hi,大家好,我是康师傅,今天和大家聊聊网络协议那些常见的知识点,为了要聊这个知识呢?主要是因为自己快忘完了,同时这不今年快要结束了,可能很多同学都在开始准备明年的面试了,那么我想不管你是前端、后端还是客户端,网络协议这块的复习应该是少不了的。
网络协议离不开我们常说的http、tcp这些,在网络分层模型中 http 属于应用层协议,tcp属于传输层协议,其实应用层协议还有像smtp、ftp等协议,传输层还有udp协议,当然我们今天重点说下http和tcp相关的知识,http离不开tcp,因此我们先说说tcp、ip相关的知识。
TCP、IP
某一天,你和你的同事正在用微信聊天,不知道你是否思考过,你们的电脑上装了很多软件,比如有网易云、QQ...等等,为什么你通过微信发的消息会正确的发送到对方的微信上,而不是发送到其他应用软件上?同时再说夸大一点,为什么你发送的消息会发送你同事的电脑上,而不是隔壁老王的电脑上,这么问题看起来有点傻,其实这些离不开我们今天要说的TCP、IP协议。首先IP大家肯定都能明白,每个电脑都有一个IP,这也是为什么我们的信息可以精准的发给我们的同事而不是隔壁老王,因为我们知道同事电脑的IP,这就是IP层干的的事。
当通过IP找到了你同事的电脑后,还要找到你同事正在运行的微信软件,电脑上软件这么多,而且大家的IP都是一样的,这可怎么办?答案是端口,这也就是TCP层干的事,在数据经过TCP层的时候,会加上目标端口也就是我们微信进程占用的端口,然后数据包到达你同事的电脑上时,在TCP层会拆包,拆包后会发现目标端口号,然后把数据丢给我们的微信进程(电脑视角:端口号是10086,哦,这个数据丢给微信进程处理吧)。
其实TCP层不仅仅会加上目标的端口号,还会加上发送者的端口号,IP层不仅仅会加上目标IP,还会加上发送者的IP,发现没,这就是我们常说的socket四元组:发送端IP+发送端端口+接收端IP+接收端端口。一个socket四元组就可以确定一个连接。
我们常说TCP协议是一种基于字节流、面向连接的、可靠的传输层通信协议,这里我们需要思考定义中的三个抽象描述:
- 基于字节流是什么意思?
- 什么叫做面向连接的?
- 如何算可靠?
基于字节流的
我们先说第一个问题,TCP 协议是基于字节流传输的,这是什么意思呢?举个例子,其实当我们往 socket 中写入1000个字节的时候,会分很多情况的,这时1000个字节会被 copy 到内核缓冲区的,但是1000个字节具体是怎么通过网卡发出去是不确定的,有可能一次性发出去,也可能分成2次,分别是300、700,也有可能是500、500,但是不管怎么分,每个字节都有自己的序号。
造成这么多情况的原因是因为受到路径最大传输单元 MTU、发送窗口大小、拥塞窗口大小等因素的影响(这些概念后面会讲,just follow me),在这里我们也说下可靠性,因为数据包已经在 TCP 层分段了,等于一块数据被打散了,这些打散的数据包被接收的顺序可能不一样,但是内核在收到乱序的数据包后,并不会直接丢给上层应用(http等),需要按照数据包的顺序组装好,这个组装依赖的就是序列号,那基于字节流方式传输的数据包,如何确定这个数据包的序列号呢?其实这个序列号就是这个包的第一个字节的序号。
![](/qrcode.jpg)
三次握手
再说第二个问题:面向连接。对没错,我们还是要说说老掉牙的问题:三次握手、四次挥手。三次握手、四次挥手其实也是一种可靠性的表现。因为需要可靠,所以在建立连接的时候需要先确认双方是否都ok,也就是三次握手。我们先看看三次握手干了什么?同时我们看看为什么三次就行了,两次或者十次行不行?
看到上图中的一堆玩意比如syn、seq、ack、isn等等,先不要害怕,我们一一解释下,然后你就会明白了,上面我们也说到了,因为需要可靠,不能一上来就直接发送数据,万一对方不在线,那数据岂不是丢失了,因此握手的目的就是先确认两边的状态都ok,那如何区分这次通信是握手而不是正常的发送数据呢?这就是SYN包的作用,SYN相当于一个双方通信中附带的一个标志,当数据包中有它的时候,说明这次通信的目的是握手。
三次握手发SYN包之后,还有一个重要的事情:交换彼此的初始序列号seq,这是因为基于字节流的TCP其实每个字节的数据都有序号,在握手确认彼此的初始序列号之后,接下来所有的字节数据都是基于初始序列号向后累加的,初始序列号的生成方法就是ISN函数,它大概会随机生成一个数字,需要注意的是它的值并不是从0开始的。当一端发送了自己的初始序列号之后,并且收到了对端的ack就说明此次交互通畅,其中ack的值就是自己发过去的序列号加1。
ok,搞懂了几个名词的概念和意义之后我们再来看看三次握手的过程。
- 首先发送端c和接收端s一开始谁也没联系谁,大家没啥交集,不存在交易,大门紧闭,也就是假想的close状态。
- 第一次:发送端c发送SYN包过去,同时带上初始序列号ISN(c),然后发送端c处于SYN-SENT状态,这时说明发送端c具有发包的能力。
- 第二次:接收端s收到发送端c发过来的SYN包之后,就知道了此次要进行的是握手认证,于是它也发送一个SYN包并且带上自己的初始序列号ISN(s),同时回复发送端c一个ack,ack的值就是ISN(c)+1,其实这个ISN(c)+1的意思就是说“发送端老弟,你的初始序号我收到了,下次通信的话,数据包直接从ISN(c)+1开始”,此时接收端处于「SYN-RCVD」状态,并且第二次握手也说明了接收端具有收包和发包的能力。
- 第三次:发送端c收到ack之后,得回一下接收端s,这样接收端s才知道发送端c也具有收包的能力,这时发送端会回一个ack=ISN(s)+1,这个的意思和上面的差不多:“接收端老哥,你的初始序列号我也收到了,下次通信的话,你的数据包序号就从ISN(s)+1开始吧”,至此三次握手完毕,双方的状态都是ESTABLISHED。
我们总结下,由于TCP是可靠的传输层通信协议,握手的目的主要是确认双方都有收发包的能力,从上文的描述来看三次刚刚好,如果少了,首先某一端的收发包能力就无法得到确认,比如最后一次如果发动端不发送最后的ack,那么接收端就不知道它是不是收到了数据包。当然超过3次肯定也是没问题的,但是没必要,因为3次已经可以知道双方的状况了。
不知道你发现没有,建立连接的过程双方都消耗了一个序列号,这里可不可以不消耗一个序列号呢?答案不可以,必须要消耗一个,关于这一点你先记住:不占用序列号的段是不需要确认的,比如ack,凡是消耗序列号的 TCP 报文段,一定需要对端确认。如果这个段没有收到确认,会一直重传直到达到指定的次数为止,像SYN 包就是需要确认的报文段。
四次挥手
看完了三次握手,我们再来看看四次挥手的过程,四次挥手的过程双方会处于某种状态,这是需要注意的,这也是面试考察点。依然一样我们来看看为什么需要四次挥手,以及每次挥手的过程干了什么?
为了方便描述,这里定义下主动断开方叫「A」,被动断开方叫做「B」。
- 第一次:A突然想要关闭连接,不想玩了,于是它会发送一个FIN包,这个FIN包和上面的SYN包是对立的,说明此刻A想要断开连接,同时FIN包也是需要对端确认的,所以FIN包是需要消耗一个序列号的,发送完FIN包后,A处于FIN_WAIT1状态。
- 第二次:B收到对端的FIN包之后,心想:“这小子是不想玩耍了呀,不想玩就算了”,于是B会对FIN包做出个回应也就是ack,意思是:“对面的,我知道了”,同时自己处于CLOSE_WAIT状态,A收到对端的ack之后同时自己处于FIN_WAIT2状态。
- 第三次:B在发送ack之后,如果还有未处理完的数据,需要接着把未发送完的数据发给对端,当数据发完之后,其实也就是和A没啥关系了,于是B也会发个FIN包,意思是“对面的,数据都发完了,你就断开吧”,此时B会处于LAST_ACK状态。
- 第四次:还是一样的,FIN包需要确认,因此A再收到FIN包后,会立马回复一个ACK,那此时对端就会断开连接处于CLOSED状态,同时A处于TIME_WAIT状态,也就是2MSL之后自动断开。
能不能三次挥手?
看流程四次挥手绝对没问题,那问题来了,三次行不行?其实某些情况下三次也是可以的,比如被动断开方没有要处理的数据也就不存在DATA那一部分,那其实ACK和FIN一起发过去问题也是没问题的,如果存在DATA,非要把ACK+DATA+FIN合并在一起发过去会发生什么呢?首先处理DATA需要时间,那么为了等DATA处理完再发ACK,可能会导致主动断开方因为迟迟没收到ack,而重发FIN包。
为啥最后一步主动断开方需要处于TIME_WAIT状态,这个状态代表什么?
TIME_WAIT是主动关闭方最后进入的一种状态,TIME_WAIT是2MSL的,MSL是报文最大的生命周期,正常来说一个数据包如果在网络中超过MSL之后还没被对端收到就会被丢弃,那为什么主动断开方需要2MSL呢?
- 一个MSL主要是保证最后一次 ACK 能到达对端,如果 1MSL 后,ACK 还没到达对端会怎么办?
- 如果主动断开方的 ACK 没到达对端,这时候会触发被断开方重传 FIN,那么另一个 MSL 就是保证重传的 FIN 包也能到达对端。
因此2MSL = 去向 ACK 消息最大存活时间(MSL) + 来向 FIN 消息的最大存活时间(MSL) 。
为什么FIN包也需要消耗一个序列号?
上图并没有说到序列号的事,其实 FIN 包和 SYN 包一样的,也是需要消耗序列号的,如果要问为什么?只是回答“因为 FIN 包需要对端确认,而需要确认的报文段都是消耗序列号的”难免有些牵强,我们来看个图你就知道了。
- 假设现在发送端的序列号是100,而且发送了100字节的数据,按道理接下来的ack应该是200(199+1)。
- 此时突然要发送 FIN 包,因为之前已经发送了100个字节,因此这时的 FIN 包对应的 seq 应该是 200
- 如果 FIN 包不消耗一个序列号,那么对应的 ACK 应该也是200,而不是201,额,这就尴尬了,最后这个 ACK 到底是对 FIN 包的 ACK 还是对 100 字节数据的 ACK?这就傻傻分不清楚了。
因此无论是 SYN 包还是 FIN 包,为了和正常的数据区分,都需要消耗一个序列号。
可靠
我是大哥我来分段-MTU和MSS
通过上文我们知道在 TCP 中数据的传输是基于字节流的,数据块会被拆分成一个个报文段然后发出去,决定报文段大小的因素很多,比如路径MTU、发送窗口大小、接收窗口大小等因素的影响,这里我们一起来看看这些因素是什么? 首先我们来看看这个路径MTU,在网络分层中,我们知道最终数据是要通过链路层发出去的,这个链路层的通道其实是有限制的,这个限制我们就叫做MTU,那这个MTU是多少呢?一般是1500,你可以通过netstat -i
查看你本机网卡的MTU。
netstat -i
en0 1500 <Link#6> 08:f8:bc:6f:6a:03 34427890 0 37802460 26255 0
复制代码
注意这只是本机的MTU,真实的网络中,你的数据从电脑网卡出去之后,可能要经过一系列的路由器、交换机等物理硬件,其中每个物理硬件都有自己的MTU,那在这漫长的网络路径中,起关键作用的MTU是哪个?答案是最小的那个,最小的那个就叫做路径MTU,这就像木桶效应,桶的容量是由最短的那一块板决定的,当你的数据包大于MTU时,会被拆成一个一个合适的网络包发出去。 IP层发现链路层的数据包有大小限制,因此IP层就说:"既然链路层有大小限制,发再大的数据包过去也是会被拆解的,还不如我自己做,在把数据发给链路老弟之前,我直接按照它的要求把数据分好段,就不麻烦它了。" IP层干了数据分段的事情之后,TCP层不高兴了,"弄啥嘞,弄啥嘞,我在他们的上层,数据竟然还要 IP 层小弟分段,我颜面何存!",于是 TCP 层为了避免数据被发送方分片,会主动把数据分割成小段再交给IP 层,TCP 能分的最大段我们称之为 MSS (Max Segment Size),这个 MSS 的值是多少呢?其实它的值是这个:
MSS = MTU - IP 头大小 - TCP 头大小
复制代码
其中 IP 头和 TCP 头各占 20 个字节,以 MTU=1500来说,那么 MSS = 1500-20-20=1460。就这样 TCP 层主动的把数据分好,从而得到了 IP 层和链路层的一致好评。 IP 层:"大哥靠谱"。 链路层:"大哥的大哥靠谱"。
我只能吃这么多-滑动窗口
在socket通信中,我们知道发送端有发送端的缓冲区,接收端有接收端的缓冲区,发送端把数据写入到socket缓冲区中,待缓冲区满了或者过了一段时间后,缓冲区的数据会被网卡一段一段的发出去,当数据到到达对端后,并不是直接等着对方处理,这样效率会很低,而是会先把数据放入到缓冲区中,也就是我们的接收缓冲区,然后应用程序不断的从接收缓冲区中取数据。缓冲区起到一个缓冲的作用,很合理,然后如果发送端发送的太快,或者说接收端的应用程序处理的太慢,导致接收端的缓冲区很快被填满,这时候该怎么办?直接发肯定不行,得告诉发送端先不要发了。这就要说到了 TCP 中的「滑动窗口」的概念。 我们知道 TCP 是基于字节流来发送数据的,也就说每个字节其实都是有序号的,有了序号可以干什么呢?首先通过序号可以重组数据,其次ack之前的序号表示都已经收到,滑动窗口和ack的情况有关,我们来站在 TCP 的角度看看数据包的状态。
这是站在发送端的角度来看数据包的状态的,其中的滑动窗口部分可以看作是发送端的滑动窗口,对于已发送已确认的部分,算是过去时了,它只会使滑动窗口向右移,真正影响滑动窗口大小的是「已发送未确认」和「未发送可发送」部分,剩下的「不能发送」是因为接收端没有足够的空间了。 我们再来站在接收端角度看看滑动窗口是什么样的。
可以发现窗口的大小其实是一样的,唯一的区别是对于接收端来说要么已接收,要么未接收,不能接收的话说明没有足够的空间了。那发送端怎么知道当前接收端剩余空间的大小?其实接收端在ACK的时候会带上自己窗口的大小,这样发送端就知道了接收端窗口的大小。以上图为例,当接收端拿到了32-35的数据后,就会ACK=36告诉发送端,同时接收端的滑动窗口会向后移动4位,发送端收到ACK=36后,就知道36之前的数据接收端都收到了,因此会把发送端的窗口也向后移动4位。
滑动窗口很棒,可以在能力范围内处理数据,但是有个问题呀:如果发送端能力极强,发的很快,接收端能力极弱,处理的很慢,这会导致什么问题?某一刻滑动窗口为0了,这时候接收端就会告诉发送端:"你奶奶的,消停会吧,没空间了"。发送端收到了通知之后:"原来是个弱鸡,休息会吧,等它下次ack通知我吧",正常来说,接收端在处理完数据之后可以告诉发送端可以继续发数据了,然而意外出现了,由于接收端所在的主机的主人正在听网易云音乐、玩着2k,同时还尼玛欣赏着b站舞蹈区up主娥罗多姿的舞蹈,导致网卡压力很大,最后一个ack丢失了,这样发送端就不知道接收端其实已经处理了一部分数据,这可怎么办,如果一直丢失,岂不是要一直傻等,得主动出击呀,于是搞了个「零窗口探测定时器」,这个定时器的功能相信大家也知道了,就是当接收方的接收窗口为0时,每隔一段时间,发送方会主动发送探测包,通过迫使对端响应来得知其接收窗口的状态,不得不说零窗口探测够稳。
悠着点慢慢来-拥塞控制
上面我们说到滑动窗口可以合理的控制接收端能处理数据的量,注意这里说的只是量,如果网络状况很差,发送端一次性发了很多数据,并且窗口未被填满(此处的意思就是和窗口的大小没关系),这时会发生什么?我想大概率是发送端疯狂的重传(因为网络差,未收到ack),那么我们反过来想一想网络状态差,我们还有必要发送大量的数据过去吗,大量的重试不是给发送端自己找麻烦,因此需要悠着点,这里的悠着点说的就是「拥塞窗口(cwnd)」,拥塞窗口指的是在收到对端 ACK 之前自己还能传输的最大 MSS 段数,那它和之前说的发送窗口有什么关系?其实真正的发送窗口大小是拥塞窗口和接收端那个窗口之间的最小值。 MSS 我们知道在 MTU=1500 的情况下它的值是1460,拥塞窗口指的就是能发多少个1460,由于在连接建立之初,发送端并不知道网络状况,如果网络状态很差,一口气传过去很多数据是不明智的,缓慢启动才是正确的选择,缓慢启动可以及时止损,同时缓慢启动也不并是说一直缓慢,如果网络ok,会随着时间慢慢增长,这就是缓慢启动的目的,那怎么个增长法呢?在通信之初,只要发送端收到一个 ACK 就会把 cwnd 翻倍,比如一开始是10,收到一个 ACK 后,下次就是20,再收到一个 ACK 后,就是40...,这个做法很聪明,我们只需忍受刚启动的时的慢速,随着时间的增长 cwnd 会以指数级的增长快速赶上来。 喝茶聊天,万事大吉。不对,这指数级的增长若不控制,一会便要超过了马斯克的财富了,这可不得了,于是搞了个慢启动阈值(ssthresh),当 cwnd 达到 ssthresh 时,这时说明 cwnd 不小了,在翻倍的涨下去可能会危险,这时可以选择小涨,不翻倍,每次在cwnd的基础上再加个1 MSS 就行了。
- 当 cwnd <= ssthresh 时,拥塞窗口按指数级增长(慢启动)
- 当 cwnd > ssthresh 时,拥塞窗口按线性增长(拥塞避免)
即使是加1个 MSS,随着时间的推移也可能无限大,但是为什么现实中没出现问题?我想其中之一就是上面的说到的真正的发送窗口的大小是两者中的最小的那个,毕竟接收窗口不可能无限大。其二就是随着网络包的越来越大,会发生网络拥堵,这时候 ssthresh 会降级, 也就是 ssthresh = cwnd / 2,然后 cwnd 会被设置为1个报文段,重头重新开始缓慢启动和拥塞避免,关于第二点,我借鉴网上一个例子: "假设 TCP 的 ssthresh 的初始值为 8。当拥塞窗口上升到 12 时网络发生了超时,于是TCP 开始使用慢开始和拥塞避免。试分别求出第 1 次到第 15 次传输的各拥塞窗口大小。"
一开始cwnd是1,然后不停的翻倍,直至到达 ssthresh,也就是8,这时开始每次加1个 MSS,当到12的时候,发生超时,也就是 ssthresh 会变成6,然后 cwnd 重新从1开始,也是不停的翻倍,当到4,准备翻倍到8的时候,发现sthresh=6,因此会变成6,然后开始每次加一个 MSS。因此第1次和第15次分别是1和9。
牛逼的算法让我不由的手舞足蹈,ちょっと待って(等一下),怎么判断网络拥堵超时了?这个其实很好判断,当超过一定时间之后,发送端没收到ack,可能就是网络超时了,正常来说,这时候,发送端会使用退避策略来重新发送,每次重传的间隔大概是几百毫秒,这几百毫秒毫秒对人类来说还挺快的,但是对计算机来说其实挺慢的,那有没有什么更快的方法?我们先来看个例子: 假设现在要发送4个数据包分别是[1,100],[101,200],[201,300],[301,400],正常来说发完第一个数据包之后,会回复ACK=101,没毛病,但是在发第二个数据包的时候,网络超时了,丢包了,当发送端继续发送第三个、第四包的时候,并不会回复 ACK=301,401,而是会继续回复 ACK=101,这里请再记住 ACK 代表这个序列号之前的数据都已收到。正如上文说到的,正常来说,此时要等几百毫秒才会意识到丢包,重发,而如果想要更快点,比如收到三次重复的ACK说明就是丢包了,这样是不是快很多,这就是「快速重传(SACK)」,但是只是单纯的告诉101之前的数据收到了(第一个数据包)有点低效,万一第三个也丢了怎么办,因此SACK做了进一步的优化:在通知ACK的同时也告诉比如第三个包也丢了、第四个数据包我收到了,这样发送端就知道了此刻除了第二个数据包丢失了,第三个包也丢失了,重传第二个、第三个即可。
最后
微信搜一搜【假装懂编程】,一起堕落一起颓废