参考:

小林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 表:

  1. TCP自连接:自己连自己
  2. 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 函数关闭连接?这时候通常需要排查代码。

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,我方调用 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 保活,需要考虑以下几种情况:

  1. 对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置等待下一个 TCP 保活时间的到来。

  2. 对端主机宕机并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息会产生一个 RST 报文这样很快就会发现 TCP 连接已经被重置

  3. 是对端主机宕机注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。

TCP 保活的这个机制检测的时间是有点长,我们可以自己在应用层实现一个心跳机制。

比如,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。

如果已经建立了连接,但是 服务端/客户端 的进程崩溃会发生什么?

TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。

客户端进程崩溃,内核也会给服务端发送 FIN 报文,完成后续的挥手。