ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

close与shutdown:Linux网络编程中的连接关闭与半关闭详解

close与shutdown:Linux网络编程中的连接关闭与半关闭详解 在实际的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 { // 父进程如果直接closefd引用计数从2降到1连接并不会关闭 close(client_fd); }这段代码里如果父进程不close子进程即使close了fd的引用计数也只是从2降到1socket不会被真正释放。这就是经典的连接泄漏场景之一。反过来如果子进程只想终止发送数据、但还想继续接收父进程从别处写来的数据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掉fdsocket才会真正释放。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一个连接被多个线程同时引用比如一个线程在读一个线程在写。这时候写线程发现对方已经半关闭收到了FINread返回0它想终止发送但不想影响读线程继续处理缓冲数据。如果这个线程调用closefd引用计数变化可能让读线程也在不知情的情况下被剥夺读取能力。正确做法是写线程调用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设成0struct 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来表达意图的先用shutdownclose永远放在最后收尾并且确保它是对fd的最后一个引用。这两个函数不是替代关系而是配合关系。具体做法上我会根据是否还需要接收数据和是否还需要发送数据两个维度来做决策如果只想终止发送、还想继续接收用shutdown(SHUT_WR)如果只想终止接收、还想继续发送用shutdown(SHUT_RD)如果完全不想再使用这个连接了shutdown(SHUT_WR)或SHUT_RDWR之后再close。手上有个小技巧close之前先给对端一个业务层的再见报文比如内存缓冲里写一个长度为0的包或者自定义的结束标记然后shutdown(SHUT_WR)等对端确认后再close。这套流程在大多数协议里都能显著减少RST的出现。另外给所有新手一个建议别嫌半关闭流程麻烦。TCP的设计者把shutdown做成独立的系统调用本来就是为了让应用能优雅地控制连接生命周期。在写网络库、代理、网关这类对连接生命周期管理要求高的代码时shutdownclose这套组合拳几乎是必须的。把这两个调用彻底搞懂很多网络层的疑难杂症都能少一半。
返回列表