如何避免无文件描述符的ESTABLISHED状态幽灵TCP连接
我有一个多线程测试程序,在本地主机上创建大量TCP自连接。遇到的问题是,部分主动发起连接的套接字虽无关联文件描述符,却仍处于ESTABLISHED状态。以下是可复现的C++最小示例:
#include <cerrno> #include <print> #include <thread> #include <netinet/in.h> #include <poll.h> #include <stdint.h> #include <sys/socket.h> void do_accept(int lfd) { sockaddr_in sin; socklen_t sinlen = sizeof(sin); int fd = accept(lfd, (sockaddr *) &sin, &sinlen); close(lfd); char buf[128]; errno = 0; while (read(fd, buf, sizeof(buf)) > 0) ; close(fd); } void do_poll(int cfd, int wake) { pollfd pfds[2]; pfds[0].fd = cfd; pfds[0].events = POLLIN; pfds[1].fd = wake; pfds[1].events = POLLIN; poll(pfds, 2, -1); } int main() { int lfd = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in sin{}; sin.sin_family = AF_INET; sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK); bind(lfd, (sockaddr *)&sin, sizeof(sin)); listen(lfd, 5); socklen_t sinlen = sizeof(sin); getsockname(lfd, (sockaddr *)&sin, &sinlen); uint16_t port = ntohs(sin.sin_port); std::println("listening on port {}", port); std::thread acceptor(do_accept, lfd); int cfd = socket(AF_INET, SOCK_STREAM, 0); if (connect(cfd, (sockaddr *)&sin, sinlen)) { perror("connect"); exit(1); } int selfpipe[2]; pipe(selfpipe); std::thread poller(do_poll, cfd, selfpipe[0]); poll(nullptr, 0, 1000); //shutdown(cfd, SHUT_WR); // Works if you uncomment close(cfd); // Would like this to send EOF acceptor.join(); // Would like this to return std::println("Will never get this far"); write(selfpipe[1], "", 1); poller.join(); }
该程序会创建无关联文件描述符的“幽灵套接字”,可通过ss和lsof命令验证,例如:
$ lsof -c closepoll -nPa -i tcp -Tqs COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME closepoll 918275 user 5u IPv4 8515786 0t0 TCP 127.0.0.1:41503->127.0.0.1:57248 (ESTABLISHED QR=0 QS=0) $ ss -antp "sport 57248 or dport 57248" State Recv-Q Send-Q Local Address:Port Peer Address:Port Process ESTAB 0 0 127.0.0.1:57248 127.0.0.1:41503 ESTAB 0 0 127.0.0.1:41503 127.0.0.1:57248 users:((closepoll,pid=918275,fd=5))
尽管处于ESTABLISHED状态,但本地端口57248并无对应的文件描述符。当最后一个文件描述符关闭时,它难道不应该进入FIN_WAIT_1状态吗?我的问题是:是什么导致这些无关联文件描述符却仍处于ESTABLISHED状态的幽灵TCP套接字?如何避免这个问题?
更新 请注意,即使检查所有系统调用错误并立即终止程序,代码行为仍相同。更重要的是,无论系统调用是否出错,运行上述代码都会产生幽灵TCP套接字。因此我的问题依旧:是什么产生了这类幽灵TCP套接字,以及如何避免?
原因分析
close与shutdown的核心行为差异close操作的对象是文件描述符,而非TCP连接本身。只有当某个套接字的所有关联文件描述符都被关闭时,内核才会触发TCP连接的关闭流程(发送FIN等)。如果有其他线程仍在引用该文件描述符的数值(比如你的poller线程还在对已关闭的fd执行poll操作),内核可能会延迟启动关闭流程,导致TCP连接停留在ESTABLISHED状态。而
shutdown操作的对象是TCP连接,调用shutdown(cfd, SHUT_WR)会立即关闭连接的写方向,向对端发送FIN,无论有多少个文件描述符指向该套接字。这也是为什么你注释掉的那行代码能解决问题。TCP连接生命周期与进程文件描述符解耦
内核会独立维护TCP连接的状态,直到完成四次握手或超时,即使进程已经关闭了所有关联的文件描述符。如果连接的一端未发送FIN,另一端会一直处于ESTABLISHED状态,直到内核的超时机制触发。在你的例子中,客户端未发送FIN,导致服务器端的read一直阻塞,连接状态无法推进。
解决方法
方法1:显式调用shutdown触发FIN发送
在关闭客户端套接字的文件描述符前,先调用shutdown关闭写方向,强制发送FIN:
shutdown(cfd, SHUT_WR); // 立即向服务器端发送FIN close(cfd);
这样服务器端的read会返回0,结束循环并关闭连接,accept线程正常退出,主线程的join能顺利返回。
方法2:先终止所有引用该fd的线程
在关闭fd前,先通过selfpipe唤醒poller线程,确保没有线程再引用该fd,再执行close:
write(selfpipe[1], "", 1); // 唤醒poller线程 poller.join(); // 等待poller线程退出 close(cfd); // 此时关闭fd会触发FIN发送 acceptor.join();
这种方式能保证close操作时,套接字的所有文件描述符引用都已释放,内核会立即启动TCP关闭流程。
方法3:设置SO_LINGER选项(谨慎使用)
通过SO_LINGER选项让close立即发送RST(而非FIN),强制重置连接:
struct linger l{}; l.l_onoff = 1; l.l_linger = 0; // 立即关闭,发送RST setsockopt(cfd, SOL_SOCKET, SO_LINGER, &l, sizeof(l)); close(cfd);
注意:这种方式会导致连接被强制重置,可能丢失未发送的数据,仅适用于不需要优雅关闭的场景。
内容的提问来源于stack exchange,提问作者user3188445

