close与shutdown:Linux网络编程中的连接关闭与半关闭详解
2026/9/23 2:12:56 网站建设 项目流程

在实际的Linux网络编程里,close和shutdown这两个函数是我见过被混淆得最厉害的一对。很多人写长连接服务,连接处理完了直接close一把梭,结果对端还在发数据,自己这边莫名其妙收到RST;也有不少人试图用shutdown去"关闭"连接,却发现fd还开着,资源一直不释放。这两个函数看似都在"关连接",但一个管的是文件描述符的生死,一个管的是TCP连接本身的收发通道,弄不清楚这层区别,线上出问题的时候排查方向都会跑偏。这篇文章我把这两个函数的底层行为、协议栈表现、典型应用场景和踩坑经验一次讲透。

1. 系统调用层面的第一层差异:描述符与连接的管理归属

先明确一个最基础的事实:close和shutdown操作的对象层级完全不同。close操作的是"文件描述符",shutdown操作的才是"TCP连接"本身。

在Linux里,socket fd本质上是一个文件描述符,内核通过它找到对应的socket结构体。一个TCP连接在极端情况下可能被多个fd引用——比如用fork派生子进程,父子进程共享同一个socket fd,这时候这个fd在内核里的引用计数是2。close的行为是"减少一次引用计数,计数归零时才真正释放fd",而shutdown是"直接作用于连接,不管fd被多少进程引用"。

我用一段代码说明这个差异有多实际。假设你写了一个多进程模型的服务端,accept之后fork子进程去处理这条连接:

int client_fd = accept(listen_fd, ...); pid_t pid = fork(); if (pid == 0) { // 子进程处理业务 handle_client(client_fd); close(client_fd); // 子进程关闭自己的副本 exit(0); } else { // 父进程如果直接close,fd引用计数从2降到1,连接并不会关闭 close(client_fd); }

这段代码里,如果父进程不close,子进程即使close了,fd的引用计数也只是从2降到1,socket不会被真正释放。这就是经典的"连接泄漏"场景之一。反过来,如果子进程只想终止发送数据、但还想继续接收父进程从别处写来的数据,close做不到——close在引用计数归零后会直接把整个socket释放掉,收发都没了。这时候必须用shutdown。

再往细看,close并不保证"优雅关闭"。一个经常被忽视的点是:当close被调用时,如果接收缓冲区里还有未读取的数据,Linux会直接发送RST而不是FIN,对端表现就是connection reset。这个行为不是所有程序员都知道的,很多人以为close就是发FIN,其实不完全对。

shutdown这边,有三个模式:

模式行为连接状态
SHUT_RD关闭读通道,后续收到的数据直接丢弃,缓冲区的数据也不再可读连接仍可发送数据
SHUT_WR关闭写通道,发送缓冲区的数据会先发送完,然后发送FIN连接仍可接收数据
SHUT_RDWR同时关闭读写通道等同半关闭叠加

这里特别注意:SHUT_WR之后,fd还是开着的,引用计数不变。它只是告诉对端"我不会再发数据了,你发完你的数据就可以关闭连接"。对端读完所有数据后收到FIN,自然也会关闭自己的写通道。这是TCP四次挥手里最标准的"半关闭"流程。

2. TCP协议栈视角:FIN、RST和缓冲区分别在什么时候完蛋

如果把TCP连接比作一条双向高速公路,close就是把整条路的所有出入口都封死,shutdown则是可以只封一个方向的入口,另一个方向的车流还能继续通行。这个比喻能帮初学者建立直觉,但协议栈里的实际行为更复杂。

先看close正常路径下的行为。假设接收缓冲区已经读干净,发送缓冲区也空了,调用close时内核会发送FIN,进入FIN_WAIT_1状态,接着按TCP状态机走完四次挥手。这个过程大家都熟。但有几个隐藏行为值得说:

第一,close不等于立即释放。即使close返回了,fd也可能进入TIME_WAIT状态,端口不会马上释放。对于高并发的短连接服务,TIME_WAIT堆积是家常便饭,这跟close本身没关系,但很多人误以为是close泄漏了资源。

第二,close时发送缓冲区还有数据的情况。按Linux的默认行为,close会尝试把发送缓冲区的数据发完再发送FIN,但这个"尝试"是后台进行的,close立即返回。问题来了:如果对端先收到了FIN,然后还在继续读数据,而你的发送缓冲区数据迟迟没发完,就会出现对端"半关闭"状态下的读写错乱。严谨的做法是在close之前先shutdown(SHUT_WR),等对端明确表示"数据都收到了"(比如业务层的ACK或者对端close),再真正close掉fd。

第三,接收缓冲区有未读数据时close = RST。这个我上面提了一句,这里展开说。TCP协议栈的规则是:如果一个连接收到了数据但应用层没读,这时候收到FIN或者主动关闭,内核无法区分"数据是被故意丢弃"还是"连接异常中断",所以直接发RST来终止连接。对端read会返回ECONNRESET。现实中很多服务端在超时踢掉连接时,如果没把接收缓冲区的数据读干净就close,客户端就会看到connection reset,而不是正常的EOF。

再来看shutdown在协议栈里的表现:

SHUT_WR是唯一能保证"优雅发送FIN"的方式。调用后,发送缓冲区里的数据会全部发送出去,然后TCP进入FIN_WAIT_1,发送FIN报文,之后对端回ACK,进入FIN_WAIT_2,对端也关闭写方向后回FIN,本端回ACK进入TIME_WAIT。注意:即使执行了shutdown(SHUT_WR),fd的资源仍然占用着,必须等到对端也关闭连接,或者你自己close掉fd,socket才会真正释放。

SHUT_RD的行为就有点微妙了。它只是让应用层不再接收数据,但协议栈层面TCP还在正常确认收到的数据(要不然对端会因为收不到ACK而超时重传)。有一种情况:如果本地丢弃了数据但确认了ACK,对端会认为数据成功送达,而实际上应用层永远读不到这些数据。这种做法如果使用不当,会造成"数据黑洞",对端毫不知情。

SHUT_RDWR相当于SHUT_RD和SHUT_WR叠加。很多人以为这跟close一样了——并不一样。shutdown(SHUT_RDWR)之后fd还活着,引用计数也没变,还得补一个close才能真正释放资源。

3. 实战选型:半关闭、多线程共享fd、长连接探测各自该用谁

搞清楚底层行为之后,真正的问题来了:我写代码的时候到底该用哪个?以我做过的几个真实项目为例,把这几个典型场景逐一拆开说。

场景一:HTTP/1.1的keep-alive判断

服务端解析完请求头之后,需要判断客户端是继续复用连接还是断开。HTTP/1.1的Connection: close头就是客户端在告诉服务端"处理完这次响应就关连接"。这时服务端正确的做法是先shutdown(SHUT_WR)把响应发出去,然后在对端也真正关闭后再close。直接用close的问题在于:如果响应数据还没发完,close只能尽力而为地往出发,无法保证对端拿到完整响应。我在实际项目里见过因为写响应缓冲没flush完就close,导致客户端收到截断的响应体,排查大半天。

场景二:多线程模型下共享同一个socket fd

一个连接被多个线程同时引用,比如一个线程在读,一个线程在写。这时候写线程发现对方已经半关闭(收到了FIN,read返回0),它想终止发送但不想影响读线程继续处理缓冲数据。如果这个线程调用close,fd引用计数变化可能让读线程也在不知情的情况下被剥夺读取能力。正确做法是写线程调用shutdown(SHUT_WR),读线程继续读,直到读完了再close。这样每个线程都只关闭自己负责的方向,最后由最了解连接状态的线程来收尾。

场景三:长连接的心跳探测与踢连接

你维护一个TCP长连接池,某个连接空闲超时要踢掉。如果直接close,接收缓冲区如果有之前积累的未读数据,很可能触发RST。对端看到的是"连接被重置",日志里全是RST告警,运维分不清是真故障还是正常超时踢连。我的做法是:先shutdown(SHUT_RDWR)表明意志,把RST风险降到最低,然后再close释放fd。这样日志里看到的是正常的FIN挥手,而不是刺眼的RST。

场景四:探测对端是否还在正常工作

TCP没有自带的应用层心跳,有时候我们的程序想探测对端是否存活,可以shutdown(SHUT_WR)发送FIN,然后尝试读对端的数据。如果对端协议实现正确(比如echo服务),它会回应FIN或数据,说明对端存活且链路健康。这种情况下如果直接用close,就丧失了"发送通道关闭、接收通道仍可用"的能力。

场景五:代理与转发程序里的半关闭

做TCP代理、端口转发这类程序时经常遇到一个经典问题:A到代理的一条连接关闭了,代理到B的连接是不是也要跟着关闭?很多初学写的代码是A连接read返回0,就close掉B连接。但A的关闭可能只是A不再发送数据了,B可能还有数据要通过代理回给A。此时正确做法是:A方向的read返回0,调用shutdown(B_fd, SHUT_WR)发起半关闭,让B继续发数据回来,等B方向也read返回0,再真正close两条连接。这个细节做不好,代理转发大文件时就会丢尾巴。

4. 踩坑实录:close之后读到RST、fd复用和SO_LINGER误用

踩过坑才知道文档里的"undefined behavior"和"implementation-defined"有多疼。这几个坑我都是线上问题逼着才彻底弄明白的。

坑一:close之后立刻有数据到达,触发RST风暴

有一个高并发推送服务,客户端连接处理完就close,代码大概是:

send(client_fd, response, len, 0); close(client_fd);

看起来没问题。但问题是close返回后,客户端的ACK可能还在路上,如果客户端又发了新数据过来(比如连续请求),内核发现这个连接已经关闭了,直接回RST。客户端主机的连接还没及时更新状态,下次再往这个已经被RST的socket写数据,就报broken pipe。有些版本的Linux还会在close时把socket放入"正在关闭"队列,此时到达的包直接被RST。后来我改成:

shutdown(client_fd, SHUT_WR); // 先发送FIN,表明不再发送数据 // 等待客户端响应FIN并关闭,或者业务层确认接收完毕 while (recv(client_fd, buf, sizeof(buf), 0) > 0); close(client_fd);

RST明显减少了。这个"等对端关闭再close"的模式看起来多花了一点时间,但换来的是干净的四次挥手。

坑二:fd复用导致误杀新连接

close的另一个危险点在于fd编号的复用。假设线程A持有fd 10,线程B也持有fd 10(共享或者dup出来的),线程A close(10)后,内核释放了这条连接。接着线程C accept了一个新连接,内核分配了fd 10。此时线程B如果还拿着自己印象中的fd 10去写数据,数据就发到新连接上去了。这种事在并发程序里是灾难性的,数据错乱、协议崩坏,全都查不出原因。解决办法就是在多线程共享fd的场景下,一定要用引用计数或者局部的同步机制保证close只发生在"最后一个持有者"手里,或者一律用shutdown控制连接语义,最后集中管理fd生命周期。

坑三:SO_LINGER设成0导致的RST非正常关闭

SO_LINGER这个socket选项控制close时对未发送数据的处理方式:linger开启且超时时间为0时,close会直接丢弃发送缓冲区所有数据并发送RST,而不是FIN。很多写爬虫、写下载工具的人为了快速释放端口,喜欢把SO_LINGER设成0:

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

这样close确实快,但代价是对端必然收到RST,数据可能丢弃。服务端如果依赖这个行为来"发完数据立即释放端口",隐患很大:对端可能还在等数据,等来的却是connection reset。我个人的准则是:除非明确知道自己在做什么(比如要立即终止一条异常连接),否则不要设置SO_LINGER为0。正常业务应该用shutdown(SHUT_WR)来优雅关闭写通道,再close释放fd。

坑四:忽略close的返回值和errno

很多人close完不看返回值。但close是有可能失败的——比如在非阻塞socket上close时发送缓冲区还有大量数据,内核尝试发送可能因为缓冲区满而失败,返回EAGAIN。这时如果不处理,数据就会丢。健壮的写法是考虑在close失败时先shutdown,再重新close。另外,close返回EINTR也不是没有可能(被信号中断),此时fd的状态不确定,需要谨慎处理。

5. 排查连接不释放问题时要检查的几个关键点

文章开头提到热搜词里有"怎么看fence是没有signal还是没有close导致泄露",这类问题本质就是资源泄漏排查。连接不释放、fd耗尽,往往是"该close的没close"和"close的时机不对"两种情况叠加。遇到这类问题,我的排查顺序比较固定:

第一,先看fd数量/proc/<pid>/fd目录下能直接看到进程打开的所有fd,数量暴涨基本就是fd泄漏。用lsof -p <pid> | wc -l能快速估算。如果fd数量稳定但TCP连接数在涨,那很可能不是fd泄漏,而是连接没有正常关闭,处于TIME_WAIT或者CLOSE_WAIT状态。

第二,用ss命令看连接状态分布ss -ant | awk '{print $1}' | sort | uniq -c能一眼看出各种TCP状态的数量。大量CLOSE_WAIT说明对端已经关闭了连接(发来FIN),但本地程序没有close自己的fd——典型的只调了shutdown或者根本忘了调close。大量TIME_WAIT则说明本地主动关闭了连接,但处于2MSL等待期,如果量特别大,考虑是不是短连接场景没开启端口复用。

第三,审查业务代码里的每个错误分支。很多连接泄漏不是正常路径的问题,而是异常路径忘了close。比如recv返回-1时直接break,但跳过了close;比如业务处理抛出异常,defer/RAII没覆盖到。排查的时候我会在代码里全局搜索close和shutdown的调用点,对照所有可能跳出的分支逐一检查。

第四,确认fd引用计数。feat的进程模型里,如果fork过子进程,父进程没close继承来的fd,子进程退出了但fd计数还没归零,连接就会一直挂着。这种问题用lsof -p <pid>看fd的owner就能发现,或者用grep '^State' /proc/net/tcp辅助判断。

第五,确认是否把shutdown和close配成对使用了。shutdown只关通道不释放资源,如果代码里只有shutdown没有close,连接一样会泄漏。更隐蔽的是shutdown(SHUT_RD)之后又去读fd,读操作会直接返回0,可能被误判为对端关闭,进而引发错误逻辑。这些都需要在代码评审里多留意。

用一套实际的排查记录来说明:之前有个推送网关,凌晨fd数量持续走高,最后把进程的fd耗尽。ss -ant看到几千个CLOSE_WAIT。顺着CLOSE_WAIT找代码,发现业务线程在收到客户端FIN后,只是调用了shutdown(SHUT_WR)想着"把剩余数据发完",但后续的close被放在一个永远等不到的条件分支里——因为对端已经关闭,不会再发数据过来,代码里却还在等对端的下一个请求。修复很简单:read返回0时,shutdown(SHUT_WR)发送FIN,然后再close。这一个改动,CLOSE_WAIT归零,fd稳定了。

6. 我对close和shutdown的使用习惯总结

最后分享一套我这些年总结出来的、比较稳妥的使用规则。先说结论:能用shutdown来表达意图的,先用shutdown;close永远放在最后收尾,并且确保它是对fd的最后一个引用。这两个函数不是替代关系,而是配合关系。

具体做法上,我会根据"是否还需要接收数据"和"是否还需要发送数据"两个维度来做决策:如果只想终止发送、还想继续接收,用shutdown(SHUT_WR);如果只想终止接收、还想继续发送,用shutdown(SHUT_RD);如果完全不想再使用这个连接了,shutdown(SHUT_WR)或SHUT_RDWR之后,再close。手上有个小技巧:close之前先给对端一个业务层的"再见"报文,比如内存缓冲里写一个长度为0的包,或者自定义的结束标记,然后shutdown(SHUT_WR),等对端确认后再close。这套流程在大多数协议里都能显著减少RST的出现。

另外,给所有新手一个建议:别嫌半关闭流程麻烦。TCP的设计者把shutdown做成独立的系统调用,本来就是为了让应用能优雅地控制连接生命周期。在写网络库、代理、网关这类对连接生命周期管理要求高的代码时,shutdown+close这套组合拳几乎是必须的。把这两个调用彻底搞懂,很多网络层的疑难杂症都能少一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询