小林图解网络TCP篇
1.TCP三次握手和四次挥手
- 序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。 用来解决网络包乱序问题。
- 确认应答号:指下⼀次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。 用来解决丢包的问题。
控制位:
- ACK:该位为 1 时,「确认应答」的字段变为有效, TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
- RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
- SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
- FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
==TCP三大特点:面向连接,可靠,基于字节流==,TCP只支持一对一通信,UDP支持一对多通信;
连接TCP需要三个方面达成共识:
- Socket:IP地址和端口号组成(TCP四元组就是源地址,源端口,目的地址,目的端口,TCP连接数就是IP数乘端口数)
- 序列号:为了解决乱序问题
- 窗口大小:用来解决流量控制问题
UDP头部格式
- 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。
- 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和。
- 校验和:校验和是为了提供可靠的 UDP首部和数据而设计,防止收到在网络传输中受损的 UDP 包。
TCP三次握手的过程
client_isn就是客户端随机初始化的序列号,对应TCP头中的序列号,ACK Num对应TCP头中的确认序列号;
三次握手的主要原因:
- 三次握手才可以阻止重复历史连接的初始化(主要原因);如果客户机挂机,挂机前发送了SYN,因为是随机生成的重启后再发送的SYN和之前的不同,如果服务端返回的ACK对应的是挂机前的SYN,客户端就可以回RST报文,断开连接。
- 三次握手才可以同步双方的初始序列号;第二次握手保证服务端知道客户端的序列号,第三次握手保证客户端知道服务端的序列号;
- 三次握手才可以避免资源浪费;如果两次握手,SYN包在网络中阻塞重发,服务端就会返回两次ACK,造成重复分配资源。
序列号的作用:
防止数据错乱,如果客户端的数据发送到网络中后断开,如果重连后发送报文的序列号和原来相同,旧报文还在服务端的接收窗口内,就可能造成旧的数据被接受,使得数据错乱。而如果使用随机的序列号会使得重连后发送的序列号,旧的报文大概率不在服务端的接受窗口内。
一些术语:
- MTU :⼀个网络包的最大长度,以太网中⼀般为 1500 字节;(MSS + IP头 + TCP头)
- MSS :除去 IP 和 TCP 头部之后,⼀个网络包所能容纳的 TCP 数据的最大长度;(只有数据部分)
- MSL:报文最大生存时间,任何报文在网络中的最大存在时间,超过这个时间报文被丢弃;
第一次握手丢失,重传SYN报文;第二次握手丢失,重传SYN报文和SYN + ACK报文;第三次握手丢失;重传SYN + ACK报文;
三次握手在服务器端的体现:
- 当服务端接收到客户端的 SYN 报文时,会创建⼀个半连接的对象,然后将其加⼊到内核的「 SYN 队列」;
- 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
- 服务端接收到 ACK 报文后,从「 SYN 队列」取出⼀个半连接对象,然后创建⼀个新的连接对象放入到「 Accept 队列」;
- 应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接对象。
遭遇客户端SYN攻击的解决方法:
- 增大半连接队列
- 减小SYN + ACK重传次数,加快处于 SYN_REVC 状态的 TCP连接断开。
- 开启syncookies,相当于绕过了SYN队列,收到SYN后,返回一个SYN cookie,如果客户端能返回ACK,则认为三次握手成功。
- 如果SYN队列满了,后面再来的SYN就会被丢弃;
如果已连接的TCP收到SYN,会==回复⼀个携带了正确序列号和确认号的 ACK 报文==,这个 ACK 被称之为 Challenge ACK。接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回RST 报文,服务端收到后,就会释放掉该连接。
由于这种特性,给了窃听真实TCP序列号的机会。两种中断TCP的方式如下:
- tcpkill :给服务端和客户端都发送了伪造的 RST 报文,从而达到关闭⼀条 TCP 连接的效果。 tcpkill 只适合关闭活跃的 TCP 连接,不适合用来关闭非活跃的 TCP 连接;因为他只有在TCP通信时,才去获取正确的序列号;
- killcx: 可以⽤来关闭活跃和⾮活跃的 TCP 连接,因为 killcx ⼯具是主动发送 SYN 报文,这时对⽅就会回复 Challenge ACK ,然后 killcx ⼯具就能从这个 ACK 获取到正确的序列号,进而发送RST中断连接。
如果在网络编程中服务端没有调用listen(),客户端对服务端建立连接时,服务端返回RST报文。
如果客户端形成TCP自连接,就不需要listen了,因为其不会创建半连接队列, 而是会将自己的连接信息放到全局hash表中,然后将信息发出,消息在回到TCP传输层的时候。就会从这个全局hash中取出信息,最后成功建立连接。
全连接队列是一个链表,半连接队列是一个哈希表。
TCP四次挥手
- 客户端打算关闭连接,此时会发送⼀个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
- 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSE_WAIT 状态。注意ACK报文不会重传,如果丢失就需要客户端重新发FIN报文
- 客户端收到服务端的 ACK 应答报文后,之后进入FIN_WAIT_2 状态。此时可能服务端还有数据要向客户端发送,==如果没有数据要发送,而且开启了 TCP 延迟确认机制==(默认会开启)(延迟确认机制就是收到数据包后ACK延迟一段时间再返回,最好和数据一起发送,这样能提高通信效率;)就会出现三次握手的情况。
- 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。(4.10)如果在这里客户端先收到了乱序的FIN报文,再收到数据包,会将FIN报文加入乱序队列,不会进入TIME_WAIT队列。直到在乱序队列找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有FIN标志,如果有,就会进入TIME_WAIT状态。
- 客户端收到服务端的 FIN 报文后,回⼀个 ACK 应答报文,之后进入 TIME_WAIT 状态。(4.11)如果在TIME_WAIT状态再收到服务端的SYN报文请求,会检查报文的序列号是不是比「期望下⼀个收到的序列号」大,并且时间戳也比最后收到的报文大,如果是直接进入SYN_RECV 状态 ,否则会发送RST断开连接;
- 服务端收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
- 客户端在经过 2MSL ⼀段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。
如果TIME_WAIT时间过短的问题:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 如果TIME_WAIT太短,断开前的服务端报文可能被网络延时,还没有到客户端,这时候如果又重新建立TCP连接,下次连接就有可能收到上次断开之前的数据,造成数据错乱。
- 保证「被动关闭连接」的一方,能被正确的关闭; 保证ACK能被服务端正确接收,如果没有接受到返回的ACK,服务端就会重新发送FIN报文,客户端返回ACK并且重置TIME_WAIT为2MSL;
出现很多TIME_WAIT可能的原因:
- HTTP没有使用长连接
- HTTP长连接超时
- HTTP长连接请求达到上限
出现很多CLOSE_WAIT可能的原因:
主要可能就是代码的问题,服务器忘记调用close;
代码层面来说,第二次握手是客户端connect()函数成功返回,第三次握手是服务端accept()函数成功返回。
- 如果服务端没有listen,会导致服务端返回RST报文;
- 如果服务端没有accept,会影响不能从全连接队列accept队列取出已建立连接的socket;
- 如果建立的连接,但是客户端断开了,就会调用保活机制,服务端发送探测报文,如果客户端断开了,就会发送RST报文;
- 如果建立的连接,但服务端断开了,服务端会发送FIN 报文进行四次挥手。
(4-12)
异常分析:TCP连接,一端断电和进程崩溃有什么区别
TCP的keepalive保活机制:TCP保活的探测报文发送给对端,若正常相应,保活时间重置;否则连续几次失败,TCP报告该TCP连接已经死亡;这个保活机制是在内核中完成的,与HTTP的保活机制不一样,HTTP的保活机制是在应用层做的,HTTP在keepalive时TCP一直保持连接,直到定时器超时。目的是为了减小HTTP短连接带来的多次TCP连接和释放的开销;
如果服务端的进程崩溃,在kill进程时,服务端会发送FIN报文,与客户端进行四次挥手; 如果是主机关机了,另一端就会进行数据重传,到一定次数后断开连接
RST段理论上不应该受时间戳影响,应该不管时间戳如何,RST段都是可以被接受的。
没有TIME_WAIT状态可能出现问题:
- 历史RST报文中断后面的相同四元组的TCP连接
- 如果第四次握手的TCP丢了,可能被动关闭的一方不能被正常关闭。
2. TCP重传、滑动窗口、流量控制、拥塞控制
重传机制
- 超时重传:发送方设置一个定时器,超过一定时间没有收到ACK就重传。可能是数据包丢失或者ACK丢失都会重传。重传时间RTO应该略大于往返时延RTT;
- 快速重传:解决了重传时间的问题,如果收到三个相同的ACK,比如ACK = 2,就认为Seq = 2丢失了。但是存在的问题是Seq = 2后面的不知道丢失了几个。
- SACK:选择性重传,解决了快速重传存在的问题,快速重传返回ACK的同时返回SACK,表示那一段收到了,比如ACK = 2, SACK = 3 ~ 6就表示只有Seq = 2丢失了,后面的都收到了,因此只需要重传Seq = 2的这段数据就可以了。
- D-SACK:使用SACK告诉发送方那些数据重复发送了。比如ACK = 4000, SACK = 3000 ~3500,表示4000之前的数据都收到了,3000 ~ 3500重复接受了,出现这种现象的原因是这一段返回的ACK丢失,发送方不知道所以重复发送。
滑动窗口
解决TCP没发送数据都需要ACK应答,通信效率低的问题。窗口大小就是指无需等待确认应答,可以继续发送数据的最大值。
这样的好处是==即使中间有的ACK丢失了,但是只要收到他后面的ACK,就知道数据正常收到了,不需要重传了==
窗口的大小往往是接受方告诉发送方的
发送方窗口如下:
接受方窗口如下:
流量控制:
TCP 提供⼀种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。是通过滑动窗口实现的。
如果接收方窗口满了,给发送方发送一个窗口为0的ACK,直到窗口空闲,再发送一个非0的ACK,但是如果这个非0的ACK丢失,就会出现类似死锁的现象,解决的方法就是发送方接受到窗口为0的ACK时,启动定时器,超时就会发送窗口探测。如果探测多次还是0,有的TCP实现会发送RST断开连接。
糊涂窗口综合征:
接收方的窗口一直很小,发送方一直发送很小的数据包,这样因为发送数据包时还需要包头等开销,通信效率很低。
解决方法:
- 接收方不通告小窗口给发送方,当窗口小于min(最大报文长度,缓存空间 / 2),直接向发送方通告窗口为0;
- 发送方避免发送小数据(Nagle算法),等到窗口大于最大报文长度并且数据大小大于最大报文长度,才会发送数据;
拥塞控制:
拥塞窗口 cwnd是发送方维护的⼀个的状态变量,它会根据网络的拥塞程度动态变化的。
拥塞控制主要是四个算法:
- 慢启动,在超过阈值之前指数增加
- 拥塞避免,到达阈值之后,拥塞窗口到底慢启动门限时就会进入拥塞避免窗口,也就是每次增加1;
- 拥塞发生,分超时重传和快速重传两种情况处理,下图是超时重传的处理流程。ssthresh变为cwnd / 2,cwnd重置为初始值;快速重传则为ssthresh变为cwnd,cwnd变为cwnd / 2;可以看出没有超时重传那么激进。
- 快速恢复,往往和快速重传一起使用,cwnd = ssthresh + 3,表示网络可能出现了阻塞, 加 3 代表快速重传时已经确认接收到了 3个重复的数据包
3. TCP实战分析
TCP快速建立连接
TCP延迟确认:
- 当有响应数据要发送时, ACK 会随着响应数据⼀起立刻发送给对方
- 当没有响应数据要发送时, ACK 将会延迟一段时间,以等待是否有响应数据可以⼀起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
4. 如何优化TCP
三次握手性能提升:
- 调整SYN报文重传次数
- 调整SYN半连接队列长度
- 调整SYN + ACK报文重传次数
- 调整accept队列长度
- 绕过三次握手,通过TCP快速连接实现
四次挥手性能提升:
- 调整FIN报文重传次数
- 调整FIN_WAIT2状态的时间
- 调整孤儿连接的上限个数
- 调整TIME_WAIT状态的上限个数
- 复用TIME_WAIT状态的连接
关闭连接有close和shutdown两种方式,close是完全关闭,变成孤儿连接;shutdown是半关闭,控制只关闭一个方向的连接。
TCP数据传输的性能提升:
- 扩大窗口大小
- 调整发送缓冲区范围
- 调整接受缓冲区范围
- 接受缓冲区动态调节
- 调整内存范围
带宽延时积BDP = RTT * 带宽,如果窗口大于了带宽延时积,容易使得发送的包太多,网络拥塞,如果窗口小于带宽延时积,不能最大化的利用网络的带宽资源。
TCP和UDP对比
UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是⼀个用户消息的边界;当用户消息通过 TCP 协议传输时, 消息可能会被操作系统分组成多个的 TCP 报文;
解决TCP粘包的问题:
- 发送固定长度的消息,适用性差;
- 特殊字符作为边界,例如HTTP,通过设置换行符,回车符作为HTTP报文协议的边界;
- 自定义消息结构,包头字段说明后面的包数据有多大。
5. TCP的局限性
- 升级TCP的工作很困难,因为TCP协议是在内核中实现的;此外,因为TLS无法对位于内核的TCP加密,因此如果攻击者伪造RST报文关闭TCP连接,只要TCP字段中的序列号位于接收方的滑动窗口内,就是合法的。
- TCP建立连接的延迟,可以通过TCP Fast Open优化,
- TCP存在队头阻塞问题,一个TCP包丢失了,后面所有发送的包即使内核收到了,==应用层也无法读取这部分数据==。发送窗口阻塞(返回的ACK丢失)会导致发送方无法继续发送数据;接收窗口阻塞(发送的数据包丢失)会导致接收方应用层无法读取新的数据。
- 网络迁移需要重新建立TCP连接,需要四元组确定一条TCP连接(源地址,源端口,目的地址,目的端口),QUIC通过连接ID标记通信的两个端点,达到了连接迁移的功能。
6. TCP的改进
解决队头阻塞的问题,可以通过UDP加QUIC实现。
TCP重传时报文的序列号和原始报文是一样的,这样就存在歧义,不知道返回的ACK是针对原始的报文还是重传的报文,导致计算的RTT存在偏差。而QUIC报文的报文序号是严格递增的,不存在这个问题。
此外QUIC还支持乱序确认,不管有没有数据包丢失,只要有新的已接受数据包确认,当前窗口就会继续向右滑动。
此外,QUIC借鉴HTTP/2里Stream的概念,在一条QUIC连接上并发发送多个HTTP请求。为每个Stream分配了一个独立的滑动窗口,使得一个连接上的多个Stream之间没有以来关系。阻塞只会阻塞一个Stream,不会影响其他的。QUIC包括Stream级流量控制和Connection级流量控制两种。
7. TCP传输的完整流程
- 数据从发送端到接收端,链路很长,任何⼀个地方都可能发生丢包,几乎可以说丢包不可避免。
- 当你发现服务异常的时候,比如接口延时很高,总是失败的时候,可以用ping或者mtr命令看下是不是中间链路发生了丢包。
- TCP只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。
8. TCP序列号和确认号的变化
发送的 TCP 报文:
- 序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是SYN 报文或者 FIN 报文,则改为上一次发送的序列号 + 1。
- 确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1。