close与shutdown到底有什么区别?一文讲透TCP套接字关闭机制 写网络程序这两年我在“关闭连接”这件事上踩过不少坑。最典型的一次是服务端有个线程想主动断开发起的连接结果用close关完对端半天没感知任务队列还是一直挂着换成shutdown之后现象立刻就不一样了。后来我才理解close和shutdown这对“双胞胎”表面上都是让连接断掉实际作用对象完全不同。如果只是照着文档背“close关闭套接字shutdown关闭TCP连接”遇到多线程、多进程或者半关闭场景照样会踩雷。这篇文章我就把close和shutdown在TCP套接字上的区别从头到尾掰开讲包括底层行为、引用计数、半关闭、RST和TIME_WAIT这些关键点附上可跑的代码和排查技巧。无论你是写C/C、Python、Java还是Go只要底子是原生socket这些原理都一样早看早省事。1. 先搞清楚close和shutdown到底在干什么很多新手以为“close就是关闭socket”这个说法不够准确。它俩最大的区别是close操作的对象是“文件描述符”shutdown操作的对象是“TCP连接本身”。这一行字就是全文的钥匙后面所有现象都能从这个差异推回去。1.1 close关的是文件描述符不是连接在Linux/Unix体系里socket是基于文件描述符fd实现的内核对象。open、socket、accept返回的fd都占用一个fd表项这个表项会引用到一个内核级的“打开文件描述”而每一条TCP连接又由这个打开文件描述指向。close看的是整个引用链不只是这一条连接。具体到行为上close(fd)做的事情是从当前进程的fd表中移除这个fd对该fd对应的内核对象引用计数减一只有当引用计数变为0时才会真正触发内核释放socket、发起正常的TCP关闭流程通常是发送FIN。也就是说close本身不保证“连接断掉”它只保证“从我这里拿不到这个fd了”。如果同一个socket被其他进程、其他线程通过fork或dup共享着那么你close一次可能只是把引用计数从3变成2连接依旧存活谁都不会收到FIN。我打过一个生活化比方fd相当于小区单元门的门禁卡连接是你家的大门。close只是“扔掉一张门禁卡”但只要还有别人手里有卡单元门还能开shutdown则是直接对着你家大门里面的总开关操作一按就是把全部门禁都废掉。这个比喻不能精确到内核层级但用来理解“引用计数”和“作用范围”够用了。另外还要注意close的返回值只表示这个fd“从用户态回收了”不代表对端已经收到数据更不代表底层四次挥手已经完成。所以很多人在close之后立刻看对端日志发现对方还是显示established根本不是网络问题而是引用计数没归零。1.2 shutdown直接对TCP连接说话shutdown和close最大的不同在于它不关心fd的引用计数也不释放fd只针对“这个fd指向的socket连接”发起关闭动作。原型是int shutdown(int sockfd, int how);how有三个取值SHUT_RD0关闭连接的读方向。之后这个socket不能再读数据但如果对端发数据过来内核会丢弃或触发对应行为SHUT_WR1关闭连接的写方向。这是最常用的半关闭方式会向对端发送FIN表示“我的数据发完了但读端还开着可以接收你后续的数据”SHUT_RDWR2读和写一起关。效果上等价于先SHUT_RD再SHUT_WR但只是对这条连接发起FIN和关闭行为fd本身还留在进程里之后还是需要调用close来释放fd。这里有个关键点即使进程中有多个fd都指向同一socket只要其中一个调用shutdown这个socket上的TCP连接就会立即受到操作对端立刻能感知到。所以shutdown常用于多进程/多线程环境下“我要主动终止连接但其他进程可能还持有这个fd”的场景。我自己的经验是关闭连接前先想清楚“我要关闭的是这个连接的读还是写还是全部”。如果只是“我不再需要这个文件描述符了”请用close如果是要明确地向对端传达“结束信号”或者需要半关闭那就必须用shutdown。2. 核心区别引用计数、半关闭与作用范围这一节是面试里最爱考的也是实际排障时最容易出问题的点。我按三个维度展开作用范围、引用计数、半关闭能力。2.1 close和shutdown对“连接状态”的影响差异先看作用范围。close是“进程级”操作它只影响当前进程里的fd表。同一个socket被多个fd引用时你close掉其中一个内核里的socket对象不会立即触发FIN只有所有引用全部关闭连接才进入正常的关闭流程。shutdown是“socket级”操作它不考虑有几个fd引用了这个socket。哪怕有多个进程都在用同一个socket只要其中一个进程调用了shutdown(SHUT_WR)这条TCP连接就会立刻发出FIN。对端看来就是你主动关闭了写方向。我整理了一张对比表方便大家直接收藏行为closeshutdown作用对象fd文件描述符socketTCP连接是否释放fd是否仍需调用close释放对引用计数的影响引用计数减一不关心引用计数是否触发FIN计数归零才触发SHUT_WR/SHUT_RDWR时立即触发是否支持半关闭不支持支持SHUT_RD/SHUT_WR对接收缓冲区的影响随socket释放处理读关闭时丢弃后续数据写关闭不受影响是否立即返回立即返回默认模式立即返回在代码里如果你close了一个fd然后又用dup过的另一个fd继续读连接其实还是活的。这种“关而不死”的情况就是最典型的“close没用对地方”。2.2 多进程场景下的引用计数陷阱多进程模型最经典的坑就是fork之后父子进程共享监听socket或连接socket。比如一个服务端进程accept了一个客户端连接后fork出子进程去处理。父进程如果不关掉这个连接fd子进程也不关那这个socket的引用计数至少是2。这时候如果子进程处理完数据调用close(fd)引用计数从2变到1连接不会关对端自然收不到FIN。如果父进程那边还一直保持fd不关这个连接就会一直挂着客户端那边也会一直认为连接还在。等到连接池资源耗尽服务端开始大量报“too many open files”你再怎么查日志都找不到原因。解决办法有两个方向父进程accept之后如果fork子进程处理父进程应该马上close掉这个连接fd只保留子进程中的fd如果确实需要多个进程同时持有fd且希望主动断开连接那就用shutdown。shutdown(SHUT_RDWR) 会立刻让这条连接进入关闭流程即使fd的引用计数还没有归零对端也能立刻感知。这类问题我在生产环境排查过不止一次。最典型的现象是客户端一直在等服务器关闭连接服务器这边明明是“close了”客户端却收不到EOF。抓包一看服务器根本没有发FIN。用lsof看socket引用计数往往能发现两个甚至三个进程/线程都持有了同一个socket的fd。所以记住一句话close是“局部关闭”shutdown才是“全局关闭”。2.3 半关闭shutdown的独门绝技TCP本身是全双工协议客户端和服务端的读写通道是独立的。半关闭的意思就是我发送完了我的数据但还希望能接收到对方的数据。这个需求在HTTP、FTP、SMTP等协议中非常常见。举个例子一个HTTP客户端想请求一个文件客户端发送完请求内容后调用shutdown(SHUT_WR)告诉服务端“我的请求发完了不会再写了”客户端仍然保留读能力可以继续读取服务端返回的响应体服务端读到EOF之后知道客户端已经发完数据再回完响应后关闭连接。如果用close代替shutdown(SHUT_WR)客户端在写完请求后就把整个fd关了读端也随之关闭后续根本无法读取服务端返回的数据。所以凡是“先发送请求再等待响应”的场景几乎必须使用半关闭。shutdown(SHUT_RD)则相反它关闭的是读方向之后这个socket就不能再接收数据了。但SHUT_RD的语义在实际中比较微妙因为如果在SHUT_RD之后对端仍然发来数据内核可能丢弃这些数据甚至在某些情况下会触发RST。这个行为在《Unix网络编程》里也有提到所以如果不是有明确需求我不建议随手用SHUT_RD。最常用的就是SHUT_WR实现单向关闭。可能有人会问那半关闭之后我还需要close吗需要。shutdown只是关闭连接这个“对象”的一部分方向fd资源还占着最后还是要close来回收fd。3. 底层TCP行为FIN、RST、TIME_WAIT全链路对比从TCP状态机的视角看close和shutdown会在发送缓冲、接收缓冲、TIME_WAIT、RST等行为上产生各种微妙的差别。这一节我们聊底层。3.1 close之后的数据残留与SO_LINGER默认情况下SO_LINGER关闭close的步骤如下内核尝试把发送缓冲区中的数据发送出去发送完缓冲区的数据后发送FIN如果对端关闭了连接并返回RSTclose的后续行为取决于socket的状态。但要注意close返回不代表这些数据已经被对端确认接收它只代表本端fd已释放。如果发送缓冲区还有大量数据而你又设置了SO_LINGER为立即放弃模式l_onoff1, l_linger0那么close会直接丢弃发送缓冲区里的所有数据并向对端发送RST而不是FIN。对端在read时会收到ECONNRESET而不是优雅的EOF。我当年第一次用SO_LINGER的“暴力关闭”模式时以为把linger设成0就能让close“秒断”结果客户端那边read直接报Connection reset by peer。后来才明白RST和FIN的语义完全不同FIN表示“我发送完了数据不再发送但之前的数据都可靠送达”RST表示“连接异常终止之前的数据可能已经丢失不需要正常握手”。所以如果需要保证数据完整送达千万不要随意设置l_linger0。合理的做法是先shutdown(SHUT_WR)等待对端关闭或者设置一个合理的linger超时给Buffer一个刷出时间。同时默认close还有一个坑对端如果还有数据正在发过来而本端已经关闭了读方向或直接close对端继续write时可能收到RST。这就是为什么“主动关闭前等对方把数据发完”很重要。3.2 shutdown如何触发正常四次挥手四次挥手的状态迁移对主动关闭的一方是这样的调用shutdown(SHUT_WR)本端发送FIN进入FIN_WAIT_1对端收到FIN回ACK本端进入FIN_WAIT_2对端也调用close/shutdown(SHUT_WR)发送FIN本端收到后回ACK进入TIME_WAIT等待2MSL后TIME_WAIT结束连接彻底释放。整个过程里shutdown(SHUT_WR)只是发送了FIN并没有关闭读端所以本端依然可以继续接收对端发来的数据。这一点在协议设计里非常关键。比如实现一个WebSocket或自定义RPC协议时你要完成“我的请求发完了”和“我还能收响应”两个动作shutdown(SHUT_WR)就是标准解法。而close是怎么处理的close在引用计数归零时本质上是对读写两端都执行关闭发送FIN如果不触发RST之后就关闭了连接。它无法做到只关写、不关读。所以你一旦在需要半关闭的场景用了close基本上就是用了错误的工具。3.3 TIME_WAIT、端口复用的避坑指南无论是close还是shutdown(RDWR)后关闭连接只要是主动发送FIN的一方都会进入TIME_WAIT状态。TIME_WAIT会保留2个MSL时间常见默认60秒左右这是为了让迟到的报文在网络中消亡也保证最后的ACK能被对端收到。TIME_WAIT过多是服务端常见问题特别是短连接高并发场景。如果你在服务端做大量主动关闭就会看到一堆TIME_WAIT。解决思路有设置SO_REUSEADDR允许新连接重用处于TIME_WAIT状态的本地端口尽量避免服务端主动关闭连接尽量让客户端先关在长连接场景下控制连接复用别频繁建连又频繁关。shutdown本身不会消除TIME_WAIT但它可以帮你在协议上做到“我先不监听了/我先停止发送”从而避免一些因为RST引起的异常状态。比如你直接close但对端还在发数据内核收到数据却发现连接已关闭很可能会回RST而如果你用shutdown(SHUT_WR)先停止发送但仍然保留读能力把对端的数据都读完再close那么整个关闭过程就会干净得多不容易出现RST导致的错乱。我的实操经验是在主动关闭连接时如果时间允许走“shutdown(SHUT_WR) - 读到EOF - close”这条路通常比直接close更稳尤其在你的协议里还有未读完数据时。4. 代码实验在Linux上把区别跑出来光讲理论不够我写两个可以直接实验的代码帮助你把close和shutdown的差异“跑出来”。都是最简版本的socket程序重点看关闭行为。4.1 C语言最小示例close与shutdown对照先看一个服务端和客户端很非常简化的雏形。服务端不单独写完整代码核心逻辑这样// 服务端处理连接的核心伪代码 int connfd accept(listenfd, ...); char buf[64]; int n read(connfd, buf, sizeof(buf)); // 读取客户端请求 // ... 处理请求 ... // 假设这里用shutdown关闭写方向保留读方向 write(connfd, ok, 2); shutdown(connfd, SHUT_WR); // 发送FIN告诉客户端“响应发送完毕” sleep(1); // 等客户端关闭 close(connfd); // 回收fd这里如果不用shutdown(connfd, SHUT_WR)而是直接write后close客户端在read返回0之前可能会因为还有数据被RST干扰。为了对比我们可以准备两个客户端脚本一个在收到响应后直接close另一个在写完请求后先shutdown(SHUT_WR)再read。客户端在发送完请求后用shutdown(SHUT_WR)来半关闭再循环读取响应int sockfd socket(AF_INET, SOCK_STREAM, 0); // connect ... char request[] GET / HTTP/1.1\r\nHost: localhost\r\n\r\n; write(sockfd, request, sizeof(request) - 1); shutdown(sockfd, SHUT_WR); // 表示“请求发完了” char buf[1024]; int n; while ((n read(sockfd, buf, sizeof(buf))) 0) { // 处理响应 } close(sockfd);这个例子能跑通HTTP的基础流程。关键在于shutdown(SHUT_WR)发送FIN后服务端的read会返回0服务端就知道客户端不会再发请求了从而能够继续回响应。如果用close服务端读到的可能是RSTread返回-1处理逻辑就乱了。4.2 Python快速验证半关闭如果你不想写CPython的socket库封得非常直观import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 8080)) s.sendall(bhello) s.shutdown(socket.SHUT_WR) # 发送FIN但保留读端 resp s.recv(1024) # 还能继续读服务端返回 print(resp) s.close()这里的close和shutdown的区别非常明显shutdown之后fd还没有释放你还能recv如果直接close后续的recv会抛异常。我在实际做一个简单HTTP客户端时就遇到过一个问题如果发送完请求后不调用shutdown(SHUT_WR)而是直接close服务端虽然能读到数据但是它在读数据时并不知道请求是否结束。因为TCP没有消息边界很多服务端协议是靠EOF即读到返回0判断对方请求结束的。这种情况下close会让对端收到“连接关闭”的语义而shutdown(SHUT_WR)能让对端收到“写方向关闭但连接还活着”的语义二者在协议完整性上差别很大。4.3 实际项目中的选型建议回到真实项目我总结的选型经验非常简单如果是短连接比如一次请求/响应后就不再使用直接close问题不大如果是一次请求后还要读响应强烈建议写完请求后shutdown(SHUT_WR)如果是多线程/多进程共享同一个fd要主动断开连接请用shutdown(SHUT_RDWR)然后再让最后一个持有者close如果只是进程内部临时创建一个fd用完不再需要直接close即可不需要shutdown如果需要避免对端收到RST一定不要在没有读完数据时关闭读端。这段话是我在很多次线上问题中总结出来的。不要死记硬背只要记住“close关fdshutdown关连接”这条主线大部分场景都能推导出来。5. 常见问题与排查技巧实录最后分享几个我在实际排查中经常遇到的分支问题每个都是踩过坑才总结出来的。5.1 对端还能收到数据fd的坑如果你close(fd)之后对端还是能收到本端其他地方write的数据那一定是有另一个fd指向同一个socket。可能来自fork、dup或者你把fd传给了子进程但没关。排查方法用lsof -p pid | grep port看当前进程打开的socket fd用ls -l /proc/pid/fd确认fd指向的socket inode是否还有别的引用用ss -tnp看连接对应的进程和fd。这时候如果只想快速让连接断开shutdown(SHUT_WR)是更可靠的手段因为它不依赖引用计数。5.2 为什么写套接字有时EPIPE有时ECONNRESETEPIPE和ECONNRESET是两种不同的错误当你调用write往一个已经收到对端FIN的连接上写数据第一次可能成功因为内核还没感知到对端已关闭第二次或后续write会触发SIGPIPE默认终止进程或返回EPIPE当对端发送RST时本端再调用read/writeread会返回ECONNRESETwrite通常会返回EPIPE。如果对端关闭后还有数据到达本端本端可能主动回RST导致对端读数据时报ECONNRESET。在实际编程中记得忽略或处理SIGPIPE信号否则进程会在一次普通write中莫名退出。信号处理方式signal(SIGPIPE, SIG_IGN);然后检查write的返回值处理EPIPE。5.3 优雅关闭的正确姿势优雅关闭的核心是让对方知道你的数据发完了同时把你这边能读的数据读完最后再释放fd。推荐流程停止向socket写入业务数据调用shutdown(SHUT_WR)发送FIN继续read直到read返回0收到EOF这时再调用close回收fd。这个过程避免了数据还没读完就强制关闭导致的RST也给对端一个完整的“结束”信号。很多RPC框架、HTTP/1.0服务端都有类似逻辑。反过来如果你直接用close本端会立刻丢弃读方向可能失去对端最后的返回数据甚至因为内核回RST导致对端报错。5.4 抓包与状态查看命令排查close和shutdown行为时抓包是最直观的。命令很简单sudo tcpdump -i lo port 8080 -nn sudo tcpdump -i eth0 tcp and host 192.168.1.10 -nn再看连接状态ss -tanp | grep 8080 ss -tan state time-wait lsof -i :8080抓包时重点看最后一个包的标志是FIN还是RST如果是FIN说明走的是正常四次挥手如果是RST说明连接被异常终止。很多时候你只要看到对端收到RST基本可以断定是本端close时还有未读数据或者SO_LINGER被设成了0。对应的修复就是改用shutdown(SHUT_WR)优雅关闭并调整linger设置。在我自己维护的一个网关项目里线上经常会出现客户端报“Connection reset by peer”。抓包后发现服务端在处理完请求后直接close了socket而这个连接上还有客户端发来的残留数据没读内核于是回了RST。后来把关闭逻辑改成“shutdown(SHUT_WR) - 读完所有客户端数据 - close”这个报错就彻底消失了。做网络编程关闭连接这件事远没有表面看起来那么简单。close和shutdown的区别本质上关心的是“你眼里这个socket到底是什么”。如果你只当成一个fdclose就够了但如果你把它看成一条有读、有写的双向连接那shutdown才提供了精细的控制能力。现在我在写任何TCP代码时都会在注释里写清楚“这里是释放fd还是关闭连接”逼着自己先想清楚再动手。