TCP四次挥手以及TIME_WAIT状态带来的问题
TCP允许连接的任意一方对连接发起关闭,下面,我们把发起关闭的一方称为主动关闭端
,而接收到关闭请求后配合关闭的一端称为被动关闭端
。
TCP允许半关闭,即主动端发起关闭后被动端可以不关闭,主动端仍然接受被动端的数据,但不返回任何数据。
TCP允许同时关闭,即两端同时发起关闭请求,这种情况下没有主动和被动端。
上面两种极端情况不是本文所讨论的范围,但还是要在开头做一个简单说明
本文会使用基于Python3
的TCP回声服务器和客户端程序,代码在本文的最后位置
四次挥手
TCP的关闭需要四次挥手
- 主动关闭端发送FIN包代表它希望关闭连接
- 被动关闭方接收到FIN包后发送ACK,通知主动方已经接到了它的FIN
- 被动关闭方发送FIN包表示它也希望关闭连接
- 主动方接收到来自被动方的FIN,并回复它ACK
三次挥手
为什么握手的时候是三次?因为握手过程中被动端的SYN
和ACK
是合并的。所以整个过程是SYN -> SYNACK -> ACK
,而挥手过程中的FIN
和ACK
没有合并,所以整个过程是FIN -> ACK -> FIN -> ACK
。
而由于ACK占用的长度是死的,无论你发的是不是ACK消息,它都在头部字段里占用空间,所以,大部分系统都可能会在允许时合并ACK和其它消息,而不是发送单独的ACK。所以实践中FIN
和ACK
也有可能合并,这样,挥手就变成了三次:
四次挥手中的连接状态
TCP的一个连接正常关闭是主动被动双方共同努力的后果。
主动关闭端状态
主动关闭端一般是客户端,但任何一端都可以先行发起关闭,来当这个主动关闭端。
下图是主动关闭端的状态转换,我们不用看该图右侧的SYN_SENT
和ESTABLISHED
。
- 主动端决定关闭,发送FIN,进入
FIN_WAIT_1
状态。这个状态下,主动端在等待被动端的ACK - 主动端接收到了被动端的ACK,进入
FIN_WAIT_2
状态。这个状态下,主动端在等待被动端的FIN - 主动端接收到了被动端的FIN,它需要返回ACK,此时进入
TIME_WAIT
状态 - 等待30秒(这只是个普遍的值,这个值可以被修改)后,进入
CLOSED
阶段
被动关闭端状态
被动关闭端一般是服务器端,但服务器也完全可以主动发起关闭。
下图是被动关闭端的状态转换,我们同样不看右侧。
- 被动端在正常连接时(ESTABLISHED)接收主动端的FIN,会发送ACK并转入
CLOSE_WAIT
阶段 - 被动端要发送自己的FIN给主动端,表明自己也要关闭,这时进入
LAST_ACK
阶段 - 被动端等待主动端的ACK,进入
CLOSED
状态
TIME_WAIT状态以及SO_REUSEADDR
选项
为什么要有TIME_WAIT
主动端要确认对于它的最后一个ACK,被动端有正确的接到,若被动端没接到,它就会重传FIN,这时主动端需要再次发送ACK。
需要注意的一点是,TCP协议没法知道一个ACK消息是否正确被收到,但是在足够长的时间范围内,若一个ACK没被正确收到,发送方将会重传等待确认的包。也正是因为这一点,所以主动端必须老老实实等待TIME_WAIT
阶段结束,因为它永远无法确认被动端会不会重传FIN。
TIME_WAIT时间必须足够长,该长度是2MSL,也就是两倍的最大段生存期(Maximum Segment Lifetime),也就是任何报文段在被丢弃前在网络中被允许存在的最长时间。如果这个时间内被动端还没有重传FIN,就认为主动端的最后一个ACK已经被接收成功。
TIME_WAIT显然不能完全解决问题,考虑被动端重发的FIN仍然丢失,那么2MSL的时间显然不够再次重传。
TIME_WAIT会带来什么问题
使用本文文末提供的客户端服务器程序,先通过start.py
来开启一个TCP服务器:
➜ simple_tcp_server python3 start.py
> use port => 8080
> =============================== <
> accepting new connection...
默认情况下,该服务器会被绑定到8080端口,并开始接收新连接,现在,我们开启一个客户端来连接它:
➜ simple_tcp_server python3 cli.py
> Connecting to ('localhost', 8080) using local port 41967
('localhost', 8080) > Connected!
('localhost', 8080) >
默认情况下,客户端会尝试连接localhost
的8080端口,并且使用随机的本地端口进行连接(这里是41967),现在,客户端出现了一个提示符,我们可以通过输入数据来向服务器发送数据。
('localhost', 8080) > Hi, Server!
> Hi, Server!
('localhost', 8080) >
我们向服务器发送了Hi, Server!
,服务器将同样的内容返回给我们,同时,我们也看到服务器这边的日志也显示了建立连接和接收到消息:
这些都不是关键,关键是,我们需要让一端主动关闭连接,然后看看TIME_WAIT
带来的副作用
服务端主动关闭
现在,我们在服务端处于ESTABLISH
的情况下直接关闭服务端(使用Ctrl+C)
然后,我们查看8080端口的占用状态,这里显示它正处于FIN_WAIT2
状态,并且需要等待57秒,这是因为客户端还没有发起它的FIN
。
你需要关闭客户端:
- 在客户端中输入
$quit
命令 - 随便发送一条消息,客户端会检测到连接已经无法使用,并调用
close
关闭连接 - Ctrl+C
现在,服务器变成了TIME_WAIT
状态,并且当我们再次尝试开启服务器时,会发现端口已经被占用。
这就是TIME_WAIT
状态的副作用,由于TCP socket绑定到IP和端口的一个二元组,所以TIME_WAIT
等待的时间里,端口一直被占用,无法重新创建socket。
有方法来让TIME_WAIT
状态的socket得到重用,但重用后由于使用相同的ip和端口,这时仍然有可能收到被动关闭端的FIN消息。
客户端主动关闭
现在我们尝试让客户端主动关闭服务器,这次,我们运行客户端,并指定它的全部参数,第一个参数是服务器IP,第二个是服务器端口,第三个是使用的本地端口,这里我们使用12345:
客户端由于可以输入,所以它的关闭显得优雅一些,不用暴力Ctrl C。你可以通过输入$quit
来退出,$
前缀代表目前输入的是一个指令,而非要发送到服务器的字符串。
这次,我们看到,客户端也进入了TIME_WAIT
阶段,并且无法再次使用该端口建立连接。
SO_REUSEADDR选项
SO_REUSEADDR
选项的功能是复用端口,它允许在一个应用程序中把多个套接字绑定到一个端口上。Unix网络编程上是这样说的:
- 当有一个有相同本地地址和端口的socket1处于TIME_WAIT状态时,而你启动的程序的socket2要占用该地址和端口,你的程序就要用到该选项。
- SO_REUSEADDR允许同一port上启动同一服务器的多个实例(多个进程)。但每个实例绑定的IP地址是不能相同的。在有多块网卡或用IP Alias技术的机器可以测试这种情况。
- SO_REUSEADDR允许单个进程绑定相同的端口到多个socket上,但每个socket绑定的ip地址不同。这和2很相似,区别请看UNPv1。
- SO_REUSEADDR允许完全相同的地址和端口的重复绑定。但这只用于UDP的多播,不用于TCP。
这里面的1,就是我们需要的功能。
在文末提供的代码中,只需要去到conf.py
下,修改SO_REUSEADDR_ENABLED = True
即可:
-SO_REUSEADDR_ENABLED = False
+SO_REUSEADDR_ENABLED = True
再次在服务器处于ESTABLISH
状态下关闭服务器,netstat
命令的结果显示Socket处于TIME_WAIT
状态
这时,重新打开服务器:
服务器打开了,并且,netstat
显示有两个socket绑定到该端口,一个处于LISTEN
状态,一个属于TIME_WAIT
状态。
当然,此时的问题就是还可能收到之前的FIN包,但问题不大。
TIME_WAIT的副作用会带来什么问题
对于客户端,TIME_WAIT
的影响不大,客户端通常是随机选择端口号的,而且不会在瞬时使用大量的端口号导致端口号不够用,而TIME_WAIT
还必须等倒计时结束才能复用端口。
对于服务端,TIME_WAIT
可能造成公用端口短时间内无法再次绑定。
考虑使用Nginx做反向代理的情况,Nginx需要作为客户端向上游服务器发起连接,这时它作为客户端。这种情况下,客户端在高并发时很可能出现大量TIME_WAIT
。可以考虑
- HTTP请求头部加入
keep-alive
,即不使用用完即关的短链接,减少关闭次数 - nginx设置
REUSEADDR
来重用处于TIME_WAIT
状态的链接 - nginx设置内核参数,缩减
TIME_WAIT
阶段的等待时间
参考文章:干货分享!服务端 TCP 连接的 TIME_WAIT 问题分析与解决
在非同一台主机上好像SO_REUSEADDR有时也无法在TIMEWAIT情况下重用端口
FIN_WAIT_2状态
FIN_WAIT_2
状态出现在主动关闭方发送了一个FIN并成功从对方接收到了ACK,正在等待对方的一个FIN。这种情况下(并且不是半关闭时)的主动方会等待被动方识别出自己接收到一个文件尾并发送一个FIN。只有这个FIN接到,主动方才会转为TIME_WAIT
状态。否则,主动方将一直处于FIN_WAIT_2
,被动方将一直处于CLOSE_WAIT
状态。
常见的解决办法是给FIN_WAIT_2
状态也设置一个计时器,到时间自动转换状态为CLOSE
。在上面的示例中也看到了,FIN_WAIT_2
阶段确实有一个定时器。
Linux系统可以通过
net.ipv4.tcp_fin_timeout
设置这个值,默认是60秒。
同时打开与关闭
同时打开
两端同时发起SYN,进入SYN_SENT
状态,当接到对端的SYN时,转入SYN_RCVD
状态,再发送SYNACK给对端,两端接收到SYNACK,状态转换为ESTABLISHED
同时关闭
两端同时将状态从ESTABLISHED
转为FIN_WAIT_1
,并向对方发送FIN,在接收到对方的FIN后,状态转为CLOSED
,但是双方还是会交换最后一次ACK,当双方接到彼此的ACK,状态转为TIME_WAIT
。
重置报文段
当发现一个到达的报文段对于相关连接来说是不正确的时,返回RST,用于通知发送方快速拆卸连接。
当端口不存在时
这里尝试连接本地一个根本没有人监听的端口。
会得到RST响应
为了避免任何人都能发布一个伪造的RST来阻止通信,RST的ACK字段必须被打开。就如下图,RST中的ACK是发送方的Seq号+1:
终止一条连接
正常情况下的FIN终止连接需要先将所有排队数据发送后才能发送FIN,RST也可以用来暴力终止连接:
- 任何排队的数据都会被丢弃,RST会立即发送出去
- 接收方会意识到这不是一次正常关闭,会采用另外的操作来关闭
我在客户端还在不停往服务端发送数据的时候直接硬关闭服务器端(Ctrl C),之后产生了一个RST,我也不知道具体是咋产生的以及能不能复现。
然后,RST比较不同的一点是客户端会接收到一个通知(异常),然后连接自动关闭,不像FIN,需要你来在读取时检测读取出的数据,并在发现数据为空时(在Python中是这样的,在其它语言中可能返回-1?),主动调用close
来发送另一个FIN。
半开连接
如果通信的一方还未能告知另一方就意外终止(比如电源被切断),那么另一方如果不发送数据,始终不知道另一方已经关闭了,这种情况叫半开连接。
想象一个远程登陆服务器,如果客户端非正常关闭时服务器端没有数据正在传输,那么它永远也不会知道客户端已经关闭了,这个半开连接将永远保持在系统中。
当崩溃的一方重启后,它会丢失之前所有的连接状态,但对方并不知道这回事,如果对方再发送数据,重启一方会返回一个RST。
代码
# filename: conf.py 作用:提供一些配置项(实际这里只有`SO_REUSEADDR_ENABLED`
from socket import *
# REUSEADDR选项是否开启,默认关闭
SO_REUSEADDR_ENABLED = False
"""
用来将布尔值转换成0(False)或1(True)
"""
def b2bin(bol):
return 1 if bol else 0
"""
传入socket后自动根据指定的配置项来配置
"""
def auto_conf(s):
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, b2bin(SO_REUSEADDR_ENABLED))
# filename: utils.py
import sys
"""
获取命令行第i个参数,若获取失败,返回default
不管返回命令行中的值还是默认值,返回的值都要经过`converter`转换
`converter`是一个策略接口,它定义了如何对返回值进行转换
比如你可能需要从命令行中读取一个参数,但需要以int形式来接受它,`converter`可以是一个对命令行参数转换成int的函数
`converter`抛出异常时,若是在转换命令行参数时抛出,那么会使用默认值,若是在转换默认值时抛出,这个异常不会被捕获
"""
def get_cmd_param_or_default(i, default, converter = lambda x:x):
try:
return converter(sys.argv[i])
except:
return converter(default)
"""
下面是三个输出方法,不用管它们的实现细节
pinfo输出一个正常消息
perror输出一个错误消息
"""
def _print_auto_add_prefix(msg, addr, print_func):
if addr == None:
print_func("> " + msg)
else:
print_func(str(addr) + " > " + msg)
def pinfo(msg, addr = None):
_print_auto_add_prefix(str(msg), addr, lambda m:print(m))
def perror(msg, addr = None):
_print_auto_add_prefix(str(msg), addr, lambda m:print(m))
# filename: start.py 作用:用于开启服务器端
from conf import *
from utils import *
from socket import *
# 默认端口
DEFAULT_PORT = 8080
# 最大等待连接数
MAX_WAITING_CONN_CNT = 128
# 读缓冲区大小
READ_BUFFER_SIZE = 1024
port = get_cmd_param_or_default(1, 8080, lambda x: int(x))
pinfo("use port => " + str(port))
try:
server = socket(AF_INET, SOCK_STREAM)
auto_conf(server)
server.bind(("0.0.0.0", port))
server.listen(MAX_WAITING_CONN_CNT)
while True:
pinfo("=============================== <")
pinfo("accepting new connection...")
client, cl_addr = server.accept()
pinfo("established connection to client => " + str(cl_addr))
while True:
try:
recv_data = client.recv(READ_BUFFER_SIZE)
if not recv_data or len(recv_data) == 0:
pinfo("rcvd an empty msg, maybe the connection closed by the client!", cl_addr)
break
pinfo(recv_data.decode("utf-8"), cl_addr)
client.send(recv_data)
except Exception as e:
perror(e, cl_addr)
pinfo("closing connection...", cl_addr);
client.close();
pinfo("connection closed!", cl_addr);
except Exception as e:
perror(e)
# filename: cli.py 作用:用于启动一个简单的客户端
from conf import *
from utils import *
from socket import *
import random
# 读缓冲区大小
RECV_BUFFER_SIZE = 1024
# 随机端口上界
RANDOM_PORT_UPPER = 60000
# 随机端口下界
RANDOM_PORT_LOEST = 20000
addr = get_cmd_param_or_default(1, "localhost")
port = get_cmd_param_or_default(2, 8080, lambda x: int(x))
lc_port = get_cmd_param_or_default(3, random.randint(RANDOM_PORT_LOEST, RANDOM_PORT_UPPER), lambda x:int(x))
server_addr = (addr, port)
pinfo("Connecting to " + str(server_addr) + " using local port " + str(lc_port))
cli = socket(AF_INET, SOCK_STREAM)
auto_conf(cli)
cli.bind(("0.0.0.0", lc_port))
cli.connect(server_addr)
pinfo("Connected!", server_addr)
def parse_cmd(msg):
cmd = msg[1:]
if cmd == 'quit':
return True
else:
perror("Unkown command [" + cmd + "]")
while True:
try:
msg = input(str(server_addr) + " > ")
if msg.startswith("$"):
needbreak = parse_cmd(msg)
if needbreak:
break
continue
cli.send(msg.encode("utf-8"))
recv_data = cli.recv(RECV_BUFFER_SIZE)
pinfo(recv_data.decode("utf-8"))
if not recv_data or len(recv_data) == 0:
perror("rcvd an empty message from server, maybe it closed the connection", server_addr)
break
except Exception as e:
perror(e, server_addr)
pinfo("closing the connection", server_addr)
cli.close()
pinfo("connection closed", server_addr)