
一、上手之前必须弄明白TCP请求到底在请求什么很多刚接触网络编程的朋友最喜欢问的一句话是TCP请求怎么实现。但说句实话这个问题本身就有很大歧义。你见过有人问HTTP请求怎么实现吗大家一般都会直接用现成的库。但TCP请求不一样它没有万能库因为TCP本身不是一个请求-响应协议它只是一个可靠字节流通道。你往通道里塞数据对端就能收到至于这是不是请求、算不算完成完全由你上层协议说了算。我刚工作那会儿第一次做网络模块拿到的需求就是实现TCP请求。我当时直接懵了——请求什么发给谁发什么格式对端怎么知道我说完了后来才明白这句话背后真正的意思是你要在一条TCP连接上把应用层的数据可靠地发过去并且想办法收到对方的响应还得处理各种异常——连不上、超时、断线、数据不完整。这才是TCP请求实现的全貌。这篇文章我打算从一条连接的生命周期来拆这件事从建立连接、发送数据、接收数据、关闭连接到最后的性能调优。全程用Python标准库演示因为它最接近TCP原始语义又不需要你写一坨C代码。不同的读者都能从里面拿到自己需要的东西小白可以先囫囵吞枣把整条链路跑通有经验的可以直接跳到中间几节看协议设计和坑点。先抛一个我和身边人反复确认过的结论绝大多数所谓的网络请求问题根源都不在应用层而在TCP这一层。你往服务端发一段数据服务端没收到问题可能是半包、是全连接队列溢出、是Nagle延迟、是TIME_WAIT堆积……这些不看清楚换什么框架都白搭。二、最小可用的TCP请求手写一遍Socket全流程我们先把门推开不看任何封装用最原始的方式在一条TCP连接上完成一次请求-响应。这一步不是为了炫技而是为了让你对后面每一层概念有具象的参照物。2.1 为什么选Python的socket作为演示工具市面上的语言里C的socket API最底层但写起来啰嗦Go的net包很优雅但很多细节被封装掉了Python的socket模块则是个很好的中间态——你仍然能碰到connect、send、recv这些原语也能直接设置各种TCP选项却不用手工管理内存。所以下面的例子都用Python理解之后无论换什么语言都能一一对应。另外我不建议一开始就上第三方高性能框架。原因很简单框架帮你把连接池、编解码、重试都做了一旦线上出问题你连排查入口都找不到。自己拿socket写一遍哪怕很简陋你再回头去看那些框架的源码会有一种原来你这里就是在干这个的通透感。2.2 一个最小案例发送一行数据并读取响应先起一个简单的本地服务端再用客户端脚本发起请求。为了不引入额外依赖服务端也直接用socket写。# server.py import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 9000)) server.listen(5) print(server listening on 9000) while True: conn, addr server.accept() print(accept from, addr) data conn.recv(1024) print(recv:, data) conn.sendall(becho: data) conn.close()# client.py import socket sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((127.0.0.1, 9000)) sock.sendall(bhello tcp) resp sock.recv(1024) print(resp:, resp) sock.close()这段代码跑起来一个最简单的TCP请求就完成了。但如果你只是跑通就收手那收获太少了。我们要逐行看看每个调用背后操作系统替我们做了什么。2.3 每个关键调用背后发生了什么socket.socket(socket.AF_INET, socket.SOCK_STREAM)AF_INET代表IPv4地址族SOCK_STREAM代表字节流套接字也就是说我们要用的是TCP。这行代码执行后操作系统内核里多了一个文件描述符这个描述符现在还不属于任何连接。connect((127.0.0.1, 9000))这不是一个简单的登记。这一声调用内核会真的向对端发送一个SYN报文然后等待SYN-ACK再回一个ACK——三次握手在毫秒级别内完成。这个函数返回时连接已经是ESTABLISHED状态你可以立刻写数据了。注意connect是阻塞的如果对端网络不通它可能卡几十秒才报错所以后面我们会重点讲超时控制。sock.sendall(bhello tcp)这行和send的区别特别容易踩坑。send是一次系统调用它只保证把数据复制到内核的发送缓冲区但不保证字节全部复制进去。缓冲区满了它可能只发送一部分就返回。而sendall的内部实现是一个循环一直调用send直到全部数据写完。所以裸写代码时请务必用sendall或者自己写循环否则会有概率出现发送线程报成功、对端却收到残缺数据的诡异问题。sock.recv(1024)从内核接收缓冲区里取数据最多取1024字节。这里有两个新手最容易误解的点第一一次recv可能只读到半条消息也可能一次读到两条消息因为TCP是流不是包第二如果对端优雅关闭了连接recv会返回一个空字节串b这不是错误而是对端已经不在的信号。很多同学把recv返回空当成异常直接抛了这是错的。sock.close()把文件描述符关掉发起四次挥手的主动关闭。注意close是一个引用计数操作——如果这个socket被多个地方引用比如dup过或者被fork继承只有引用计数归零时才会真正关闭。这段最小代码就是整个TCP请求实现的骨架。后面所有复杂内容都是在给这个骨架加防护、加容错、加性能优化。三、连接建立阶段三次握手与两类队列的实战影响连接建立是整个TCP请求生命周期里最容易被忽略却又最能引起企业级故障的环节。先说个行情大多数人调connect失败第一反应是网络不通但实际排查会发现很多问题出在服务端的监听队列上。3.1 SYN队列与全连接队列服务端在握手期间的两个房间服务端调用listen(fd, backlog)时内核会为这个监听套接字分配两个队列SYN队列半连接队列和全连接队列accept队列。握手流程是这样的客户端SYN到达内核在SYN队列中创建一个条目返回SYN-ACK。客户端发来最后的ACK握手完成。此时连接从SYN队列移动到全连接队列。服务端程序调用accept()从全连接队列中取出一个连接。backlog参数的大小直接限制全连接队列的长度。在很多Linux发行版上这个参数还会再受net.core.somaxconn的上限影响。如果全连接队列满了内核会直接丢弃抵达的ACK或者启动SYN-ACK重传客户端就会表现为connect成功后又立刻被重置或者connect长时间卡住。我见过一次有意思的线上事故某个服务在某次大促开始时突然大量客户端报连接超时服务端CPU、内存都很正常就是请求进不来。最后用ss -lnt检查发现Send-Q显示的全连接队列长度已经被打满而应用层代码里listen的backlog设成了128但net.core.somaxconn被改成了64内核直接压缩了队列。改回一致后故障瞬间消失。3.2 客户端视角connect失败的错误码究竟在说什么connect失败时客户端能看到的无非那几类错误但含义天差地别错误码含义常见触发场景ECONNREFUSED对端端口根本没有进程监听或防火墙回RST服务没起来、端口写错ETIMEDOUTSYN发出去后没有收到任何回应对端IP不可达、防火墙静默丢包EHOSTUNREACH网络层就不可达路由问题、对端主机下线EAGAIN/EWOULDBLOCK非阻塞connect仍在进行中超时设置太短或网络抖动实际排查时有个经验先看ss -t确认对端端口有没有监听再ping和traceroute判断网络层然后用tcpdump在两端抓包看SYN有没有出去、SYN-ACK有没有回来。不要一上来就怀疑DNS或者代理。3.3 建立连接阶段最值得做的三个防护第一设置connect超时。阻塞socket的默认超时很长一旦对端黑洞调用线程会挂很久。做法是把socket先设为非阻塞用select/poll/epoll等超时或者直接settimeout——Python的socket.settimeout(5)就会让所有阻塞操作变成最多等5秒。第二服务端listen的backlog不要拍脑袋。一个参考值是结合你的每秒新建连接数以及accept循环的处理速度。如果平均每秒产生100个新连接每10毫秒能accept一个那100个积压连接大约需要1秒处理backlog至少应该能容纳这个数量级。第三打开SO_REUSEADDR。这个我在最小案例里已经写了它的作用是让服务端在TIME_WAIT状态下也能重新绑定同一端口否则你重启服务时会撞上Address already in use。四、字节流的世界没有消息边界粘包半包与协议设计这是TCP请求实现里最重要的一个认知。TCP是流式协议——它自己不管你的数据是一条一条的消息它只负责把一连串字节按顺序搬到对端。就像一根水管水是连续不断的没有在水里画格子。很多第一次做网络协议的人会在客户端发了一条消息服务端recv了一次就以为这是一对一的。但实际运行后会出现各种奇葩现象一次recv只读到半条消息一次recv把两条消息拼在一起读出来了消息被拆成好几段跨了多次recv。这些不是TCP错了是应用层没做好成帧。4.1 为什么sendall并不等于消息边界sendall保证的是调用返回时字节已经全部提交给内核发送缓冲区它不保证这些字节被对端一次性recv出来。因为TCP分段和IP分片的存在一条大消息可能被拆成多个TCP段在网络里各走各路最后对端分批发来。反过来如果客户端连续调用两次sendall两次之间的间隔极短内核很可能会把这两段数据合并成一个TCP段发出去对端一次recv就可能同时读到两段内容。这就是粘包的本质。4.2 三种成帧方案固定长度、分隔符、长度前缀既然TCP不帮你分消息应用层就得自己分。实践中常用的三种固定长度每条消息固定N字节不足补零读满N字节算一条。实现最简单适合消息尺寸基本固定的场景缺点是浪费带宽、灵活性差。分隔符每条消息末尾加特殊字符比如\r\n或\n读到分隔符就算消息结束。HTTP协议头就是这么干的。缺点是要逐字节判断且消息内容里如果含有分隔符就要转义。长度前缀先读固定长度的头部通常是4字节头部用大端字节序存放后续消息体的长度再按长度读消息体。这是二进制协议的主流方案比如很多RPC框架、消息队列都采用类似思路。扩展性好解析效率高。我自己的习惯是默认选长度前缀。它比固定长度省流量比分隔符更严谨后续想加协议版本、消息类型都可以放在头部里扩展。4.3 一个可靠的按消息读取实现用Python实现一个带长度前缀的接收函数应该长这样import struct def recv_exact(sock, n): buf b while len(buf) n: chunk sock.recv(n - len(buf)) if not chunk: raise ConnectionError(连接被对端关闭期待 {} 字节但只收到 {}.format(n, len(buf))) buf chunk return buf def recv_message(sock): header recv_exact(sock, 4) length struct.unpack(I, header)[0] return recv_exact(sock, length)recv_exact是核心它循环recv直到凑够N字节。十行不到的代码解决了半包这个疑难杂症。struct.unpack(I, ...)用大端字节序解析无符号整数这样消息长度上限是4GB正常情况下足够用了。发送端对应的写法def send_message(sock, payload): header struct.pack(I, len(payload)) sock.sendall(header payload)这里要注意一点如果payload很大header payload会在内存中额外复制一份。对超大消息可以分两次sendall第一次发头部第二次发正文。对小消息来说合并一次发送反而能减少系统调用次数两种方式都有道理按场景取舍就好。4.4 实际运行中的两个经典坑第一个坑是recv的缓冲区大小。很多人喜欢recv(4096)甚至recv(65536)以为越大越好。其实一次recv返回的字节数由内核决定不取决于你给的缓冲区上限给太大也不会让你多读。关键是不要假设一次recv刚好是一条消息一定要按上面的循环去凑。第二个坑是长连接上服务端主动推送与客户端请求混在一个socket里。如果客户端在同一时间发起请求A服务端又推送了消息B客户端一次recv可能同时拿到A和B的响应。这时候如果按请求号去匹配响应就会发现对不上号。解决思路通常是在消息头里增加消息ID字段客户端按ID去匹配而不是简单地发一条、读一条。五、断开连接的正确姿势四次挥手背后的资源回收连接建好了数据也发完了接下来就要说再见了。断开这件事远没有你想的那么简单而且它是线上连接泄漏的重灾区。5.1 四次挥手与TCP资源残留TCP的关闭是四次挥手主动关闭方发FIN对端回ACK对端再发FIN主动方回ACK。之后主动关闭方会进入TIME_WAIT状态等待2个MSL报文最大生存时间才彻底释放连接。TIME_WAIT不是可有可无的它的意义有两个一是保证最后的ACK如果丢失还能重传二是防止旧连接里的延迟报文串到复用的新连接中。所以这是协议的正确行为不是bug。问题在于如果服务端频繁主动断开会积累大量TIME_WAIT连接占用本地端口和内核资源。由于四元组源IP、源端口、目标IP、目标端口里源端口有限大量TIME_WAIT可能导致新连接无法建立。处理思路有几个长连接复用来避免频繁建连/断连调小TIME_WAIT等待时间打开net.ipv4.tcp_tw_reuse允许复用处于TIME_WAIT的端口仅对主动发起连接的一方有效。我一般不推荐盲目改内核参数优先做连接复用收益更大。5.2 CLOSE_WAIT堆积一台服务器悄悄变老的过程与TIME_WAIT相对的服务端最常见的异常是CLOSE_WAIT堆积。它的出现机制很机械对端先发了FIN你已经收到了说明对方要关闭连接你回了一个ACK此时本地连接进入CLOSE_WAIT。接下来如果应用代码一直没有执行close()或shutdown()连接就一直停在CLOSE_WAIT。我见过最典型的案例是这样一个bug服务端每收到一条消息就开一个线程去处理处理完后线程退出但conn既没close也没被垃圾回收。压测时客户端主动开了几千个连接发几条消息就断开。服务端一个个进入CLOSE_WAIT没人去关肉眼可见地连接数蹭蹭往上涨最后文件描述符耗尽新连接全部失败。排查方法很简单ss -ant | grep CLOSE_WAIT | wc -l数量持续增长就基本坐实了。修复也简单无论是异常还是正常路径都必须保证close被执行到——用try/finally或者with上下文管理不要让关闭连接这件事散布在各处。5.3 优雅关闭shutdown和close不是一回事close是释放文件描述符shutdown是禁止收发数据。如果我想告诉对端我不会再发数据了但还想读你后续发来的数据得用sock.shutdown(socket.SHUT_WR)SHUT_WR之后本端会发送FIN但接收端还是打开的你依然能recv。这在有些协议里是必须的比如客户端发送完请求后主动半关闭服务端读完请求就知道客户端没有更多数据了可以安心处理并返回响应。如果用SOCK_STREAM实现半双工交互这个API是绕不开的。我在实际项目中还发现关闭连接时要注意正在阻塞的recv。如果某个线程阻塞在recv上另一个线程执行shutdown或close阻塞的recv通常会抛异常或返回空行为在不同系统上有差异。设计多线程下的连接关闭时最好先shutdown让阻塞中的I/O唤醒再close释放资源顺序不要搞反。六、性能调优TCP选项与超时策略的取舍到了这一步一个TCP请求实现已经能跑、能传、能断、能处理异常了。但线上环境要稳快省还得在TCP层做几处调优。这些选项对业务的影响往往比你在应用层优化算法更直接。6.1 Nagle算法与延迟确认小请求为什么莫名变慢TCP有个默认算法叫Nagle当连接上还有未确认的小数据包时新产生的小数据包会被合并缓存等到前面的数据被确认后再一起发送。它的目的是减少网络里的小包数量对交互式协议却非常不友好。另一个角色是延迟ACK接收端收到数据后不会立刻回ACK而是等一小段时间通常40ms左右期待能和要发送的数据一起捎带回去。当这两者撞在一起经典故事出现了——客户端发送两次小请求第一次数据过去后服务端延迟ACK客户端这边Nagle又在等ACK才发第二段于是一次原本几毫秒的交互硬生生被拖成40ms。解决办法是在不需要Nagle的场景设置TCP_NODELAYsock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)注意这不是无脑开的。TCP_NODELAY会把所有小包都立即发送对高频小请求场景会增加大量报文数反而加大网络开销。我的一般原则请求-响应式的短交互比如RPC开它大批量流式传输比如文件下载保持默认。6.2 接收缓冲区和发送缓冲区别在一个参数上死磕内核为每个TCP连接分配了发送缓冲区和接收缓冲区。调大接收缓冲区能提升大流量场景的吞吐但缓冲区过大会增加内存占用、放大延迟问题调小则可能触发频繁流量控制降低吞吐。一个稳妥的做法是先基准测试再微调。看不到明确瓶颈时不要动它们。sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 262144) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 262144)我还想强调一个很多人忽略的点这些socket选项必须在connect或listen之前设置否则不生效。设完还可以用getsockopt读回来确认内核没有因为系统上限帮你截断。6.3 超时设置的层次连接、读写、重试三层各管各的做网络请求实现超时是最容易被拍脑袋的。我建议至少分三层考虑连接超时TCP建连最多等多久。内网通常1~3秒跨公网往往5~10秒。读超时开始读数据后多久等不到下一个字节就放弃。注意读超时和总耗时是两码事。总耗时超时从发起请求到收到完整响应的总时间必须在应用层自己计时否则任何一层单独超时都可能让请求无限拖下去。就我经历的一次教训某个后台服务调下游时只设了连接超时没设读超时。下游偶发接受连接但不回包结果这个服务的一堆线程全部挂在recv上最终线程池被打满整个服务失去响应。而那之后我才养成一个习惯——无论用什么语言、什么框架请求的总超时永远是第一优先级连接超时、读超时再往下排。在Python里socket.settimeout是应用层最简单的手段但它影响的是单次系统调用单次recv不是整个业务的请求时长。所以严谨的做法是记录开始时间每次收完消息校验一下总耗时超了就主动放弃把连接交给上层做回收或重试。6.4 KeepAlive长连接上的隐形心跳长连接最怕的不是断开而是半开连接——比如对端崩溃、断电、中间设备静默丢弃连接TCP自己并没有感知。SO_KEEPALIVE可以定时发探测报文默认参数比较保守通常2小时起步不满足需求时可以让内核或者应用层的心跳机制来补充。sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)如果keepalive参数太慢应用层可以自己做心跳每隔几秒发一个ping消息对端必须在期限内回pong否则判定连接不可用。这已经是另一个话题了但记住一点判定连接是否健康永远比修复坏连接更重要。因为只要检测得够早重试新连接的开销通常完全可接受。关于TCP请求实现的最后一点经验文章到这里一张完整的TCP请求实现地图已经展开了从socket原语出发经历了三次握手和队列处理了粘包半包搞清楚了断开时的资源回收也拨开了TCP选项的迷雾。写的过程中我一直想起早年那些被线上问题追着跑的日子很多困扰回过头看都是因为一开始没有建立起TCP是一个连接不是一个请求的基本画面。如果让我给一个最朴素的实践建议那就是每个网络请求的实现首先要写清楚连接生命周期和消息边界其次才是功能和性能。连接是谁建立、谁关闭异常路径是否释放了句柄软件包到达对端后以什么单位被切分、又靠什么字段被重组——这些想清楚一个请求就算做得扎实了。以后当你再遇到线上偶发超时连接数暴涨消息对不上号时可以试着像拆积木一样从链路最底层逐步排查。相信我问题大概率就藏在这些细节里。