CSInternet08
欢迎回来!本篇我们开始详细地讨论TCP协议,包括讲解TCP的头部格式,与TCP握手和挥手的流程,争取做到一网打尽!
认识TCP
首先不得不评鉴的一环是TCP的头部格式。在学习时,可以牢牢围绕上篇讲述的TCP的定义(面向连接、可靠、基于字节流)进行理解——因为所有的实现都是围绕这几个用途落地的。
序列号(seq):在建立连接时由计算机生成的随机数作为其初始值,通过SYN包传给接收端主机。每发送一次数据,就”累加”一次该”数据字节数”的大小。用来解决网络包乱序问题。
确认应答号(ack,注意与下文控制位ACK区分):指下一次”期望”收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。一般来说,如果连接正常,那么客户端上一次传输中的seq值即为本次服务端传输中的ack值+1。
控制位
- ACK:该位为
1时,”确认应答”的字段变为有效,TCP规定除了最初建立连接的的SYN包之外该位必须设置为1。 - RST:该位为
1时,表示TCP连接中出现异常必须强制断开连接。 - SYN:该位为
1时,表示希望建立连接,并在其”序列号”的字段进行序列号初始值的设定。 - FIN:该位为
1时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换FIN位为1的TCP段。
学英语:
- ACK: Acknowledgment(确认)
- RST: Reset(重置)
- SYN: Synchronize(同步)
- FIN: Finish(结束)
什么是TCP连接?
TCP和UDP能不能绑定相同的端口?
可以。
在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。
所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。
传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。
因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。
TCP连接建立(握手)
:我要向你发起连接了。听得到吗?
:我听得到。你听得到吗?
:听得到。现在开始向你传输数据。
我们先讲解标准的TCP连接建立,即三次握手的过程:
0.TCP服务器主动监听某个端口,从 CLOSE 状态改变为 LISTEN 状态;
1.如有连接请求,客户端会首先发送一个SYN报文。此报文中含有客户端随机初始化的序列号(图中为x),并将控制位的SYN置为1,表示向服务端发起请求连接。该报文不含应用层数据。发送该报文后,客户端进入 SYN-SENT 状态。
2.服务端接收到SYN报文后,将发送SYN+ACK报文给客户端。在此之中,含有服务端随机初始化的序列号(图中为y),确认应答号ack=x+1,以及控制位的SYN、ACK置为1. 该报文也不含应用层数据。发送该报文后,服务端进入 SYN-RCVD 状态。
3.客户端收到SYN+ACK报文后,还要回应最后一个ACK报文。首先该应答报文 TCP 首部 ACK 标志位置为 1,其次「确认应答号」字段填入 y+1 ,最后把报文发送给服务端。这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
4.服务端收到ACK报文后,也会进入 ESTABLISHED 状态。此时连接建立完成,双方可以互相发送数据。
结束了嘛?不,实际上才刚刚开始😈
接下来会通过一系列经典的面试题,帮助读者深刻理解三次握手的必要性。
为什么是三次握手,不是两次?
在前面我们知道了什么是 TCP 连接——用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。
所以,重要的是为什么三次握手才可以初始化 Socket、序列号和窗口大小并建立 TCP 连接。我们将从两个方面分别进行讨论。
1.避免历史连接(主要原因)与资源浪费
我们考虑一个场景,客户端先发送了 SYN(seq =90) 报文,然后客户端宕机了,而且这个 SYN 报文还被网络阻塞了,服务端并没有收到。
接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq =100) 报文 (注意!不是重传 SYN,重传的SYN 的序列号是一样的)。
下图展示了三次握手是如何在这个场景中避免历史连接的:
客户端连续发送多次 SYN(都是同一个四元组)建立连接的报文,在网络拥堵情况下:
- 一个「旧 SYN 报文」比「最新的 SYN」报文早到达了服务端,那么此时服务端就会回一个 SYN +ACK 报文给客户端,此报文中的确认号是 91(90+1)。
- 客户端收到后,发现自己期望收到的确认号应该是 100+1,而不是 90 +1,于是就会回 RST 报文。
- 服务端收到 RST 报文后,就会释放连接。
- 后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。
上述中的「 旧 SYN 报文」称为历史连接,TCP 使用三次握手建立连接的最主要原因就是防止「历史连接」初始化了连接。
那么二次握手的缺点此刻也显而易见了:倘若没有SYN+ACK报文的确认,取而代之的是接收到SYN报文之后直接进入ESTABLISHED状态并发送数据后,如果此时再接收到新的SYN报文,那么又得再发一次数据。这是毫无疑问地资源浪费。
2.同步初始序列号
在上一篇中讲到,序列号是保证TCP可靠性的一个关键因素,它的作用有:
- 接收方可以去除重复的数据;
- 接收方可以根据数据包的序列号按序接收;
- 可以标识发送出去的数据包中,哪些是已经被对方收到的(通过 ACK 报文中的序列号知道);
所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收;那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,初始序列号才能被可靠的同步。
而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收,因此被舍弃。
第一次握手丢失了,会发生什么?
当客户端想和服务端建立TCP连接时,首先第一个发的就是SYN报文,然后进入到 SYN_SENT 状态。
在这之后,如果客户端迟迟收不到服务端的SYN-ACK报文(第二次握手),就会触发”超时重传”机制,重传SYN报文,而且重传的SYN报文的序列号都是一样的。不同版本的操作系统可能超时时间不同,有的1秒的,也有3秒的,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。
当客户端在1秒后没收到服务端的SYN-ACK报文后,客户端就会重发SYN报文,那到底重发几次呢?
在Linux里,客户端的SYN报文最大重传次数由 tcp_syn_retries 内核参数控制,这个参数是可以自定义的,默认值一般是5。
1 | # cat /proc/sys/net/ipv4/tcp_syn_retries |
通常,第一次超时重传是在1秒后,第二次超时重传是在2秒,第三次超时重传是在4秒后,第四次超时重传是在8秒后,第五次是在超时重传16秒后。没错,每次超时的时间是上一次的2倍。
当第五次超时重传后,会继续等待32秒,如果服务端仍然没有回应ACK,客户端就不再发送SYN包,然后断开TCP连接。
所以,总耗时是1+2+4+8+16+32=63秒,大约1分钟左右。
举个例子,假设tcp_syn_retries参数值为3,那么当客户端的SYN报文一直在网络中丢失时,当客户端已达到最大重传次数,再等待段时间(时间为上一次超时时间的2倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。
第二次握手丢失了,会发生什么?
因为第二次握手报文里是包含对客户端的第一次握手的 ACK确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。
如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传SYN-ACK 报文。
在Linux下,SYN-ACK报文的最大重传次数由 tcp_synack_retries 内核参数决定,默认值是5。
1 | # cat /proc/sys/net/ipv4/tcp_synack_retries |
因此,当第二次握手丢失了,客户端和服务端都会重传:
- 客户端会重传SYN报文,也就是第一次握手,最大重传次数由
tcp_syn_retries内核参数决定; - 服务端会重传SYN-ACK报文,也就是第二次握手,最大重传次数由
tcp_synack_retries内核参数决定。
举个例子,假设tcp_syn_retries参数值为1,tcp_synack_retries参数值为2,那么当第二次握手一直丢失时,发生的过程如下图:
这里最需要注意的地方是:重传SYN报文之后,SYNACK的重传次数也会被重制。其余内容与第一次握手丢失的情况几乎相同。
第三次握手丢失了,会发生什么?
接收到SYN+ACK报文的客户端此时已经进入ESTABLISHED状态。作为最后一次握手,如果服务端接收不到ACK报文,只会重发SYN+ACK,直到收到第三次握手,或者达到最大重传次数。到达tcp_syn_retries后,服务端再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。客户端最终会通过尝试使用连接或TCP保活机制来发现连接已断开。
TCP连接断开(挥手)
: 我已经没有数据要发送了,想要关闭连接。
: 我知道了,但我可能还有数据要发给你,请继续保持接收。
(继续发送剩余数据)
: 我也已经没有数据要发送了,可以关闭连接了。
: 我明白了,连接关闭。
TCP 断开连接是通过四次挥手方式。双方都可以主动断开连接,断开连接后主机中的「资源」将被释放,四次挥手的过程如下图:
详细过程(此处省略seq和ack的分析):
- 客户端打算关闭连接,此时会发送一个TCP首部
FIN标志位被置为1的报文,也即FIN报文,之后客户端进入FIN_WAIT_1状态。(注:同时也有ACK=1确认之前收到的数据) - 服务端收到该报文后,就向客户端发送
ACK应答报文,接着服务端进入CLOSE_WAIT状态。 - 客户端收到服务端的
ACK应答报文后,之后进入FIN_WAIT_2状态。 - 等待服务端处理完数据后,也向客户端发送
FIN报文,之后服务端进入LAST_ACK状态。(注:同时也有ACK=1确认之前收到的数据) - 客户端收到服务端的
FIN报文后,回一个ACK应答报文,之后进入TIME_WAIT状态。 - 服务端收到了
ACK应答报文后,就进入了CLOSE状态,至此服务端已经完成连接的关闭。 - 客户端在经过
2MSL一段时间后,自动进入CLOSE状态,至此客户端也完成连接的关闭。
你可以看到,抛开两次数据确认,在挥手过程中每个方向都需要一个FIN和一个ACK,因此通常被称为四次挥手。
这里一点需要注意是:主动关闭连接的,才有TIME_WAIT状态。
第一次挥手丢失了,会发生什么?
当客户端(主动关闭方) 调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到FIN_WAIT_1 状态。如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN报文,重发次数由 tcp_orphan_retries 参数控制。当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的2倍),如果还是没能收到第二次挥手,那么直接进入到 close 状态。
举个例子,假设 tcp_orphan_retries 参数值为 3,当第一次挥手一直丢失时,发生的过程如下图:
第二次挥手丢失了,会发生什么?
在讲解TCP握手时我们也讲到,ACK报文自带不粘锅属性,自己丢失了不会自己重传,一定要前一个报文重传之后自己才能作为响应报文再次发送,说白了就是没有自我重传的能力。因此挥手时也是同样的:当ACK报文丢失时,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。
第三次挥手丢失了,会发生什么?
首先需要说明:由于客户端是主动关闭连接的,在内核中调用了close函数。对于 close 函数关闭的连接,由于无法再发送和接收数据,FIN_WAIT_2 状态不可以持续太久,而tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。
这意味着如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭,如下图:
服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。如果迟迟收不到第四次握手的 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。当服务端达到最大重发次数,会再等上两倍的时间,还是没有收到ACK的话就自动关闭连接。
对于客户端来说,由于FIN报文没有接收到,所以就认为是上方图片中的情况,到达60秒后自动关闭。
第四次挥手丢失了,会发生什么?
当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态。然后,服务端(被动关闭方)没有收到 ACK 报文前,还是处于 LAST_ACK 状态。
由于ACK报文的不粘锅属性,如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的tcp_orphan retries 参数控制。
对于客户端,收到重传的FIN报文后客户端会重制定时器。当等待 2MSL 时长后,客户端就会断开连接。
对于服务端,当服务端重传第三次挥手报文达到2时,由于
tcp_orphan_retries为 2,达到了最大重传次数,于是再等待一段时间(时间为上一次超时时间的2倍),如果还是没能收到客户端的第四次挥手,那么服务端就会断开连接。
为什么 TIME_WAIT 状态要等待 2MSL?
首先要明确的是,主动发起关闭连接的一方,才会有 TIME_WAIT 状态。图解网络这里说的很明白了,我就直接贴图:
为什么需要 TIME_WAIT 状态?
两个原因:
防止历史连接中的数据,被后面相同四元组的连接错误的接收;
保证「被动关闭连接」的一方,能被正确的关闭。
在详细解释之前,我们先来了解一下序列号(seq)和初始序列号(ISN)的区别:
- 序列号,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。
- 初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。
因此,序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。
这会导致什么问题呢?
如同图中场景,服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。当服务器以相同的四元组打开新一轮连接时,残留的数据报却被客户端窗口接收了,这会造成数据混乱等非常严重的问题。
为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
第二个原因就更简单了:
在 TIME_WAIT 时间过短 + ACK数据报丢失的场景中,客户端不会理会重传的FIN报文,而是返回RST使得服务端异常终止。有了TIME_WAIT等待的2MSL,这事儿当然就解决了:
TIME_WAIT 过多有什么危害?
过多的 TIME-WAIT 状态主要的危害有两种:
- 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
- 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过 net.ipv4.ip_local_port_range 参数指定范围。
客户端和服务端 TIME_WAIT 过多,造成的影响是不同的。
如果客户端(发起连接方)都是和「目的 IP+ 目的 PORT」都一样的服务端建立连接的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。
不过,即使是在这种场景下,只要连接的是不同的服务端,端口是可以重复使用的,所以客户端还是可以向其他服务端发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。
如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。
服务端产生大量 TIME_WAIT 状态的原因是什么?
能够让服务端主动断开连接的原因只有三个:
1.第一个场景:HTTP没有使用长连接
首先可以排查下是否客户端和服务端都开启了HTTP Keep-Alive,因为任意一方没有开启 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接。
针对这个场景下,解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制就行。
2.第二个场景:HTTP长连接超时
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。HTTP 长连接可以在同一个 TCP 连接上接收和发送多个 HTTP 请求/应答,避免了连接建立和释放的开销。
并且,为了避免长时间无通信导致的资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。
假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端建立完 TCP 连接后,很长一段时间没有发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于TIME_WAIT 状态的连接。
此时可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时。
3.第三个场景:HTTP 长连接的请求数量达到上限
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。
比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
keepalive_requests 参数的默认值是 100,意味着每个 HTTP 长连接最多只能跑 100 次请求,这个参数往往被大多数人忽略,因为当 QPS(每秒请求数)不是很高时,默认值 100 凑合够用。
但是,对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000,50000 甚至更高,如果 keepalive_requests 参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态。
针对这个场景下,解决的方式也很简单,调大 nginx 的 keepalive_requests 参数就行。
服务端产生大量 CLOSE_WAIT 状态的原因是什么?
CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。
所以,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接。
那什么情况会导致服务端的程序没有调用 close 函数关闭连接?这时候通常需要排查代码。
我们先来分析一个普通的 TCP 服务端的流程:
- 创建服务端 socket,bind 绑定端口、listen 监听端口
- 将服务端 socket 注册到 epoll
- epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
- 将已连接的 socket 注册到 epoll
- epoll_wait 等待事件发生
- 对方连接关闭时,我方调用 close
可能导致服务端没有调用 close 函数的原因,如下。
第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。
不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了。
第二个原因:第 3 步没有做,有新连接到来的没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。
发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。
第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。
发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。
第四个原因:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。
可以发现,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close。





