尽管文件描述符已关闭,但仍CLOSE_WAIT TCP 状态

CLOSE_WAIT TCP states despite closed file descriptors

提问人:nh2 提问时间:10/25/2023 最后编辑:nh2 更新时间:11/4/2023 访问量:219

问:

我的 Linux 服务器应用程序侦听端口并使用 正确关闭其所有文件描述符 (FD)。8000close()

尽管如此,我有时会观察到多达 3000 个 TCP 连接:CLOSE_WAIT

# netstat -antp | grep CLOSE_WAIT
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp      149      0 127.0.0.1:8000          127.0.0.1:49630         CLOSE_WAIT  -                   
tcp      236      0 127.0.0.1:8000          127.0.0.1:48440         CLOSE_WAIT  -                   
tcp      251      0 127.0.0.1:8000          127.0.0.1:41748         CLOSE_WAIT  -                   
tcp      149      0 127.0.0.1:8000          127.0.0.1:46064         CLOSE_WAIT  -                   
tcp      251      0 127.0.0.1:8000          127.0.0.1:56654         CLOSE_WAIT  -                   
tcp      251      0 127.0.0.1:8000          127.0.0.1:37502         CLOSE_WAIT  -                   
tcp      251      0 127.0.0.1:8000          127.0.0.1:56976         CLOSE_WAIT  -                   
tcp      251      0 127.0.0.1:8000          127.0.0.1:36416         CLOSE_WAIT  -                   
... ~3000 more of these ...

(netstat正在运行,因此不会丢失数据。root

我知道当服务器应用程序没有连接到套接字的 FD 时会发生这种情况。这在 RFC793 的 TCP 状态图中进行了解释(例如更好的渲染,例如这里)以及例如 https://blog.cloudflare.com/this-is-strictly-a-violation-of-the-tcp-specification/CLOSE_WAITclose()

但我知道我的服务器是正确的,因为在服务器上进程只显示 90 个打开的 FD,而不是 3000 个。close()ls -1 "/proc/$(pidof myserver)/fd" | wc -l

正确关闭的进一步证据是,如上所示,没有列出与端口关联的程序(请参阅)。netstat -pCLOSE_WAIT -

一些其他未解决的案例的集合,其中显示没有相关过程:CLOSE_WAIT -

所以问题来了:

怎么会存在比开放套接字 FD 更多的CLOSE_WAIT状态?

为什么 Linux 在 /proc/$PID/fdnetstat 的输出上自相矛盾,CLOSE_WAIT必须有一个未关闭的套接字 (FD) 与之关联,怎么可能发生CLOSE_WAIT

Linux 套接字 TCP

评论

1赞 user207421 10/25/2023
你的服务器做任何分叉吗?如果是这样,父进程和子进程是否都关闭接受的套接字?
1赞 user207421 10/25/2023
@CarloArenas 这是不正确的。如果客户没有关闭它的末端,他就永远无法进入CLOSE_WAIT:连接将保持建立。
1赞 user207421 10/25/2023
@CarloArenas 除非客户端关闭它而服务器尚未关闭,否则无法CLOSE_WAIT它。
1赞 nh2 10/26/2023
@GilHamilton 是的,@user207421“连接将保持建立”的说法是不准确的,但句子的前一部分(也是更相关的)部分,“如果客户没有关闭它的目的,他就永远无法进入CLOSE_WAIT”,是正确的。因此,它确实用“是”回答了@CarloArenas的“你确定客户端也关闭了他们的连接端吗?”的问题:我们在服务器上观察,所以客户端一定已经关闭。CLOSE_WAIT
2赞 Gil Hamilton 10/26/2023
不好意思。我正在看和读.我会展示自己。CLOSE_WAITTIME_WAIT

答:

0赞 Carlo Arenas 10/29/2023 #1

在多种情况下,可以观察到这种情况,但如果没有关于服务器实现的细节,就不可能有效地回答这个问题。

首先,让我们澄清一下,你不能指望在 /proc/$PID/fd 中看到与你的进程无关的连接的文件句柄(这就是在显示输出的最后一列中没有你的进程 ID 所指示的),所以根本没有不一致,你可能只是对这些套接字在某个时间根据端口号与你的应用程序相关联的事实感到困惑使用过,但显然不是当前正在运行的进程,或者至少不是它,如下面的案例 1 示例所示:accept()

root@debian:~# ls -al /proc/1157/fd
total 0
dr-x------ 2 carenas carenas  0 Oct 29 22:39 .
dr-xr-xr-x 9 carenas carenas  0 Oct 29 22:39 ..
lrwx------ 1 carenas carenas 64 Oct 29 22:39 0 -> /dev/pts/0
lrwx------ 1 carenas carenas 64 Oct 29 22:39 1 -> /dev/pts/0
lrwx------ 1 carenas carenas 64 Oct 29 22:39 2 -> /dev/pts/0
lrwx------ 1 carenas carenas 64 Oct 29 22:39 3 -> 'anon_inode:[eventpoll]'
lrwx------ 1 carenas carenas 64 Oct 29 22:39 4 -> 'socket:[23882]'
root@debian:~# ls -al /proc/1199/fd
total 0
dr-x------ 2 carenas carenas  0 Oct 29 22:51 .
dr-xr-xr-x 9 carenas carenas  0 Oct 29 22:51 ..
lrwx------ 1 carenas carenas 64 Oct 29 22:51 0 -> /dev/pts/0
lrwx------ 1 carenas carenas 64 Oct 29 22:51 1 -> /dev/pts/0
lrwx------ 1 carenas carenas 64 Oct 29 22:51 2 -> /dev/pts/0
lrwx------ 1 carenas carenas 64 Oct 29 22:51 3 -> 'anon_inode:[eventpoll]'
lrwx------ 1 carenas carenas 64 Oct 29 22:51 4 -> 'socket:[23882]'
lrwx------ 1 carenas carenas 64 Oct 29 22:51 5 -> 'socket:[22177]'
root@debian:~# netstat -antpe | grep 7777
tcp        2      0 0.0.0.0:7777            0.0.0.0:*               LISTEN      1000       23882      1157/./fork         
tcp        0      0 127.0.0.1:60574         127.0.0.1:7777          ESTABLISHED 1000       21053      1198/perl           
tcp        0      0 127.0.0.1:7777          127.0.0.1:60574         ESTABLISHED 1000       22177      1199/./fork         
tcp        5      0 127.0.0.1:7777          127.0.0.1:59690         CLOSE_WAIT  0          0          -                   
tcp        5      0 127.0.0.1:7777          127.0.0.1:44830         CLOSE_WAIT  0          0          - 

其次,您是对的,如果您的应用程序这样做,那么您不应该在CLOSE_WAIT中看到套接字,但您的应用程序可能没有这样做,因为:close()

  1. 这些连接在应用程序能够处理它们之前就被卡住了,因此在应用程序有机会这样做之前,客户端关闭了套接字。
  2. 由于涉及多个线程、进程甚至信号处理程序,FD 可能已被更改,因此除非检查 close() 的返回状态,否则它可能根本无效,如果您的应用程序依赖于操作系统关闭所有文件句柄,甚至可能不为 SIGCHLD 提供默认信号处理程序,这可能会导致进程成为僵尸。exit()

案例 2 的一个有趣的示例(原始示例),但由于共享套接字,它们无论如何都是相关的,如下所示:

# netstat -antpe | grep 7777
tcp        3      0 0.0.0.0:7777            0.0.0.0:*               LISTEN      1000       23882      1157/./fork         
tcp        1      0 127.0.0.1:7777          127.0.0.1:37786         CLOSE_WAIT  0          0          -                   
tcp        1      0 127.0.0.1:7777          127.0.0.1:34022         CLOSE_WAIT  0          0          -                   
tcp        4      0 127.0.0.1:7777          127.0.0.1:60150         ESTABLISHED 0          0          -                   
tcp        0      0 127.0.0.1:60150         127.0.0.1:7777          ESTABLISHED 1000       21228      1406/perl      
# netstat -antpe | grep 7777
tcp        0      0 0.0.0.0:7777            0.0.0.0:*               LISTEN      1000       23882      1157/./fork         
tcp        0      0 127.0.0.1:7777          127.0.0.1:60150         ESTABLISHED 1000       24277      1524/./fork         
tcp        0      0 127.0.0.1:60150         127.0.0.1:7777          ESTABLISHED 1000       21228      1406/perl      

在这里,客户端得到了一个无响应的服务器并被挂起,直到很久以后,当无响应的服务器最终被收割时,它连接到一个没有 pid 的套接字,直到最终将其连接“正式”移动到另一个服务器进程。

最后,工作 fd 与CLOSE_WAIT中的数量之间的极度不平衡意味着您的应用程序存在严重问题,接收队列中的高数字可能表明服务器变得无响应,因此这可能是客户端主动关闭连接并通过重试使问题变得更糟的 sideffect。

确保进程之间没有共享套接字,并且没有任何东西会阻塞侦听器套接字(例如:一些垃圾回收)可能会有所帮助。如果这只是一个容量问题,那么缩短侦听队列和水平扩展可能是最好的选择。

评论

0赞 user207421 10/29/2023
(1)据我所知毫无意义。(2)不正确。ACK 进入插槽,而不是 FD。所有过程都会“看到它”的影响。(3)也是不正确的:真的没有任何要检查的。它可能失败的唯一方式是 EBADF 或延迟超时,这很少使用。close()return state
0赞 Carlo Arenas 10/29/2023
@user207421我试图避免使我的答案过于复杂,但是 (1) 适用于侦听器套接字没有响应的情况,如果有兴趣,我有代码可以清楚地显示这一点,但我想避免讨论可能不相关的错误,我们仍然不知道服务器中出现问题的代码是什么样子的。(2) 如果应用程序共享 fd(包括 epoll 队列),则这适用,如果 EPOLLONESHOT,并非所有应用程序都可以看到其效果。(3) 完全适用于 EBADF,如果 fd 由所有线程共享,例如,在其他线程关闭它后,它可能已更改为 -1。
1赞 user207421 10/29/2023
(1) 如果客户端在服务器接受连接之前关闭了连接,则 TCP 会关闭该连接并从积压队列中删除该连接。服务器应用程序永远不会看到它。(2)“来自客户端的 ACK”是没有意义的,并且 ACK 不能决定投票行为。您的 (3) 一旦解释即有效。这里的问题肯定是服务器中的套接字泄漏,无论 OP 怎么说或想什么。
0赞 nh2 10/29/2023
@CarloArenas 您能否详细说明一下您是如何在输出中没有关联进程的情况下创建条目的?我还没有设法用你的脚本做到这一点,但似乎你做到了!CLOSE-WAITss
1赞 Carlo Arenas 11/4/2023
@nh2:这是“情况 1”,正如我所说,几乎可以用任何版本的服务器重现(尽管我喜欢你的版本),为了“改善”这种情况,您可以更改客户端以强制关闭连接,如共享客户端参数所示。nc--connect=reset
0赞 nh2 11/4/2023 #2

我想通了:

当在 Linux 内核的 listen() 积压工作队列中等待的客户端在用户空间应用程序接受 () 之前断开连接时,就会发生没有关联进程的CLOSE_WAIT状态。

这很容易用 重现,见下文。netcat

关于这个问题的现有评论摘要:

  • 我在上述评论中的直觉是正确的,如果没有显示任何关联的进程,它必须只涉及内核,而不涉及我的程序的文件描述符。
  • 评论者的建议是不正确的,这些无进程的 s 是由服务器忘记调用 创建的。CLOSE_WAITclose()
  • 评论者的建议是不正确的,内核的显示在某种程度上被窃听了。/proc/<pid>/fd

复制nc

简短的再现摘要(阅读下面的解释):

nc -l 1234
nc localhost 1234
nc localhost 1234         # press Ctrl+C here
ss -tapn 'sport = :1234'  # shows process-less `CLOSE-WAIT`
1 号航站楼(“服务器”):
nc -v -l 127.0.0.1 1234

这将创建一个套接字(并使用队列长度调用它,并调用以等待连接。listen(..., 1)backlog1accept()

(可以用 验证。strace

旁白:

  • 这个队列被称为“接受队列”(关于它的很深入的文章),包含接受()连接,但我在这里将其称为队列,因为它的大小由 决定,它的生存期由 .listen()listen()listen()
  • Linux(在我的情况下)实际上将实际队列大小设置为 ,因此队列实际上有 2 个插槽。 我还没有研究过为什么会这样,但是在这里提到了它,并且我已经通过实验验证了它: 对于一个,3个客户端可以连接到上面的netcat服务器(1个版本,2个在队列中),只有第4个客户端在没有连接的情况下挂起。6.1.51backlog + 1listen(, ...)accept()
  • 传递的大小可以作为以下输出中的字段进行观察。backlogSend-Qss -tlpn 'sport = :1234'

2 号航站楼(“客户 A”):
nc -v -4 127.0.0.1 1234

此连接在服务器上返回。accept()


3 号航站楼(“客户 B”):
nc -v -4 127.0.0.1 1234

此连接将填充服务器队列中的空位。它不是ed。listen()accept()

现在,按 取消这个 . 这将从问题创建状态。Ctrl+CncCLOSE_WAIT

观察输出ss

如果我们在另一个终端中观看时运行上述重现,我们可以观察上述每个步骤后的状态:sudo watch -n1 --exec ss -tapn 'sport = :1234'

# After the server is started, we see the listening socket with a `Recv-Q` of `0`:

State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      1          127.0.0.1:1234      0.0.0.0:*    users:(("nc",pid=3613079,fd=3))

# After client A is started, we see the listening socket with a `Recv-Q` of `0`
# because client A was `accept()`ed:

State  Recv-Q Send-Q Local Address:Port Peer Address:Port  Process
LISTEN 0      1          127.0.0.1:1234      0.0.0.0:*     users:(("nc",pid=3613079,fd=3))
ESTAB  0      0          127.0.0.1:1234    127.0.0.1:52190 users:(("nc",pid=3613079,fd=4))

# After client B is started, we see the listening socket with a `Recv-Q` of `1`
# because client B has not yet been `accept()`ed and is in the queue:

State  Recv-Q Send-Q Local Address:Port Peer Address:Port  Process
LISTEN 1      1          127.0.0.1:1234      0.0.0.0:*     users:(("nc",pid=3613079,fd=3))
ESTAB  0      0          127.0.0.1:1234    127.0.0.1:42420
ESTAB  0      0          127.0.0.1:1234    127.0.0.1:52190 users:(("nc",pid=3613079,fd=4))

在最后一步中,我们已经可以观察到与空的联系。 这是因为连接确实是启用的 -- 但仅限于服务器的内核,而不是服务器进程,因为进程尚未建立连接。ESTABProcessaccept()

内核为我们执行 TCP SYN-ACK-ACK 握手,因此在发生之前在内核中建立了 TCP 连接。accept()

现在,在客户端 B 断开连接后,我们看到 without 进程:CLOSE-WAIT

State      Recv-Q Send-Q Local Address:Port Peer Address:Port  Process
LISTEN     1      1          127.0.0.1:1234      0.0.0.0:*     users:(("nc",pid=3621113,fd=3))
ESTAB      0      0          127.0.0.1:1234    127.0.0.1:52628 users:(("nc",pid=3621113,fd=4))
CLOSE-WAIT 1      0          127.0.0.1:1234    127.0.0.1:45096

并且仍然,所以断开连接仍然在内核队列中!Recv-Q1

观察输出netstat

同:netstatsudo watch -n1 'netstat -antpe | grep 1234'

我们看到更多的行,因为无法进行提供的方便的筛选,所以我们也看到了客户端套接字:netstat'sport = :1234'ss

# After the server is started:

Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name
tcp        0      0 127.0.0.1:1234          0.0.0.0:*               LISTEN      1000       62603813   3621113/nc

# After client A is started:

Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name
tcp        0      0 127.0.0.1:1234          0.0.0.0:*               LISTEN      1000       62603813   3621113/nc
tcp        0      0 127.0.0.1:52628         127.0.0.1:1234          ESTABLISHED 1000       62608860   3621978/nc
tcp        0      0 127.0.0.1:1234          127.0.0.1:52628         ESTABLISHED 1000       62603814   3621113/nc

# After client B is started:

Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name
tcp        1      0 127.0.0.1:1234          0.0.0.0:*               LISTEN      1000       62603813   3621113/nc
tcp        0      0 127.0.0.1:52628         127.0.0.1:1234          ESTABLISHED 1000       62608860   3621978/nc
tcp        0      0 127.0.0.1:1234          127.0.0.1:52628         ESTABLISHED 1000       62603814   3621113/nc
tcp        0      0 127.0.0.1:45096         127.0.0.1:1234          ESTABLISHED 1000       62609455   3622106/nc
tcp        0      0 127.0.0.1:1234          127.0.0.1:45096         ESTABLISHED 0          0          -

同样,在这里我们首先寻求连接。-PID/Program nameESTABLISHED

这也回答了这个问题:

在客户端 B 断开连接后:

Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name
tcp        1      0 127.0.0.1:1234          0.0.0.0:*               LISTEN      1000       62603813   3621113/nc
tcp        0      0 127.0.0.1:52628         127.0.0.1:1234          ESTABLISHED 1000       62608860   3621978/nc
tcp        0      0 127.0.0.1:1234          127.0.0.1:52628         ESTABLISHED 1000       62603814   3621113/nc
tcp        0      0 127.0.0.1:45096         127.0.0.1:1234          FIN_WAIT2   0          0          -
tcp        1      0 127.0.0.1:1234          127.0.0.1:45096         CLOSE_WAIT  0          0          -

这就是我们的流程。CLOSE_WAIT-

使用 Python 进行更简约的重现

由于可能会随着时间的推移而改变它的确切功能(例如,系统调用它),因此在 Python 中有一个类似的 TCP 服务器,可以更加清楚地了解服务器上发生的事情:nc

#!/usr/bin/env python3

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:  # TCP
  s.bind(('127.0.0.1', 1234))
  s.listen(1)
  while True:
    conn, addr = s.accept()

    print(f"Got client {addr} as FD {conn.fileno()}")
    action = input("Press enter to close the current connection and call accept() again, or enter 'close-socket' to close the entire socket... ").strip()

    if action == "close-socket":
      s.close()
      input("Socket closed, press enter to terminate... ")
      exit()
    else:
      conn.close()

为什么存在无过程以及如何摆脱它CLOSE_WAIT

内核将断开连接的连接保留在 定义的队列中,而没有关联的进程,直到:CLOSE_WAITlisten()

  • 服务器的套接字由 返回,或者close()listen()
  • 服务器的连接已断开连接。accept()

接受已经断开连接的连接将成功:将向服务器提供 FD。 这会将 without 进程转换为 with 进程。 服务器现在可以调用 FD 来关闭连接并解析状态。accept()CLOSE_WAITCLOSE_WAITclose()CLOSE_WAIT

调用返回的套接字会破坏整个内核队列,从而立即消失。close()listen()CLOSE_WAIT

总结

  • 无进程是进入队列并被发送 TCP 的另一方终止的连接,然后在我们的进程之前从队列中取出。CLOSE_WAITlisten()FINlisten()accept()
  • 它们生活在内核中。
  • 它们没有与之关联的文件描述符 (FD),因为尚未为它们分配 FD。accept()
  • 这就解释了为什么 s 可以多于文件描述符。CLOSE_WAIT

3000 个这样的无进程问题表明服务器没有连接。 这可能是由于错误,或者因为服务器进程正忙于做其他事情(例如垃圾回收或运行其他一些功能)。 因此,队列已满。 服务器必须已调用或更高。确实,我可以看到在. 当排队的客户端最终由于超时而放弃时,排队的连接将成为排队的连接。CLOSE_WAITaccept()listen(, 3000)Send-Q4096ss -tlpn 'sport = :8000'ESTABLISHEDCLOSE_WAIT

因此,解决多达 3000 个连接的问题的下一步应该是弄清楚服务器停止调用 的原因。CLOSE_WAITaccept()

评论

0赞 nh2 11/4/2023
非常感谢@CarloArenas的评论和分享的示例代码!它帮助我找到了最小的复制品。