TCP 协议(一)报文结构
TCP 协议(二)连接与断开
TCP 协议(三)十种核心机制
TCP 协议(四)重传与超时
TCP 协议(五)异常报文

三次握手与四次挥手

在学习计算机网络之前,我们对于“三次握手”和“四次挥手”有所耳闻,其实这两个名词指的就是 TCP 连接与断开过程。

三次握手过程

三次握手
三次握手是为了让客户端和服务端分别确认自己和对方接收和发送消息的能力是正常的。
一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态。
1.第一次握手:客户端会发送 SYN 报文给服务端,TCP 部首 SYN 标志位置为 1,并随机初始化首部序列号 seq=x;表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN_SENT 状态。
2.第二次握手:服务端收到客户端的 SYN 报文后,首先,服务端也随机初始化自己的 TCP 部首序列号 seq=y;其次,把首部的确认号填入 ack=x+1;接着,把部首 SYN 和 ACK 标志位都置为 1;最后、把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN_RCVD 状态。
3.第三次握手:客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次部首确认号填入 ack=y+1 ,最后把报文发送给服务端。这次报文可以携带客户到服务器的数据。
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此致连接就已建立完成,客户端和服务端就可以相互发送数据了。
从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的。

四次挥手过程

TCP是全双工的工作模式,因此每个方向都必须单独进行关闭。当一方完成自己的数据发送任务后,就可以发送一个FIN报文来终止这个方向的连接。
四次挥手
四次挥手也就是客户端与服务器断开连接时,需要一共发送四个报文段来完成断开TCP连接。
初始时,客户端与服务器都处于 ESTABLISHED 状态,假如客户端发起断开连接的请求(服务器也可以发起),四次挥手过程如下:
1.第一次挥手:客户端发送 FIN 报文给服务端,TCP 部首 FIN 标志位置为 1,并随机初始化部首序列号 seq=u。之后客户端处于 FIN_WAIT_1 状态。
2.第二次挥手:服务器收到 FIN 报文后,立即发送一个 ACK 报文,部首确认号为 ack=u+1,序号设为 seq=v。表明已经收到了客户端的报文。之后服务器处于 CLOSE_WAIT 状态。
在第二次挥手和第三次挥手之间的时间段内,由于只是半关闭的状态,数据还是可以从服务器传送到客户端的。
3.第三次挥手:如果数据传送完毕,服务器也想断开连接,那么就发送 FIN 报文给客户端,并重新指定一个序号 seq=w,确认号还是ack=u+1,表明可以断开连接。
4.第四次挥手:客户端收到报文后,发出一个 ACK 报文应答,上一次客户端发送的报文序列号为 seq=u,那么这次序列号就是 seq=u+1,确认号为 ack=w+1。此时客户端处于 TIME_WAIT 状态,需要经过一段时间确保服务器收到自己的应答报文后,才会进入 CLOSED 状态。
服务器收到ACK报文后,就关闭连接,也处于 CLOSED 状态了。

11种状态名词解析​​​​​​​

LISTEN:等待从任何远端TCP 和端口的连接请求。
SYN_SENT:发送完一个连接请求后等待一个匹配的连接请求。
SYN_RCVD:发送连接请求并且接收到匹配的连接请求以后等待连接请求确认。
ESTABLISHED:表示一个打开的连接,接收到的数据可以被投递给用户。连接的数据传输阶段的正常状态。
FIN_WAIT_1:等待远端TCP 的连接终止请求,或者等待之前发送的连接终止请求的确认。
FIN_WAIT_2:等待远端TCP 的连接终止请求。
CLOSE_WAIT:等待本地用户的连接终止请求。
CLOSING:等待远端TCP 的连接终止请求确认。
LAST_ACK:等待先前发送给远端TCP 的连接终止请求的确认(包括它字节的连接终止请求的确认)
TIME_WAIT:等待足够的时间过去以确保远端TCP 接收到它的连接终止请求的确认。
	TIME_WAIT 两个存在的理由:
    1.可靠的实现tcp全双工连接的终止;
    2.允许老的重复分节在网络中消逝。
CLOSED:不在连接状态(这是为方便描述假想的状态,实际不存在)

抓包分析

第一次握手:客户端10.180.201.67发送 SYN 报文。
seq=1073647333(x),relative seq=0;
ack=0,relative ack=0。
第一次握手
第二次握手:服务端10.49.44.14发送 SYN / ACK 报文。
seq=326764992(y),relative seq=0;
ack=1073647334(x+1),relative ack=1。
第二次握手
第三次握手:客户端10.180.201.67发送 ACK 报文。
seq=1073647334(x+1),relative seq=1;
ack=326764993(y+1),relative ack=1。
第三次握手
客户端第一次发送数据:客户端10.180.201.67发送数据。
seq=1073647334(x+1),relative seq=1,payload=130,数据流的第一个字节编码为1,也即为relative seq=1;
ack=326764993(y+1),relative ack=1。
客户端第一次发送数据
服务端响应:服务端10.49.44.14发送响应数据。
seq=326764993(y+1),relative seq=1;
ack=1073647464(x+1+130),relative ack=131,字节流编码130之前的数据服务器已收到,请客户端继续发送131及以后的数据,也即为relative ack=131。
服务端第一次响应
服务端第一次发送数据:服务端10.49.44.14发送数据。
seq=326764993(y+1),relative seq=1,payload=261;
ack=1073647464(x+1+130),relative ack=131,还是上次的确认号,没有改变。
服务端第一次发送数据
客户端第二次发送数据:客户端10.180.201.67发送数据。
seq=1073647334(x+1+130),relative seq=131,payload=156;
ack=326765254(y+1+261),relative ack=262,同时确认服务端发送的第一次数据(261字节)。
客户端第二次发送数据
中间经过多次收发数据之后。
四次挥手好像没有完全遵守四次挥手的标准。
第一次挥手,客户端10.180.201.67断开连接。
seq=1073648132,relative seq=799,payload=0;
ack=3296855786,relative ack=90794。
客户端断开连接
第二次挥手,服务端10.49.44.14发送确认报文。
seq=3296855786,relative seq=90794,payload=179;
ack=1073648132,relative ack=799。
第二次挥手
第三次挥手;服务端发送 FIN 报文。
seq=3296855965(3296855786+19),relative seq=90973(90794+179),payload=0;
ack=1073648132,relative ack=799。
第三次挥手

三次握手问题总结

三次握手的作用

1.确认双方的接受和发送能力是否正常。
2.指定自己的初始化序列号,为后面的可靠传送做准备。序列号能够保证数据包不重复、不丢弃和按序传输。
3.如果是 https 协议的话,三次握手这个过程,还会进行数字证书的验证以及加密密钥的生成到。

为什么是三次握手?不是两次、四次?

接下来以三个方面分析三次握手的原因:
三次握手才可以阻止重复历史连接的初始化(主要原因)
三次握手才可以同步双方的初始序列号
三次握手才可以避免资源浪费
总结:
两次握手:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
四次握手:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。

原因一:阻止重复历史连接的初始化

简单来说,三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。我们考虑一个场景,客户端先发送了 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 使用三次握手建立连接最主要的原因,就是防止服务器初始化历史连接。

原因二:同步双方初始序列号

TCP 协议的通信双方, 都必须维护一个序列号,序列号是可靠传输的一个关键因素,它的作用:
1).接收方可以去除重复的数据;
2).接收方可以根据数据包的序列号按序接收;
3).可以标识发送出去的数据包中, 哪些是已经被对方收到的。
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带“初始序列号”的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送“初始序列号”给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。
同步双方初始序列号
“四次握手”其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了三次握手。
而“两次握手”只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。

原因三:避免资源浪费

如果只有“两次握手”,当客户端的 SYN 连接请求在网络中阻塞,客户端没有也不会接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接,这会造成什么情况呢?
如果客户端的 SYN 阻塞了,重复发送多次 SYN 报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
为什么不用两次握手
即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN 报文,而造成重复分配资源。

初始序列号 ISN 是如何随机产生的?

起始 ISN(Initial Sequence Number) 是基于时钟的,每 4 毫秒 + 1,转一圈要 4.55 个小时。
RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。
ISN = M + F (localhost, localport, remotehost, remoteport)
M 是一个计时器,这个计时器每隔 4 毫秒加 1。
F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。

为什么客户端和服务端的初始序列号 ISN 是不相同的?

**避免攻击:**三次握手的一个重要功能是客户端和服务端交换 ISN(Initial Sequence Number),以便让对方知道接下来接收数据的时候如何按序列号组装。如果ISN是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。
**避免干扰:**因为网络中的报文会延迟、会复制重发、也有可能丢失,这样会造成的不同连接之间产生互相干扰,所以为了避免互相干扰,客户端和服务端的初始序列号是随机且不同的。

三次握手过程中可以携带数据吗?

第一次、第二次握手不可以携带数据,而第三次握手是可以携带数据的。
为什么这样呢?假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据,因为攻击者根本就不理会服务器的接收、发送能力是否正常,然后疯狂得重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。也就是说,第一次握手可以放数据的话,会让服务器更加容易受到攻击。
而对于第三次的话,此时客户端已经处于 ESTABLISHED 状态,对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据也没啥毛病。

既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?

MTU和MSS
MTU:一个网络包的最大长度,以太网中一般为 1500 字节;
MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度。
如果 TCP 的整个报文交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU 大小的数据要发送,那么 IP 层就要把数据分成若干片,保证每一个分片都小于 MTU。IP 分片数据由目标主机的 IP 层重新组装,然后交给上一层 TCP 传输层。
**这样看起来井然有序,但是是存在隐患的:**如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传(不然 IP 报文中传输的 TCP数据就会不完整),因为 IP 层本身没有超时重传机制(超时重传机制是由传输层的 TCP 来负责的)。
当接收方发现 TCP 报文不完整,就不会发送 ACK 响应给对方,那么发送方的 TCP 在超时后,就会重发整个 TCP 报文。
可以看出,由 IP 层进行分片传输,是非常没有效率的。所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
协商MSS
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。

内核 SYN 队列和 Accept 队列

Linux 内核中会维护两个队列:
**半连接队列(SYN 队列):**服务器收到客户端的 SYN 请求之后,服务器处于 SYN_RCVD 状态,此时双方还没有完全建立连接,服务器会把此种状态下请求连接放在一个队列里,我们称之为半连接队列(SYN 队列)。
**全连接队列(Accpet 队列):**已经完成三次握手过程,服务器处于 ESTABLISHED 状态,建立起的连接就会放在全连接队列中。
如果队列满了就有可能会出现丢包现象。
这里在补充一点关于 SYN/ACK 重传次数的问题:服务器发送完 SYN/ACK 包,如果未收到客户端的确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传,如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s, 2s, 4s, 8s…

SYN 请求处理流程

当服务端接收到客户端的 SYN 报文时,会将其加入到内核的 SYN 队列;
接着发送 SYN/ACK 给客户端,等待客户端回应 ACK 报文;
服务端接收到 ACK 报文后,从 SYN 队列移除放入到 Accept 队列;
应用通过调用 accpet() socket 接口,从 Accept 队列 取出的连接。
正常流程

什么是 SYN 攻击?如何避免 SYN 攻击?

SYN 攻击

我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 SYN/ACK 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的半连接队列(SYN 队列),使得服务器不能为正常用户服务。
攻击者只发送 SYN,不返回 ACK:
SYN 攻击
SYN 队列被占满:
SYN攻击二

避免 SYN 攻击方式一:直接丢弃

修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。

// 控制该队列的最大值如下参数
net.core.netdev_max_backlog
// SYN_RCVD 状态连接的最大个数
net.ipv4.tcp_max_syn_backlog
// 超出处理能时,对新的 SYN 直接回 RST,丢弃连接
net.ipv4.tcp_abort_on_overflow

避免 SYN 攻击方式二:启动 cookie

如果不断受到 SYN 攻击,就会导致 SYN 队列被占满。

net.ipv4.tcp_syncookies = 1

启动cookie
当 SYN 队列满之后,后续服务器收到 SYN 包,不进入 SYN 队列;
计算出一个 cookie 值,再以 SYN/ACK 中的序列号返回客户端,
服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到 Accept 队列。
最后应用通过调用 accpet() socket 接口,从 Accept 队列取出的连接。

应用程序处理过慢,会导致 Accept队列溢出

如果应用程序过慢时,就会导致 Accept 队列被占满。
应用程序过慢

如何在 Linux 系统中查看 TCP 状态?

TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。
netstat
Linux netstat 命令用于显示网络状态。利用 netstat 指令可让你得知整个 Linux 系统的网络情况。

语法 netstat [-acCeFghilMnNoprstuvVwx][-A<网络类型>][–ip] 参数说明:
	-a,--–all 显示所有连线中的Socket。
	-A<网络类型>或–<网络类型> 列出该网络类型连线中的相关地址。
	-c,--continuous 持续列出网络状态。
	-C,--cache 显示路由器配置的快取信息。
	-e,--extend 显示网络其他相关信息。
	-F,--fib 显示FIB。
	-g,--groups 显示多重广播功能群组组员名单。
	-h,--help 在线帮助。
	-i,--interfaces 显示网络界面信息表单。
	-l,--listening 显示监控中的服务器的Socket。
	-M,--masquerade 显示伪装的网络连线。
	-n,--numeric 直接使用IP地址,而不通过域名服务器。
	-N,--netlink或–symbolic 显示网络硬件外围设备的符号连接名称。
	-o,--timers 显示计时器。
	-p,--programs 显示正在使用Socket的程序识别码和程序名称。
	-r,--route 显示Routing Table。
	-s,--statistics 显示网络工作信息统计表。
	-t,--tcp 显示TCP传输协议的连线状况。
	-u,--udp 显示UDP传输协议的连线状况。
	-v,--verbose 显示指令执行过程。
	-V,--version 显示版本信息。
	-w,--raw 显示RAW传输协议的连线状况。
	-x,--unix 此参数的效果和指定"-A unix"参数相同。
	--ip,--inet 此参数的效果和指定"-A inet"参数相同。

四次挥手问题总结

为什么挥手需要四次?

再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。
关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。 服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。

为什么 TIME_WAIT 等待的时间是 2MSL?

MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
为什么要用2MSL
TIME_WAIT 等待 2MSL 有两个原因:
1:如果客户端最后一个 ACK 丢失,服务端需要重传 FIN,如果客户端直接进入 CLOSED 状态,那对于重传的 FIN,肯定是 RST 响应。
2:为了保证最后一个 ACK 正常的丢失,因为不确认对方是否收到,需要等待 1MSL,至于另一个MSL,能找到比较信服的解释是被动关闭方在收到 ACK 那一刻之前重发了 FIN,为了保证这个 FIN 正常丢失,需要再等1MSL。
在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:

#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT 
                                    state, about 60 seconds  */

如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。

[test@localhost ~]$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60
[test@localhost ~]$ sysctl net.ipv4.tcp_fin_timeout
net.ipv4.tcp_fin_timeout = 60

为什么需要 TIME_WAIT 状态?

主动发起关闭连接的一方,才会有 TIME_WAIT 状态。
需要 TIME_WAIT 状态,主要是两个原因:
1.防止具有相同“四元组”的,旧连接的数据包被收到;
2.保证“被动关闭方”能被正确的关闭,即保证最后的 ACK 能让“被动关闭方”接收,从而帮助其正常关闭。

原因一:防止旧连接的数据包

假设 TIME_WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
防止旧链接的数据包
如上图黄色框框服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。
这时有相同端口的 TCP 连接被复用后,被延迟的 SEQ = 301 抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。
所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。

原因二:保证连接正确关闭

在 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 报文如果在网络中被丢失了,此时如果客户端 TIME_WAIT 过短或没有,则就直接进入了 CLOSE 状态了,那么服务端则会一直处在 LASE_ACK 状态。
当客户端发起建立连接的 SYN 请求报文后,服务端会发送 RST 报文给客户端,连接建立的过程就会被终止。
如果 TIME_WAIT 等待足够长的情况就会遇到两种情况:
1.服务端正常收到四次挥手的最后一个 ACK 报文,则服务端正常关闭连接。
2.服务端没有收到四次挥手的最后一个 ACK 报文时,则会重发 FIN 关闭连接报文并等待新的 ACK 报文。
所以客户端在 TIME_WAIT 状态等待 2MSL 时间后,就可以保证双方的连接都可以正常的关闭。

TIME_WAIT 过多有什么危害?

如果服务器有处于 TIME_WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。
过多的 TIME_WAIT 状态主要的危害有两种:
1.第一是内存资源占用;
2.第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口。
第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过参数 net.ipv4.ip_local_port_range 设置指定。
如果服务端 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。

如何优化 TIME_WAIT?

这里给出优化 TIME_WAIT 的三种方式,都是有利有弊:
1.打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;
2.net.ipv4.tcp_max_tw_buckets
3.程序中使用 SO_LINGER ,应用强制使用 RST 关闭。

方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps

如下的 Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。
net.ipv4.tcp_tw_reuse = 1
使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即 net.ipv4.tcp_timestamps=1(默认即为 1)
这个时间戳的字段是在 TCP 头部的“选项”里,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。
由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
弊端: net.ipv4.tcp_tw_reuse 要慎用,因为使用了它就必然要打开时间戳的支持 net.ipv4.tcp_timestamps,当客户端与服务端主机时间不同步时,客户端的发送的消息会被直接拒绝掉。

方式二:net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置。
弊端: 这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。

方式三:程序中使用 SO_LINGER

我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。

struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));

如果 l_onoff 为非 0, 且 l_linger 值为 0,那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT 状态,直接关闭。
弊端: 这为跨越 TIME_WAIT 状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

TCP 保活机制,应对客户端突然出现故障?

TCP 有一个机制是保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

net.ipv4.tcp_keepalive_time=7200; // 表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
net.ipv4.tcp_keepalive_intvl=75; // 表示每次检测间隔 75 秒;
net.ipv4.tcp_keepalive_probes=9; // 表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

也就是说在 Linux 系统中,最少需要经过2小时11分15 秒才可以发现一个死亡连接。这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。
TCP 保活机制
如果开启了 TCP 保活机制,需要考虑以下几种情况:
1.对端程序是正常工作的:当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
2.对端程序崩溃并重启:当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
3.是对端程序崩溃,或对端由于其他原因导致报文不可达:当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该连接已经死亡。

socket 编程与状态分析

相关参数设置

在 Linux 系统中,时间阈值通常是根据 TCP 的超时重传机制和相关参数来确定的。内核确实会设置一些默认值,但这些值可以通过系统配置和内核参数调整。以下是一些与 TCP 重传超时相关的参数:

tcp_syn_retries(SYN 请求次数)

这个参数决定了客户端在放弃尝试建立连接之前发送SYN报文的次数。默认值通常是5。如果需要查询或修改这个参数,可以使用以下命令:

# 查询
sysctl net.ipv4.tcp_syn_retries
# 修改
sudo sysctl -w net.ipv4.tcp_syn_retries=<新值>

tcp_synack_retries(SYN/ACK 请求次数)

这个参数决定了服务器在放弃尝试建立连接之前发送SYN-ACK报文的次数。默认值通常是5。如果需要查询或修改这个参数,可以使用以下命令:

# 查询
sysctl net.ipv4.tcp_synack_retries
# 修改
sudo sysctl -w net.ipv4.tcp_synack_retries=<新值>

使用套接字编程 API 设置 TCP 连接的参数

尽管应用层无法直接更改内核参数,但它可以为特定的套接字(socket)设置选项以覆盖默认设置。以下是一个设置TCP连接超时的示例:
1.首先,包含必要的头文件

#include <iostream>
#include <sys/socket.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>

2.创建一个套接字(socket)

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    std::cerr << "Error creating socket" << std::endl;
    return -1;
}

3.设置套接字选项以调整 TCP 连接超时
对于连接超时(连接建立超时),可以使用 setsockopt 函数设置 SO_RCVTIMEO 和 SO_SNDTIMEO 选项:

struct timeval timeout;
timeout.tv_sec = 10; // 10 seconds
timeout.tv_usec = 0; // 0 microseconds

if (setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(timeout)) < 0) {
    std::cerr << "Error setting socket options" << std::endl;
    close(sockfd);
    return -1;
}

if (setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, (char *)&timeout, sizeof(timeout)) < 0) {
    std::cerr << "Error setting socket options" << std::endl;
    close(sockfd);
    return -1;
}

请注意,这里的超时设置是针对特定套接字的,而不是全局设置。还可以设置其他套接字选项,如 TCP_NODELAY 以禁用 Nagle 算法,或 SO_KEEPALIVE 以启用 TCP 保活功能。
4.接下来,像往常一样连接到远程服务器、发送数据和接收数据。

TCP 连接和断开过程相关的一些套接字选项

以下是与 TCP 连接和断开过程相关的一些套接字选项:

# 通用套接字选项(SOL_SOCKET):
SO_RCVTIMEO:设置接收超时时间。影响连接过程中等待服务器响应的时间。
SO_SNDTIMEO:设置发送超时时间。影响连接过程中发送数据的时间。
SO_KEEPALIVE:设置是否启用 TCP keepalive。影响连接在空闲状态下的维持。
SO_REUSEADDR:允许重用本地地址和端口。影响连接后,立即重新使用地址和端口。
SO_LINGER:设置套接字关闭时的行为。影响断开连接时,未发送完毕的数据的处理方式。
# TCP 选项(IPPROTO_TCP):
TCP_NODELAY:禁用 Nagle 算法,即禁用TCP数据发送的合并。影响连接过程中发送数据的效率。
TCP_QUICKACK:允许立即确认接收到的数据。影响连接过程中确认数据的速度。
# IP选项(IPPROTO_IP):
IP_TTL:设置 IP 数据报的生存时间(Time to Live)。影响连接过程中IP数据报的传输。

这些选项在 TCP 连接和断开过程中有一定的作用,但并非所有选项都涉及连接和断开。有些选项可能在连接建立后的数据传输过程中发挥作用。在设置套接字选项时,请参阅相关文档以确保正确理解每个选项的作用。

Socket 编码流程

网络编程
服务端和客户端初始化 socket,得到文件描述符;
服务端调用 bind,将绑定在 IP 地址和端口;
服务端调用 listen,进行监听等待客户端请求;
客户端调用 connect,向服务器端的地址和端口发起连接请求;
服务端调用 accept,接受客户端连接,accept 返回用于传输的 socket 的文件描述符;
客户端调用 write 写入数据;
服务端调用 read 读取数据;
客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。
注意: 服务端调用 accept 时,会返回一个已完成连接的 socket,用于后续传输数据。所以,服务端监听的 socket 和真正用来传送数据的 socket,是两个socket,一个叫作监听 socket,一个叫作已完成连接 socket。

listen 参数 backlog 的意义?

Linux 内核中会维护两个队列:
未完成连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
已完成连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;
SYN队列和Accept队列

int listen (int socketfd, int backlog)
// 参数一:socketfd 为 socketfd 文件描述符
// 参数二:backlog,这参数在历史有一定的变化
// 在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
// 在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。

accept 和 close 处理时机?

在这里插入图片描述

accept 在三次握手的哪一步?

客户端向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 seq=x,客户端进入 SYNC_SENT 状态;
服务器端收到这个包之后,返回 ACK 应答,应答的值为ack=x+1,表示对客户端 SYN 包的确认,同时服务器也告诉客户端当前发送序列号为 seq=y,服务器端进入 SYNC_RCVD 状态;
客户端收到 ACK 之后,应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端也会对服务器端的 SYN 包进行确认应答,确认号 ack=y+1;
应答包到达服务器端后,服务器端程序从 accept 调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。

客户端调用 close 了,连接断开的流程是什么?

客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,并进入 FIN_WAIT_1 状态;
服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。文件结束符 EOF 会被放在排队等候的已接收数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态;
当已数据处理完之后,自然就会读到 EOF,于是调用 close 关闭已完成连接 socket,并发出一个 FIN 包,之后处于 LAST_ACK 状态;
客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
客户端进过 2MSL 时间之后,也进入 CLOSED 状态。

完整代码

以下代码仅作为示例,展示一个简单的 TC P客户端和服务器之间建立连接的过程。在这个示例中,我们使用了 C 语言和 BSD 套接字(Socket)API 来实现 TCP 连接。
首先,我们创建一个简单的 TCP 服务器:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define BUF_SIZE 1024

int main(int argc, char *argv[]) {
    int server_socket, client_socket;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_size;
    char message[BUF_SIZE];

    if (argc != 2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    // 创建服务器端套接字
    server_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (server_socket == -1) {
        perror("socket error");
        exit(1);
    }

    // 配置服务器端地址信息
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(atoi(argv[1]));

    // 绑定套接字
    if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind error");
        exit(1);
    }

    // 监听连接
    if (listen(server_socket, 5) == -1) {
        perror("listen error");
        exit(1);
    }

    // 接受客户端连接
    client_addr_size = sizeof(client_addr);
    client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_addr_size);
    if (client_socket == -1) {
        perror("accept error");
        exit(1);
    }

    // 向客户端发送数据
    strcpy(message, "Hello, Client!");
    write(client_socket, message, sizeof(message));

    // 关闭套接字
    close(client_socket);
    close(server_socket);

    return 0;
}

接下来,创建一个简单的 TCP 客户端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define BUF_SIZE 1024

int main(int argc, char *argv[]) {
    int client_socket;
    struct sockaddr_in server_addr;
    char message[BUF_SIZE];

    if (argc != 3) {
        printf("Usage : %s <ip> <port>\n", argv[0]);
        exit(1);
    }

    // 创建客户端套接字
    client_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (client_socket == -1) {
        perror("socket error");
        exit(1);
    }

    // 配置服务器端地址信息
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr(argv[1]);
    server_addr.sin_port = htons(atoi(argv[2]));
    // 连接到服务器
    if (connect(client_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("connect error");
        exit(1);
    }
    
    // 从服务器接收数据
    int str_len = read(client_socket, message, BUF_SIZE - 1);
    if (str_len == -1) {
        perror("read error");
        exit(1);
    }
    
    // 打印接收到的数据
    message[str_len] = '\0';
    printf("Message from server: %s\n", message);
    
    // 关闭套接字
    close(client_socket);
    
    return 0;   
}

在这个例子中,服务器创建了一个套接字、绑定了地址信息、开始监听连接。当客户端尝试连接时,服务器接受连接并向客户端发送一条消息。客户端创建套接字、配置服务器地址信息、连接到服务器、接收服务器的消息并打印。

参考:
https://blog.csdn.net/qq_32907195/article/details/108335868
https://liucjy.blog.csdn.net/article/details/130397559
https://blog.csdn.net/m0_38106923/article/details/108292454
https://blog.csdn.net/sutong_first/article/details/126540687
https://blog.csdn.net/weixin_52109884/article/details/119885387

Logo

长江两岸老火锅,共聚山城开发者!We Want You!

更多推荐