Loading

TCP四次挥手以及TIME_WAIT状态带来的问题

TCP允许连接的任意一方对连接发起关闭,下面,我们把发起关闭的一方称为主动关闭端,而接收到关闭请求后配合关闭的一端称为被动关闭端

TCP允许半关闭,即主动端发起关闭后被动端可以不关闭,主动端仍然接受被动端的数据,但不返回任何数据。
TCP允许同时关闭,即两端同时发起关闭请求,这种情况下没有主动和被动端。

上面两种极端情况不是本文所讨论的范围,但还是要在开头做一个简单说明

本文会使用基于Python3的TCP回声服务器和客户端程序,代码在本文的最后位置

四次挥手

img

TCP的关闭需要四次挥手

  1. 主动关闭端发送FIN包代表它希望关闭连接
  2. 被动关闭方接收到FIN包后发送ACK,通知主动方已经接到了它的FIN
  3. 被动关闭方发送FIN包表示它也希望关闭连接
  4. 主动方接收到来自被动方的FIN,并回复它ACK

三次挥手

为什么握手的时候是三次?因为握手过程中被动端的SYNACK是合并的。所以整个过程是SYN -> SYNACK -> ACK,而挥手过程中的FINACK没有合并,所以整个过程是FIN -> ACK -> FIN -> ACK

而由于ACK占用的长度是死的,无论你发的是不是ACK消息,它都在头部字段里占用空间,所以,大部分系统都可能会在允许时合并ACK和其它消息,而不是发送单独的ACK。所以实践中FINACK也有可能合并,这样,挥手就变成了三次:

img

四次挥手中的连接状态

TCP的一个连接正常关闭是主动被动双方共同努力的后果。

主动关闭端状态

主动关闭端一般是客户端,但任何一端都可以先行发起关闭,来当这个主动关闭端。

下图是主动关闭端的状态转换,我们不用看该图右侧的SYN_SENTESTABLISHED

img

  1. 主动端决定关闭,发送FIN,进入FIN_WAIT_1状态。这个状态下,主动端在等待被动端的ACK
  2. 主动端接收到了被动端的ACK,进入FIN_WAIT_2状态。这个状态下,主动端在等待被动端的FIN
  3. 主动端接收到了被动端的FIN,它需要返回ACK,此时进入TIME_WAIT状态
  4. 等待30秒(这只是个普遍的值,这个值可以被修改)后,进入CLOSED阶段

被动关闭端状态

被动关闭端一般是服务器端,但服务器也完全可以主动发起关闭。

下图是被动关闭端的状态转换,我们同样不看右侧。

img

  1. 被动端在正常连接时(ESTABLISHED)接收主动端的FIN,会发送ACK并转入CLOSE_WAIT阶段
  2. 被动端要发送自己的FIN给主动端,表明自己也要关闭,这时进入LAST_ACK阶段
  3. 被动端等待主动端的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!,服务器将同样的内容返回给我们,同时,我们也看到服务器这边的日志也显示了建立连接和接收到消息:

img

这些都不是关键,关键是,我们需要让一端主动关闭连接,然后看看TIME_WAIT带来的副作用

服务端主动关闭

现在,我们在服务端处于ESTABLISH的情况下直接关闭服务端(使用Ctrl+C)

img

然后,我们查看8080端口的占用状态,这里显示它正处于FIN_WAIT2状态,并且需要等待57秒,这是因为客户端还没有发起它的FIN

img

你需要关闭客户端:

  1. 在客户端中输入$quit命令
  2. 随便发送一条消息,客户端会检测到连接已经无法使用,并调用close关闭连接
  3. Ctrl+C

现在,服务器变成了TIME_WAIT状态,并且当我们再次尝试开启服务器时,会发现端口已经被占用。

img

这就是TIME_WAIT状态的副作用,由于TCP socket绑定到IP和端口的一个二元组,所以TIME_WAIT等待的时间里,端口一直被占用,无法重新创建socket

有方法来让TIME_WAIT状态的socket得到重用,但重用后由于使用相同的ip和端口,这时仍然有可能收到被动关闭端的FIN消息。

客户端主动关闭

现在我们尝试让客户端主动关闭服务器,这次,我们运行客户端,并指定它的全部参数,第一个参数是服务器IP,第二个是服务器端口,第三个是使用的本地端口,这里我们使用12345:

img

客户端由于可以输入,所以它的关闭显得优雅一些,不用暴力Ctrl C。你可以通过输入$quit来退出,$前缀代表目前输入的是一个指令,而非要发送到服务器的字符串。

img

这次,我们看到,客户端也进入了TIME_WAIT阶段,并且无法再次使用该端口建立连接。

SO_REUSEADDR选项

SO_REUSEADDR选项的功能是复用端口,它允许在一个应用程序中把多个套接字绑定到一个端口上。Unix网络编程上是这样说的:

  1. 当有一个有相同本地地址和端口的socket1处于TIME_WAIT状态时,而你启动的程序的socket2要占用该地址和端口,你的程序就要用到该选项。
  2. SO_REUSEADDR允许同一port上启动同一服务器的多个实例(多个进程)。但每个实例绑定的IP地址是不能相同的。在有多块网卡或用IP Alias技术的机器可以测试这种情况。
  3. SO_REUSEADDR允许单个进程绑定相同的端口到多个socket上,但每个socket绑定的ip地址不同。这和2很相似,区别请看UNPv1。
  4. SO_REUSEADDR允许完全相同的地址和端口的重复绑定。但这只用于UDP的多播,不用于TCP。

这里面的1,就是我们需要的功能。

在文末提供的代码中,只需要去到conf.py下,修改SO_REUSEADDR_ENABLED = True即可:

-SO_REUSEADDR_ENABLED = False
+SO_REUSEADDR_ENABLED = True

再次在服务器处于ESTABLISH状态下关闭服务器,netstat命令的结果显示Socket处于TIME_WAIT状态

img

这时,重新打开服务器:

img

服务器打开了,并且,netstat显示有两个socket绑定到该端口,一个处于LISTEN状态,一个属于TIME_WAIT状态。

img

当然,此时的问题就是还可能收到之前的FIN包,但问题不大。

TIME_WAIT的副作用会带来什么问题

对于客户端,TIME_WAIT的影响不大,客户端通常是随机选择端口号的,而且不会在瞬时使用大量的端口号导致端口号不够用,而TIME_WAIT还必须等倒计时结束才能复用端口。

对于服务端,TIME_WAIT可能造成公用端口短时间内无法再次绑定。

考虑使用Nginx做反向代理的情况,Nginx需要作为客户端向上游服务器发起连接,这时它作为客户端。这种情况下,客户端在高并发时很可能出现大量TIME_WAIT。可以考虑

  1. HTTP请求头部加入keep-alive,即不使用用完即关的短链接,减少关闭次数
  2. nginx设置REUSEADDR来重用处于TIME_WAIT状态的链接
  3. 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

img

同时关闭

两端同时将状态从ESTABLISHED转为FIN_WAIT_1,并向对方发送FIN,在接收到对方的FIN后,状态转为CLOSED,但是双方还是会交换最后一次ACK,当双方接到彼此的ACK,状态转为TIME_WAIT

img

重置报文段

当发现一个到达的报文段对于相关连接来说是不正确的时,返回RST,用于通知发送方快速拆卸连接。

当端口不存在时

这里尝试连接本地一个根本没有人监听的端口。

img

会得到RST响应

img

为了避免任何人都能发布一个伪造的RST来阻止通信,RST的ACK字段必须被打开。就如下图,RST中的ACK是发送方的Seq号+1:

img

终止一条连接

正常情况下的FIN终止连接需要先将所有排队数据发送后才能发送FIN,RST也可以用来暴力终止连接:

  1. 任何排队的数据都会被丢弃,RST会立即发送出去
  2. 接收方会意识到这不是一次正常关闭,会采用另外的操作来关闭

我在客户端还在不停往服务端发送数据的时候直接硬关闭服务器端(Ctrl C),之后产生了一个RST,我也不知道具体是咋产生的以及能不能复现。

img

然后,RST比较不同的一点是客户端会接收到一个通知(异常),然后连接自动关闭,不像FIN,需要你来在读取时检测读取出的数据,并在发现数据为空时(在Python中是这样的,在其它语言中可能返回-1?),主动调用close来发送另一个FIN。

img

半开连接

如果通信的一方还未能告知另一方就意外终止(比如电源被切断),那么另一方如果不发送数据,始终不知道另一方已经关闭了,这种情况叫半开连接

想象一个远程登陆服务器,如果客户端非正常关闭时服务器端没有数据正在传输,那么它永远也不会知道客户端已经关闭了,这个半开连接将永远保持在系统中。

当崩溃的一方重启后,它会丢失之前所有的连接状态,但对方并不知道这回事,如果对方再发送数据,重启一方会返回一个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)
posted @ 2022-07-18 14:04  yudoge  阅读(355)  评论(0编辑  收藏  举报