ARTICLE DETAIL

资讯详情

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

TCP协议与Socket编程实战:从三次握手到粘包排查

TCP协议与Socket编程实战:从三次握手到粘包排查 干了这么多年网络编程每次带新人都会发现同一个问题很多人能把“三次握手、四次挥手”倒背如流可一让他写个TCP聊天程序就懵。理论知识背了一堆到了排查问题时依然无从下手。这篇东西我想换个讲法把TCP协议和Socket编程放在一起看从协议动机讲到API设计再落到一份能直接跑起来的Python代码上最后把我在实际开发里撞过的坑都摊开说。适合刚学网络编程的同学也适合写过一些代码但始终没把“协议”和“代码”对应起来的同行。1. 先把TCP的“人格”看清1.1 TCP是一套“签收确认”的运输协议要理解TCP不能只看它是“面向连接的、可靠的、字节流协议”这个定义。你得先搞清楚它在现实中到底解决什么问题。打个比方IP协议像快递公司的普通快递员他把货扔到你门口就走了货有没有丢、有没有被雨淋了他不管。而TCP是那个非要你当面签字、还要拍照留底才肯走的快递员。这个“签字确认”的过程在TCP里就是序号Sequence Number、确认号Acknowledgment Number、超时重传Retransmission这套机制。发送方给每个字节编上号每发出去一段数据就启动一个定时器对方收到后回一个确认号“我从你发的第X个字节之后的下一个字节开始要。”发送方如果在规定时间内没收到确认就认为数据丢了或者错了重新发一遍。我刚开始学的时候也觉得很绕为什么非要字节编号后来被坑过一次就明白了。网络里没有“一条消息”的概念只有“一串字节”。你调用send函数往里塞了100个字节TCP很可能把它拆成两个包发过去也可能和上一条数据拼在一个包里。如果不在字节级别打编号接收方根本分不清哪些字节是新的、哪些是重传的。1.2 不只是可靠TCP还管“速度”如果说可靠传输是TCP的底线那么流量控制和拥塞控制就是它的“油门”。这两个词原则上一句话就能讲完接收方的接收能力决定了发送方能发多快网络路径的承载能力决定了发送方能发多快。但实现起来很有意思。流量控制靠的是“滑动窗口”。接收方在每次确认时会通告自己的剩余缓冲区大小也就是“我还剩多少地方放数据”这个值叫Window Size。发送方根据这个窗口值决定一口气能发多少数据不能超过否则就会把对方的缓冲区塞爆。拥塞控制则是另一套逻辑。它不关心对方能不能接住关心的是“网络上是不是已经堵死了”。TCP的经典做法是慢启动一开始只发一个段收到确认后就翻倍指数增长直到撞上阈值或者出现丢包再退回来。很多人觉得慢启动太保守可现实是如果每个连接一上来就用满带宽狂发路由器早就被塞崩溃了。这里有一点必须提TCP里80%的疑难问题最后都出在你是“不知道数据丢了还是对方处理不过来还是网络本身堵了”这三件事上。理解滑动窗口和拥塞控制的区别排查时才不会乱。1.3 三次握手和四次挥手不只是“礼貌”现在我讲最常见的部分。三次握手客户端先发SYN服务端回了SYNACK客户端再回ACK连接建立。经常有人问为什么不能两次因为TCP要同时确认双方的发送能力和接收能力都是通的。第一次握手服务端知道自己能收到数据但不确定自己发出去的数据对方能不能收到。第二次握手客户端知道自己发的能被收、自己也能收对方的但服务端还不确定自己发的有没有被收到。所以必须有第三次客户端告诉服务端“你的数据我也能收到”。四次挥手则是因为TCP支持半关闭也就是允许一端停止发送但仍然接收。主动关闭方发送FIN对方回一个ACK表示知道了然后对方把自己这边还没发完的数据继续发完再发FIN最后主动关闭方回ACK连接才真正断开。中间那两步不能合并。我推荐你记住这个状态流转主动关闭方断开后会进入TIME_WAIT状态等两个最大报文段生存时间。这个状态很关键后面讲“端口占用”问题时会反复见到它。很多新手写服务端程序改完代码重启进程时报“Address already in use”十有八九就是TIME_WAIT里的旧连接占着端口。2. 从协议到Socket操作系统替你藏了什么2.1 Socket就是操作系统给你开的“邮局窗口”协议是抽象的代码是具体的。连接两者的是Socket。我的理解是操作系统内核里已经实现了TCP协议栈的完整逻辑——组装报文、重传、窗口管理、状态机全都在内核里跑。那么问题来了应用程序想用TCP这辆货车运东西怎么跟内核打交道Socket就是内核开给应用程序的“装卸窗口”。程序员调用socket、bind、listen、connect、accept、send、recv、close这些API实际上是在命令内核帮我建立连接、收发数据。你不需要自己拼TCP包头内核全包了。这种设计有一个直接后果同一个TCP连接从用户程序的角度看就是“一个能读能写的文件描述符”。你能读、能写、能关逻辑跟操作文件一模一样。这也是为什么很多Socket代码看起来那么像文件读写。2.2 Socket API的全流程逐一拆开讲写TCP服务端时必须走的完整链路是这样的socket()创建socket指定地址族、套接字类型和协议。bind()把socket绑定到本机IP和端口。这一步决定“我从哪个门收快递”。listen()把主动socket变成被动监听socket内核开始接受外部连接。这个调用第二个参数就是backlog后面详说。accept()从已完成握手的连接队列里取出一个连接。注意取出来后你得到的是一个“新的socket”专门服务这个客户端。recv()/send()在已建立的连接上收发数据。close()关闭连接。客户端侧短得多socket()创建socketconnect()发起连接。connect都是阻塞的它会一直等到三次握手完成或超时失败。上面这段流程我说得很赶因为代码才是主角。不过有几个细节想单独拎出来讲因为它们太容易踩坑了。2.3 容易被忽略的四个Socket细节第一个是listen的backlog参数。它决定内核为这个监听socket维护的未完成连接队列和已完成连接队列有多大。如果客户端连接来得太猛backlog太小多余的连接会被内核直接丢掉客户端表现为connect超时或连接重置。生产环境里这个值不能拍脑袋填得根据预期的并发连接率来设置。第二个是半关闭API shutdown。close会立刻把socket标记为关闭导致未发的数据被丢弃。而shutdown可以将某一方向的数据流单独关掉对方能感知到“这一端不再发数据了但还是能收到数据”。写一些应用层协议时比如客户端发完请求就要告诉服务端“我没有更多数据了该响应了”靠的就是半关闭。第三个是TCP_NODELAY它关闭的是Nagle算法。Nagle算法会把零碎的小包合并成一个大包再发提升带宽利用率。但代价是延迟变高你send两次10字节的数据第二次可能要等第一次的确认回来了才发。做网络游戏或者实时交互程序时这个延迟是致命的所以一般都会把TCP_NODELAY打开。第四个是SO_REUSEADDR。这个选项允许新启动的进程绑定到处于TIME_WAIT状态的老连接使用的端口。服务端想重启又不想干等几十秒就得在bind之前设置它。注意这不等于是占着端口不放的程序能被抢走它只对TIME_WAIT状态的连接生效。3. 代码实战从零写一个TCP回显服务3.1 目标与准备工作下面进入动手环节。我用Python来演示因为它标准库足够简洁能把“网络逻辑”本身突显出来不会被内存管理、指针这些细节干扰。如果你用Go、Java或者C流程完全一样只是语法不同。我们的目标是写一个服务端监听到TCP连接后把客户端发来的数据原样发回去。客户端连上来后连续发几条消息并打印响应。整个过程能完整覆盖socket的标准API调用。不需要装任何第三方库Python 3.6以上版本就行。我用的是Python 3.10。3.2 服务端代码import socket SERVER_HOST 127.0.0.1 SERVER_PORT 9000 def main(): # 1. 创建TCP socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 允许端口复用方便调试重启 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 3. 绑定地址和端口 server.bind((SERVER_HOST, SERVER_PORT)) # 4. 开始监听backlog设为8 server.listen(8) print(f[*] 服务端已启动监听 {SERVER_HOST}:{SERVER_PORT}) try: while True: # 5. 接受新连接返回一个新的socket和客户端地址 client_sock, client_addr server.accept() print(f[] 客户端已连接: {client_addr}) # 这个例子是单线程处理同一时间只管一个客户端 handle_client(client_sock) except KeyboardInterrupt: print(\n[*] 服务端被手动停止) finally: server.close() def handle_client(client_sock: socket.socket): with client_sock: while True: # 6. 收数据缓冲区大小设为1024字节 data client_sock.recv(1024) if not data: # recv返回空字节串说明客户端正常关闭了连接 print([*] 客户端断开连接) break print(f[←] 收到数据: {data!r}) # 7. 原样返回 client_sock.sendall(data) print(f[→] 已回显: {data!r}) if __name__ __main__: main()这段代码有几点要说明。recv调用会阻塞直到有数据到达。它返回的是bytes对象。如果对方关闭了连接recv会返回空字符串 b这是“对端正常断开”的唯一可靠信号务必记住。有些新手用异常来判断断开不能完全依赖。sendall和send不一样。send一次最多只能发送缓冲区允许的数据量如果数据量大可能只发出去一部分。sendall会循环发送直到全部发完。在这个例子里数据量小用send也没问题但养成用sendall的习惯能少踩坑。3.3 客户端代码import socket SERVER_HOST 127.0.0.1 SERVER_PORT 9000 def main(): # 1. 创建socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: # 2. 发起连接这里会经历TCP三次握手 client.connect((SERVER_HOST, SERVER_PORT)) print(f[*] 已连接到 {SERVER_HOST}:{SERVER_PORT}) messages [bhello tcp, bhello socket, bbye] for msg in messages: # 3. 发送数据 client.sendall(msg) print(f[→] 发送: {msg!r}) # 4. 接收回显 recv_data client.recv(1024) print(f[←] 收到: {recv_data!r}) # 简单print停顿方便观察抓包 input(按回车继续...) finally: client.close() print([*] 客户端已关闭) if __name__ __main__: main()客户端这边的connect调用会触发三次握手。如果握手失败会抛ConnectionRefusedError。下面运行的时候你会看到服务端每accept一次内核其实已经替我们把握手完成了accept只是从队列里把连接“捞”出来。3.4 跑起来验证一遍先启动服务端python server.py再开一个终端启动客户端python client.py运气好的话服务端立刻打印出客户端连接信息客户端每发送一条消息服务端打印收到数据并回显客户端接着打印收到的回显。整个过程就是这样一组看起来非常简单、但底层横跨协议栈的循环。如果想快速验证端口在监听可以用nc命令nc -vz 127.0.0.1 9000输出“succeeded”之类的内容就说明TCP端口已经能被外部访问到。3.5 用Wireshark看一次完整的TCP生命周期只让程序跑通是不够的你要亲眼看到三次握手长什么样才叫真正入门。打开Wireshark选择lo回环网卡在过滤栏输入 tcp.port 9000然后重新启动客户端连接一次。你会看到一列干净利落的TCP报文。第一条是客户端发到服务端的包SYN标志位为1Sequence Number是一个随机生成的初始序号。第二条是服务端回给客户端的包SYN和ACK同时为1同时带上了自己的初始序号并把确认号设为“客户端的初始序号1”。第三条是客户端回给服务端的包只有ACK确认号是“服务端的初始序号1”。这三条包就是三次握手在网络上留下的真实轨迹。断开连接时再看一次你会看到FIN。前面我讲的状态机在这里都会以实体的形式出现在你的屏幕里。到了这一步理论知识才真正变成了你的感官经验。我建议你反复连几次配合ipython里import socket手动connect保证你能精确说出某一条包的TCP标志位含义。4. 我在实际开发里踩过的TCP坑4.1 “bind: only one usage of each socket address” 到底在说什么这个报错应该是全网出现频率最高的TCP错误之一。完整表述类似 error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket addre后面的“address already in use”才是重点。它的意思是你想绑定的IP和端口已经被其他socket占用了。出现这种问题有三种可能。第一种进程没退出端口还被占着。你ps aux 查一下那个进程kill掉就行。第二种进程退出了但连接还停留在TIME_WAIT状态端口要过一会儿才释放。这种情况就用setsockopt开启SO_REUSEADDR。第三种你跑了两个相同配置的服务抢同一个端口。这种纯属配置错误只能改端口或停掉多余的服务。Windows下会报“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”本质一样。排查时先查进程再查端口占用最后确认自己的配置。不要一上来就怀疑系统。4.2 Connection Reset by Peer 和 Connection Refused 别搞混这两个错误让人极其头疼但含义天差地别。Connection Refused发生在连接建立阶段。你connect一个没有进程监听的端口内核直接回一个RST包客户端收到后抛ConnectionRefusedError。翻译成人话就是门没开快递员敲了半天没人接。Connection Reset by Peer则发生在连接已经建立之后。常见触发场景是服务端进程崩溃或者服务端直接close了一个带未读数据的socket内核就会发RST。客户端这边再读或再写就会收到“TCP连接被对端重置”的异常。最典型的错误是curl报“curl: (35) TCP connection reset by peer”通常也是服务端非正常关闭导致的比如业务崩溃、负载均衡健康检查主动断开。排查思路是看服务端日志有没有panic有没有线程池拒绝有没有网关设置过短的读超时。4.3 数据库连不上mysql.sock 相关的连环坑很多做Web开发的同学遇到 error 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock第一反应是数据库挂了。但实际上MySQL的本地连接走的是Unix domain socket它本质也是一种socket只是不走TCP/IP而是通过文件系统通信。常见问题是mysqld启动时指定了socket路径为/var/run/mysqld/mysqld.sock但目录不存在或没权限mysqld创建不了socket文件。另一种是客户端默认找/tmp/mysql.sock服务端却把socket放在其他地方两边对不上。像 mysqld_safe directory /var/run/mysqld for Unix socket file dont exists 这个报错基本就是“父目录不存在”。解决办法很简单手动创建目录并授权给mysql用户或者把socket路径统一到/tmp/mysql.sock并在my.cnf里同时修改服务端和客户端配置。这种事看起来是MySQL运维问题根子却在socket编程的基础概念上。4.4 ADB 5037端口daemon起不来的乌龙开发安卓时经常见这种报错* daemon not running; starting now at tcp:5037 could not read ok from adb server。它的含义是ADB服务进程想把自己绑定到本机5037端口但碰到异常。很多次我排查到最后发现是因为Docker容器、微信开发者工具、其他安卓工具链抢先占用5037端口。你去看报错全文如果已经有一个adb server在跑新版adb和旧版adb协议不通也会出现“could not read ok”。解决方法是找到占用5037的进程kill掉再adb kill-server; adb start-server。如果还不行就检查环境变量里有没有多个不同版本的adb被同时调用。表面上看这是安卓工具链的问题实际上也是“同一端口只能被一个进程监听”这个TCP基础规则的又一次活体展示。4.5 粘包与半包新手最容易问歪的问题很多初学者都会问一个问题TCP协议有消息边界吗答案是没有。这直接导致两类现象粘包多组数据粘在一起被一次读到半包一组数据被拆成几次读到。很多项目里客户端发一条JSON服务端recv一次没拿到完整JSON就开始报解析错误。处理方案无非三类。第一固定长度每条消息都凑成一样的长度读到足够就解析。第二分隔符比如用\r\n或者自定义的终结符读到就截断。第三长度前缀每个消息前面用2字节或4字节表示体长先读长度再按长度读消息体。最通用的还是第三种几乎所有二进制协议都在用。说到这不得不提另一种常见情况在嵌入式环境下用lwIP做TCP断连频繁。一般来说不是有人抢端口而是嵌入式设备功耗管理把网络栈关了或者路由NAT老化把连接踢了又或者设备的TCP keepalive没开导致闲置连接被中间设备回收。这些问题的排查思路本质上还是回到“谁先走了、为什么走”这条主线上。4.6 “就是连不上”时我惯用的排查顺序当你看到socket is not initialized、create socket connection failure这类笼统报错时别急着深挖。先按顺序确认第一服务端进程在不在端口有没有在监听。第二本机能连远程不能连查防火墙和安全组。第三能连接但握手超时抓包看SYN包有没有发出去、有没有响应。第四连接建立后立刻断看服务端日志和网络设备有没有主动介入。这个顺序从我第一次实习开始用到现在一直没翻过车。网络排错最忌讳的就是东一榔头西一棒槌。5. 拐回去再说几句实在话代码写多了你会发现网络编程绝不只是在写代码更多时候是在“判断意图”和“应对故障”。TCP的可靠是用一整套复杂的确认、重传、超时、窗口机制换来的它给应用层提供的是字节流能力不是消息边界。理解了这一点你就能解释很多“看起来很奇怪”的现象为什么接收方会一次收到两段数据为什么本地连接断了自己不知道为什么端口总是被占用。这三个问题搞透一个中级网络开发者的底子就算打扎实了。如果你接下来往上深入建议优先读三样东西TCP/IP详解第一卷、Wireshark官方文档、你常用语言标准库里的socket模块源码。前两样帮你补内功第三样帮你落地。尤其是抓包别嫌麻烦我见过太多人凭感觉调试网络结果被真相狠狠拍了一巴掌。与其那样不如把时间花在抓包上数据包不会撒谎它比任何日志都诚实。
返回列表