大家好,我是「云舒编程」,今天我们来聊聊计算机网络面试之-(传输层tcp)工作原理。
文章首发于微信公众号:云舒编程
关注公众号获取:
1、大厂项目分享
2、各种技术原理分享
3、部门内推
想必不少同学在面试过程中,会遇到「在浏览器中输入www.baidu.com后,到网页显示,其间发生了什么」类似的面试题。
本专栏将从该背景出发,详细介绍数据包从HTTP层->TCP层->IP层->网卡->互联网->目的地服务器 这中间涉及的知识。
本系列文章将采用自底向上的形式讲解每层的工作原理和数据在该层的处理方式。
图解 | 深入揭秘数据链路层、物理层工作原理
图解 | 深入揭秘IP层工作原理
图解 | 深入揭秘TCP工作原理
图解 | 深入揭秘HTTP工作原理
图解 | 深入揭秘Linux 接收网络数据包
图解 | 深入揭秘IO多路复用原理
通过上一篇文章每天5分钟玩转计算机网络-(网络层ip)工作原理的介绍,我们知道了网络层的基本工作原理。
本篇将会详解对传输层(tcp)进行介绍。
tcp是工作在传输层,也就是网络层上一层的协议。
它是面向连接的,可靠的,基于字节流、全双工的通信协议。
TCP收到上一层的数据包后,会加上TCP头并且进行一些特殊处理后,再传递给网络层
全双工(Full Duplex)是一种通信方式,指通信的双方可以同时发送和接收数据,而不需要像半双工那样在发送和接收之间切换。在全双工通信中,数据可以在两个方向上同时传输,因此通信速度更快,效率更高。
close:刚开始client和server都处于close状态
listen:server某个进程主动监听某个端口,进入listen状态
syn_sent:当server进入listen状态后,client就可以发起连接请求了。发送如下请求报文头:
然后client进入syn_sent状态
syn_rcvd:server收到client的请求,并且响应该请求,发送如下报文头后:进入syn_rcvd状态。
由于TCP是全双工,所以server也会向client请求建立server到client的连接,于是也会发送SYN和seq
established(client):client收到server的响应后,进入established状态,并且发送响应报文给server,报文如下:
established(server):server收到client的响应后,进入established状态。
一次wireshark抓包三次握手过程如下:
可以看到过程跟上述描述一模一样
通过前面的文章每天5分钟玩转计算机网络-(网络层ip)工作原理,我们知道IP层对于大于MTU的数据会进行分包,然后才会在网络上进行传输。
同样的,TCP对于上层传递过来的数据也会进行分包处理,当包的大小大于MSS时TCP会对包进行拆分后才会传递给IP层。
这里可能有同学会有疑问了:
为什么IP层已经进行分包了,TCP层还要进行一次呢?这不是多此一举吗?
还真不是多此一举,考虑下面一个传输场景:
可以发现,即使只丢失了分片二,但是TCP不得不重传整个报文,这是因为IP层不具备丢失重传能力,这样的设计造成了极大的资源浪费。
为了避免该问题,于是TCP的设计者们,就希望当只有某一分片丢失时,TCP可以知道是哪个分片,这样就可以只重传该分片即可,极大的节约了资源。
于是就提出了MSS的概念。
TCP MSS,全称为 Maximum Segment Size,是指 TCP 协议中的最大数据段大小。
它是 TCP 传输过程中数据段的最大长度,一般除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度就是MSS
有了MSS以后,再看下上面的问题怎么解决:
通过MSS可以避免包被IP层分割,TCP就可以完整掌握包的生命周期。
正如前面提到的,每个TCP报文都有一个序列号(seq),他们是严格有序的,TCP收到数据后会进行如下处理:
通过前面的描述,我们知道TCP每收到一个包,都会通过确认号(ack)告诉发送方。
如果发送方发现超时了,还没有收到对方的确认号,就会认为包丢失了,对该包进行重传,通过这样的形式可以保证数据一定送达了。
在谈重传机制之前,我们先说下确认号(ack)的确认机制:
TCP 使用确认号(ack)来告知对方下一个期望接收的序列号,表示小于此确认号的所有字节都已经收到
TCP采用的是连续确认机制,例如上图,一共发送了9,10,,11,,12,,13共5个包,只有5个包都收到了才会回ack=14,如果收到了9,10,12,13,丢失了11,那么TCP server是不会回ack=14的,不然客户端会以为小于14的包都收到了。
那遇到这种情况怎么处理呢?
一直不回ack=12,等待client发现包11超时了,然后重发包11,一旦server收到包11后就好回ack=14。
这种机制优点就是简单,但是缺点也很明显:
从前面的TCP重传机制我们知道超时的设置对于重传非常重要。
并且由于网络是动态变化的,RTT也不能定一个固定值,必须动态的去设置。于是TCP引入了RTT算法,表示一个数据包从发出去到收到响应的时间。这样发送方就可以灵活的设置超时时间(RTO)了。
RFC6298 建议使用以下的公式计算 RTO(这里忽略了RTT算法的演进史,有兴趣的同学可以自行查阅):
首次计算RTO:
SRTT = R
RTTVAG = R/2
RTO = SRTT + max(G,K*RTTVAG)
后续计算RTO,R'为最新计算出来的RTT:
SRTT = (1-α)*SRTT + α*R'
RTTVAG = (1-β)*RTTVAG + β*|SRTT-R'|
RTO = SRTT + max(G,K*RTTVAG)
============================
其中:
SRTT(Smoothed RTT):表示平滑RTT
R:为第一次计算出来的RTT
RTTVAG(Round Trip Time Variation):表示往返时间变化
G:最小时间颗粒
α=1/8,β=1/4,K=4
为了解决超时重传的问题,TCP引入了快速重传机制。
快速重传不以时间为驱动,而是以数据驱动重传。
如图:发送方发出了 9,10,11,12,13 共5份数据:
快速重传解决了超时效率的问题,但是他依旧有缺点:
假设上图丢失的包是9和10,那么在收到包11,12,13后,会连续返回三个ack=9,于是重传包9,然后又要连续返回三个ack=10再重传包10。
当然也可以选择收到三个ack=9后,就把9以后的报文全部重传,但是这样包11,12,13就重复了
为了解决上述问题,于是又引入了SACK(选择性确认),通过在TCP头部【选项】字段中加一个叫SACK的玩意,告诉发送方接收方已经收到哪些数据了,这样发送方就有了上帝视角,知道哪些数据丢失了,可以精确重传这些数据。
由于TCP头部长度有限制,所以一个报文最多可以容纳4组SACK信息
每个SACK信息包含一个区间,代表接收端存储的失序数据的起始至最后一个序列号(加1)
收到三次相同ack后,发送端根据sack信息就可以选择性的重传丢失报文了
1、TCP为了实现可靠性,要求对于发出去的每一个包都必须得到确认,否则会进行重传,所以需要有个地方存储已经发送了,但是还未确认的数据。
2、服务器的处理能力是有限的,类似人一样,有的人一次可以吃一个馒头,有的可以一次吃两个。那么就需要发送方根据服务器方的处理能力来控制发送数据的速率,避免把服务器方“撑死”。
为了解决上诉问题,于是引入滑动窗口。
应用会把要发送的数据放入TCP发送缓冲区,接收到的数据放入接收缓冲区。
而滑动窗口就是工作在缓冲区的,它把缓冲区的数据分为四部分:
1:已发送,并且已收到ack确认的数据
2:已发送,但是还未收到ack确认,如果超时还未收到ack就会重发这部分数据。
3:未发送,但是服务端还有剩余空间可以接受这部分数据
4:未发送,并且服务端没有剩余空间可以接受这部分数据
假设发送方把3窗口中的数据全部发送出去,那么可用窗口就会变为0:
这种情况下,在未收到接收方ack前,发送方将无法再发送新的数据。
其中黑色框就是滑动窗口。
假设服务端返回了ack=37,代表32~36共5个包已经收到,那么黑框就会向前移动5个包的位置:
发送方的滑动窗口大小(也就是黑框)是由接收方决定并且告知的。
例如下图:
接收方就是告诉发送方,我的窗口大小是17920,你最多可以发这么多数据过来,再多我就处理不过来了。
发送方看到win后,就会把自己的发送滑动窗口设置为17920,按照该值限制数据发送。
前面我们已经了解了滑动窗口,他可以避免【发送方】把 【接收方】填满打垮,但是对于网络来说,除了接收方发送方,中间经过的设备,光纤资源也是有限的。
他们的作用就类似于我们生活中的高速路和服务区。如同高速路人多了就会堵车一样,在网络上如果发包太快太多,依旧会发生堵塞。
TCP并不只满足于控制【发送方】和【接收方】,它还希望可以控制整个网络,在网络“堵车”的时候减少数据包传输,网络“畅通”的时候加快数据包传输。
核心思想:刚开始的时候,只发一点数据,如果可以正常接收到,那就多发一点,如果还能收到,那就继续多发,以此类推。
而拥塞控制就是做这件事的,拥塞控制主要由以下几个算法组成
提到拥塞控制不得不提拥塞窗口
拥塞窗口:在收到对端ACK前自己还能传输的最大数据包数
可以通过ss -nil | grep cwnd 查看cwnd初始大小
在连接刚建立的时候,发送方还不了解网络上的情况,所以慢启动的策略是一点一点的增加发送数据包的数量。
具体步骤:
刚开始cwnd=1,可以发送1个数据包,过了一段时间,收到ACK,这个时候cwnd+1 = 2。就可以发送2个数据包了。等收到这两个数据包的ACK,cwnd+2=4,就可以发送4个数据包了。以此类推,可以发现慢启动阶段,拥塞窗口成倍增长。
Linux 3.0后采用了Google的论文《An Argument for Increasing TCP’s Initial Congestion Window》,把cwnd 初始化成了 10个MSS
当 cwnd > ssthresh 时,拥塞窗口进入「拥塞避免」阶段,每经过一个RTT,拥塞窗口大约增加 1 个 MSS 大小,直到检测到拥塞为止。
这里可以看到慢启动阶段每经过一个RTT是翻倍,但是拥塞避免阶段一个RTT,窗口只增加1。
也就是进入了线性增长,降低窗口的增长速度,进一步限制发送方发包的数量,从而避免形成网络拥塞。
但是即使是这样,只要窗口一直在增加,那么最终肯定还是会导致拥塞发生。
当发生丢包的时候,就证明可能发生网络拥塞了,就会进入拥塞发生阶段。
而发现丢包主要依赖于两种手段:
TCP关于拥塞发生的处理有很多实现算法,下面我们主要介绍几种常见的:
可以通过 cat /proc/sys/net/ipv4/tcp_congestion_control 查询本机使用的拥塞控制算法
ACK超时或者收到三个重复ACK时,执行以下操作:
可以看到这是一种激进的做法,辛辛苦苦增加窗口大小,但是一夜回到解放前。并且容易造成网络抖动,影响应用程序。
TCP Reno 对收到重复ACK的场景进行了优化,TCP设计者认为既然可以收到三个ACK,证明网络没有那么拥塞,就不必像超时重传那么激进的做法,不采用cwnd置为初始值,而是根据当前值减半,并且sshthresh也等于当前窗口减半,那么就会立即进入拥塞避免阶段。如果网络没有那么糟糕,那么TCP还能维持一定的发送速率,并且缓慢上涨。如果仍然超时,再进入ACK超时算法阶段。
TCP Reno对三个重复ACK进行了优化,但是依旧存在问题:
当多个包在一个拥塞窗口丢失时,TCP Reno会重复减少拥塞窗口的大小。例如:连续丢失了3号,4号包。
刚开始收到关于3号包的重复ACK,窗口减半并且重发3号包,然后3号包达到了。又会重复收到4号包的重复ACK,这个时候窗口再次减半。但是对于这样的场景,其实拥塞窗口减半一次足以恢复重传已经丢失的数据包。
于是TCP NewReno 提出:对于同一窗口中的多次数据包丢失,希望减少一次窗口即可。
具体原理如下:
随着时间的发展,网络带宽越来越大,可以容纳的数据量也越来越多。上面的算法就面临一个问题:进入拥塞避免状态或快速恢复状态后,每经过一个RTT才会将窗口大小加1。当网络带宽很大,又只是偶尔丢包时,就会导致需要很长时间才能达到最佳拥塞窗口大小,对资源的利用就很低。
其实任何拥塞算法都是想找到一个最佳的拥塞窗口大小。
BIC认为:当产生丢包时,当前最佳拥塞窗口肯定是小于丢包时的拥塞窗口的,记为Wmax。同时定义一个乘法缩小因子β,令Vmin=β*Vmax,那么很明显当前的最佳窗口w,Vmin < w < Vmax。之前的算法都是采用加法去找,BIC采用二分的形式去搜索,将当前窗口设置为(Vmin + Vmax) /2。
这样当窗口远离Vmax时增长快,靠近Vmax增长慢。
当client和server数据传输完成后,就需要释放连接,TCP通过四次挥手释放连接。
MSL (Maximum Segment Lifetime),报文最大生存时间,超过这个时间报文将被丢弃。
- 在 macOS 可以通过 sysctl net.inet.tcp.msl 查询,该值/2等于MSL
- 在 Linux 上可以通过 sysctl net.ipv4.tcp_fin_timeout 查询,同上
这个问题其实可以拆成两个问题:
1、为什么需要time_wait?
2、time_wait的时间为什么是2MSL?
前面我们有提到TCP是可靠协议,他既要保证数据的可靠传输,也要保证连接的可靠关闭。
我们先假设没有time_wait,client收到server的FIN后,发送ACK响应,代表收到了FIN。但是client怎么知道自己发送的ACK被server收到了呢?只能sever收到ACK,再返回一个ACK表示收到了client的ACK,client又要响应ACK表示收到了ACK,就这样反反复复,形成了死循环。
为了解决这个问题,TCP设计者们就提出,采取一个默认规则吧:
client发送ACK后,等待一段时间,如果双方都没有数据传递了,那就可以断开连接了。所以就引入了time_wait。
情况一:
server第一次发送FIN到client,client响应ACK,但是ACK快到server端时丢失了。server端等待响应超时后,重传FIN,client收到新FIN后再次响应ACK,这次server成功收到ACK,于是关闭了连接。
也就是说为了兼容响应ACK丢失的情况,client需要等待一段时间,保证当server重传FIN时,他也可以处理。
那么考虑极端情况,ACK快到server才丢失,FIN从server重传(也就是图中红线部分),一来一回两个报文,加起来的时间刚好是2MSL。
情况二:
假设在极端情况下,如上图,旧连接的请求包,在新连接中又达到了,刚好序列号又在接受范围内,那就产生了脏数据。
为了避免这样的情况,在断开连接时,需要保证旧连接的报文全部消亡。前面我们说过,报文的最大生命周期是MSL,也就是为了保证旧报文消亡,time_wait至少需要MSL。
那么综合情况一和二,也就是time_wait至少需要2MSL。
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
当client 发送 SYN 到server,server 收到后回复 ACK 和 SYN。同时会将这个连接信息放入【半连接队列】,同时server会开启一个定时器,如果超时还未收到 client的 ACK 就会重传 SYN+ACK ,重传的次数由 tcp_synack_retries 值确定。
可以通过:sysctl net.ipv4.tcp_synack_retries 查询重传值,一般是5
一旦收到客户端的 ACK,服务端就会把该连接加入另外一个全连接队列。
从上面的描述可以看出,当client发送SYN并且服务端响应后就会把连接放到【半连接队列】,并且等待client的ACK。如果client构造了大量的请求,但是都不回复最后一个ACK,那么就会有大量的半连接信息占据【半连接队列】,把服务器的资源耗尽,无法响应正常连接请求,这就是SYN泛洪攻击。
我们可以通过两种方式模拟SYN泛洪攻击:
1、hping
hping 是用于生成和解析TCPIP协议数据包的开源工具。创作者是Salvatore Sanfilippo。目前最新版是hping3,支持使用tcl脚本自动化地调用其API。hping是安全审计、防火墙测试等工作的标配工具
# -S 发送SYN数据包
# --flood 泛洪攻击
hping -S -p 端口 --flood ip
2、iptables
iptables 可以过滤发给主机的网络包,我们通过iptables增加规则,丢弃服务端响应的SYN+ACK报文,这样客户端就不会发送ACK报文,达到模拟SYN攻击的目的
--append INPUT: 将规则添加到INPUT链中,该链用于处理输入的数据包。
--match tcp: 匹配TCP协议的数据包。
--protocol tcp: 指定匹配TCP协议的数据包。
--src 10.211.55.10: 指定源IP地址为10.211.55.10的数据包。
--sport 9090: 指定源端口为9090的数据包。
--tcp-flags SYN SYN: 匹配TCP flags中设置了SYN标志位的数据包,即TCP连接的初始请求包。
--jump DROP: 如果以上条件匹配,则将数据包丢弃。
iptables --append INPUT --match tcp --protocol tcp --src 目标ip地址 --sport 目标端口 --tcp-flags SYN SYN --jump DROP
所有完成了三次握手,但是还未被应用调用accept函数取走的连接都会被存放在【全连接队列】。
当应用调用accept函数后,内核就会移除队列头的连接返回给应用,如果没有可用的连接的话,就会阻塞。
可以使用 ss 命令,来查看 TCP 全连接队列的情况:
命令:ss -lt
输出:
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 *:36000 *:*
LISTEN 0 4096 *:48369 *:*
LISTEN 0 100 127.0.0.1:42222 *:*
注意:
ss 输出的结果 Recv-Q和Send-Q 在【LISTEN 状态】和【非 LISTEN 状态】的含义是不一样的
1、LISTEN 状态
2、非 LISTEN 状态时
1、CLOSED
TCP 连接还未开始建立或者连接已经释放的状态。
2、LISTEN
server主动监听一个特定的端口,等待客户端的连接。
//golang 可以用以下代码构造处于 listen 状态的 连接。
listener, err := net.Listen("tcp", ":8080")
3、SYN_RCVD
server收到client的SYN请求,并且回复SYN+ACK响应该请求后进入SYN-RCVD状态。
4、SYN-SENT
client发送 SYN 报文并且等待server回复 ACK 时进入 SYN-SENT状态。
5、ESTABLISHED
6、FIN-WAIT-1
主动关闭的一方(可以是client也可以是server)发送了 FIN 包,等待对端回复 ACK 时进入FIN-WAIT-1状态。
7、FIN-WAIT-2
处于 FIN-WAIT-1状态的连接收到 ACK 确认包以后进入FIN-WAIT-2状态。
8、CLOSE-WAIT
当被动关闭方收到对方的FIN包时就会进入CLOSE-WAIT。
9、TIME-WAIT
主动关闭端收到被动关闭端的FIN报文后,回复ACK就会进入time_wait状态;
10、LAST-ACK
被动关闭的一方,发送 FIN 包给对端,并且等待对端的 ACK 包时进入该状态。
keepAlive是TCP连接的一种保活机制,探测连接是否还可用的手段。
当连接的双方没有数据交互时,如果任意一方产生意外崩溃、当机、网线断开或路由器故障等问题,另一方会无法及时得知TCP连接已经失效,它就会一直维护这个连接,这样的连接称为【半打开连接】。非常多的半打开连接会造成系统资源的消耗和浪费。
为了解决这种情况,于是设计了TCP KeepAlive来避免。
当TCP 连接建立之后,如果开启了TCP Keepalive ,那么就会启动一个计时器。当计时器倒计时到0后,就会发出一个TCP 探测包。
TCP 探测包是一个纯 ACK 包(RFC1122#TCP Keep-Alives规范建议:不应该包含任何数据,但也可以包含1个无意义的字节,比如0x0),其 Seq号 与上一个包是重复的,所以其实探测保活报文不在窗口控制范围内。
1、原来阿里字节员工简历长这样
2、一条SQL差点引发离职
3、MySQL并发插入导致死锁
如果你也觉得我的分享有价值,记得点赞或者收藏哦!你的鼓励与支持,会让我更有动力写出更好的文章哦!
更多精彩内容,请关注公众号「云舒编程」