参考:
小林coding: https://xiaolincoding.com/network/3_tcp/tcp_interview.html
TCP建立连接——三次握手
第三次握手是可以携带数据的,前两次握手是不可以携带数据的,这也是面试常问的题。
三次握手分别丢失会发生什么?
一些原则:
- syn 报文的最大重传次数由 tcp_syn_retries 控制,默认值一般是 5
- syn+ack 报文的最大重传次数由 tcp_synack_retries 控制,默认值一般是5
- ack 报文不会重传
- 每次超时的时间是上一次的 2 倍(幂次)。总耗时是 1+2+4+8+16+32=63 秒,所以重传时间最多 大约 1 分钟左右。
也就是说客户端/服务端在 1 秒后没收到 syn 报文 或 syn+ack 报文,就会超时重传,直到收到 ack,或当第五次超时重传后,会继续等待 32 秒。之后还是没收到响应,就会断开连接。
第一次握手丢失
- 如果第一次握手 SYN 丢失了,客户端就收不到服务端的 SYN+ACK 确认。客户端会重传 SYN 报文,也就是第一次握手。
第二次握手丢失
当第二次握手丢失了,客户端和服务端都会重传:
- 如果第二次握手 SYN+ACK 丢失了,客户端就收不到对自己第一次握手 ACK 的确认。客户端会重传 SYN 报文,也就是第一次握手。
- 如果第二次握手 SYN+ACK 丢失了,服务端就收不到第三次握手 ACK 对自己第二次握手的确认。服务端会重传 SYN+ACK 报文,也就是第二次握手。
第三次握手丢失
ack 报文是不会重传的
- 如果第三次握手 ACK 丢失了,服务端就收不到第三次握手 ACK 对自己第二次握手的确认,服务端会重传 SYN+ACK 报文,也就是第二次握手。
为什么是三次握手?
RFC 793 指出的 TCP 连接使用三次握手的首要原因:
The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.
「旧 SYN 报文」称为历史连接,TCP 使用三次握手建立连接的最主要原因就是防止「历史连接」初始化了连接。
此外,还能帮助双方同步初始化序列号。序列号能够保证数据包不重复、不丢弃和按序传输。
不使用「两次握手」和「四次握手」的原因:
- 「两次握手」:无法防止历史连接的建立,也无法可靠的同步双方序列号;
- 「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?
可以看到,如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传整个 IP 报文的所有的分片,大大增加了重传的效率。
Socket 编程
服务端启动服务进程监听端口:
- 服务端初始化
socket
,得到文件描述符; - 服务端调用
bind
,将 socket 绑定在指定的 IP 地址和端口; - 服务端调用
listen
,进行监听(执行时会创建半连接队列和全连接队列);【监听 Socket {*,*,本机 IP,本应用端口 }】 - 服务端调用
accept
,等待客户端连接(负责从 TCP 全连接队列取出一个已经建立连接的 socket);
客户端向服务端发起请求:
- 客户端初始化
socket
,得到文件描述符; - 客户端调用
connect
,向服务端的地址和端口发起连接请求; - 服务端
accept
从全连接队列中取出一个已建立连接的socket
的文件描述符;【已连接 Socket { 对端 IP,对端端口,本机 IP,本应用端口 }】 - 客户端调用
write
写入数据;服务端调用read
读取数据; - 客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
- 客户端 connect 成功返回是在第二次握手
- 服务端 accept 成功返回是在三次握手成功之后。
listen 时候参数 backlog 的意义?
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
没有 accept,能建立 TCP 连接吗?
可以,accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket
没有 listen,能建立 TCP 连接吗?
可以,listen 系统调用作用是 创建半连接队列和全连接队列。供服务端使用。
没有的话,可以建立以下两种连接,放在内核一个用于存放 sock 连接信息的全局 hash 表:
- TCP自连接:自己连自己
- TCP同时打开:两个客户端同时向对方发出请求建立连接
什么是 SYN 攻击?如何避免 SYN 攻击?
假设攻击者短时间伪造不同 IP 地址的 SYN
报文,服务端每接收到一个 SYN
报文,就进入SYN_RCVD
状态,但服务端发送出去的 ACK + SYN
报文,无法得到【未知 IP 主机】的 ACK
应答,久而久之就会占满服务端的【半连接】队列,使得服务端不能为正常用户服务。
当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
避免 SYN 攻击方式,可以有以下四种方法:
- 调大 netdev_max_backlog:当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包;
- 增大 TCP 半连接队列:要改三个参数,详见小林 coding;
- 开启 tcp_syncookies:相当于绕过了 SYN 半连接来建立连接,服务端算一个cookie放第二次握手里,客户端ack应答后服务端校验cookie合法性通过后直接放到全连接队列;
- 减少 SYN+ACK 重传次数:受攻击时,服务端收不到第三次握手,会重传SYN-ACK,减少这个重传次数,让重传快速达到上限后断开连接。
TCP断开连接 —— 四次挥手
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
为什么挥手需要四次?
- 关闭连接时,客户端向服务端发送
FIN
时,仅仅表示客户端不再发送数据了但是还能接收数据。 - 服务端收到客户端的
FIN
报文时,先回一个ACK
应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送FIN
报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK
和 FIN
一般都会分开发送,因此是需要四次挥手。
四次挥手分别丢失会发生什么?
一些原则:
- FIN 报文最大重传次数由 tcp_orphan_retries 参数控制
- ack 报文不会重传
- 每次超时的时间是上一次的 2 倍(幂次)。
- 主动关闭方收到被动方 FIN 之后,进入 FIN_WAIT2 状态,此状态有最大持续时间限制,由 tcp_fin_timeout 参数控制,默认值是 60 秒。
- 主动关闭方在发完第四次挥手完之后,进入 TIME_WAIT 状态,等待 2MSL 后,关闭连接。而每个第三次挥手(包括重传)都会刷新 主动关闭方的 TIME_WAIT 计时器
第一次挥手丢失
- 如果客户端的第一次挥手 FIN 丢失,那么客户端就收不到服务端的第二次挥手确认 ACK,就会重传第一次挥手 FIN 报文。
第二次挥手丢失
- 如果服务端的第二次挥手 ACK 丢了,那么客户端就收不到这个对第一次 FIN 的确认 ACK,就会重传第一次挥手 FIN 报文。
第三次挥手丢失
- 如果服务端的第三次挥手 FIN 丢失,那么服务端就收不到客户端的第四次挥手确认 ACK,服务端就会重传第三次挥手 FIN 报文
- 客户端因为是通过 close 函数关闭连接的,处于 FIN_WAIT_2 状态是有时长限制的,如果 tcp_fin_timeout 时间内还是没能收到服务端的第三次挥手(FIN 报文),那么客户端就会断开连接。
第四次挥手丢失
- 如果客户端的第四次挥手 FIN 丢失,那么服务端就收不到客户端的第四次挥手确认 ACK,服务端就会重传第三次挥手 FIN 报文
- 客户端在收到第三次挥手后,就会进入 TIME_WAIT 状态,开启时长为 2MSL 的定时器,如果途中再次收到第三次挥手(FIN 报文)后,就会重置定时器,当等待 2MSL 时长后,客户端就会断开连接。
TIME_WAIT 状态
为什么 TIME_WAIT 等待的时间是 2MSL?
MSL
是 Maximum Segment Lifetime,报文最大生存时间- IP 头中有一个
TTL
字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1
TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
发送和响应的 一来一回需要等待 2 倍的时间。看到 2MSL时长 这其实是相当于至少允许报文丢失一次。
比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。
2MSL
的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。
为什么需要 TIME_WAIT 状态?
在 RFC 793 指出 TIME-WAIT 一个重要的作用是:
TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.
也就是说,TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,客户端在收到服务端重传的 FIN 报文后,就会回 RST 报文(Connection reset by peer)。
为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。
TIME_WAIT 过多有什么危害?
- 如果客户端(主动发起关闭连接)都是和「目的 IP+ 目的 PORT 」都一样的服务端建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。(只要连接的是不同的服务端,端口是可以重复使用的)
- 如果服务端(主动发起关闭连接)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。
如何优化 TIME_WAIT ?
这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:
- 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项:在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。
- net.ipv4.tcp_max_tw_buckets:默认为 18000,当处于 TIME_WAIT 的连接超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置
- 程序中使用 SO_LINGER :应用强制使用 RST 关闭。调用
close
后,会立该发送一个RST
标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT
状态,直接关闭。
《UNIX网络编程》一书中却说道:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。
如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。
服务器出现大量 TIME_WAIT 状态的原因有哪些?
首先要知道 TIME_WAIT 状态是主动关闭连接方才会出现的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。
问题来了,什么场景下服务端会主动断开连接呢?
- 第一个场景:HTTP 没有使用长连接
- 第二个场景:HTTP 长连接超时
- 第三个场景:HTTP 长连接的请求数量达到上限
第一个场景:HTTP 没有使用长连接
- 在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的 header 中添加:
Connection: Keep-Alive
- 从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive,现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加
Connection:close
信息。
现在大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接(原因见小林 coding):
- 客户端禁用了 HTTP Keep-Alive,服务端开启了 HTTP Keep-Alive:服务端是主动关闭方
- 客户端开启了 HTTP Keep-Alive,服务端禁用了 HTTP Keep-Alive:服务端是主动关闭方
所以,此时服务端上就会出现 TIME_WAIT 状态的连接。
可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive。针对这个场景下,解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制。
第二个场景:HTTP 长连接超时
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。
客户端在完后一个 HTTP 请求后,在 keepalive_timeout 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端(nginx)上就会出现 TIME_WAIT 状态的连接。
可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时。
第三个场景:HTTP 长连接的请求数量达到上限
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。
比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
对于一些 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 函数关闭连接?这时候通常需要排查代码。
- 创建服务端 socket,bind 绑定端口、listen 监听端口
- 将服务端 socket 注册到 epoll
- epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
- 将已连接的 socket 注册到 epoll
- epoll_wait 等待事件发生
- 对方连接关闭时,我方调用 close
可能导致服务端没有调用 close 函数的原因,如下:
- 没有将服务端 socket 注册到 epoll。这种属于明显的代码逻辑 bug。
- 有新连接到来时没有调用 accpet 获取该连接的 socket。在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。
- 通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll。服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。
- 客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。
如果已经建立了连接,但是客户端突然出现故障了怎么办?
客户端出现故障指的是客户端的主机发生了宕机,或者断电的场景。
发生这种情况的时候,
- 如果服务端会发送数据,由于客户端已经不存在,收不到数据报文的响应报文,服务端的数据报文会超时重传,当重传总间隔时长达到一定阈值(内核会根据 tcp_retries2 设置的值计算出一个阈值)后,会断开 TCP 连接;
- 如果服务端一直不会发送数据,再看服务端有没有开启 TCP keepalive 机制?
- 如果有开启,服务端在一段时间没有进行数据交互时,会触发 TCP keepalive 机制,探测对方是否存在,如果探测到对方已经消亡,则会断开自身的 TCP 连接;
- 如果没有开启,服务端完全无法感知客户端的状态,的 TCP 连接会一直存在,并且一直保持在 ESTABLISHED 状态。
保活机制机制的原理是这样的:
- tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
- tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
- tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒(7200+75*9)才可以发现一个「死亡」连接。
如果开启了 TCP 保活,需要考虑以下几种情况:
-
对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
-
对端主机宕机并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
-
是对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
TCP 保活的这个机制检测的时间是有点长,我们可以自己在应用层实现一个心跳机制。
比如,web 服务软件一般都会提供 keepalive_timeout
参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。
如果已经建立了连接,但是 服务端/客户端 的进程崩溃会发生什么?
TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。
客户端进程崩溃,内核也会给服务端发送 FIN 报文,完成后续的挥手。