ARTICLE DETAIL

资讯详情

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

Python网络编程实战:从socket到TCP粘包拆包与多线程并发

Python网络编程实战:从socket到TCP粘包拆包与多线程并发 1. 网络编程到底在学什么先想清楚再动手很多朋友一提到网络编程脑子里浮现的全是晦涩的术语TCP三次握手、滑动窗口、socket套接字、IO多路复用……还没开始就先被劝退了一半。我在早期带新人时经常说一句话网络编程不是一个孤立的知识点它是一整套怎么让程序之间说话的办法。你写的服务端和客户端本质上就是两个进程之间要完成一次可靠的对话只不过这个对话发生在网络上不再是你本机同一个进程里函数调用来调用去那么简单。说人话就是你写单机程序时A函数调用B函数数据通过内存直接传。但网络编程里客户端在深圳服务端在上海数据要走一根看不见的链路中间隔了交换机、路由器、运营商机房。你没有办法拿到对方的内存地址只能通过一个叫socket的东西按照约定的协议把数据以字节流的形式发送过去。这就是网络编程的底层基本盘。那这次要聊的就是这条路线上最关键的一个落点用Python做socket网络编程。为什么选Python因为语法干净API设计得又简单又直观特别适合用来理解网络编程的骨架。等你用Python把socket玩明白了再去碰Java NIO、C的libevent、Go的goroutine网络模型会发现底层全是同一个道理只是换了一门语言来表达而已。这篇内容比较适合三类人一是刚学完Python语法、想往服务端方向走的新手二是后台开发但一直没系统捋过socket细节的工程师三是做测试、运维平时经常需要自己写个小脚本做端口探测或模拟客户端请求的实践者。我会尽量不堆术语每一步都告诉你为什么要这么做以及我踩过的坑在哪里。2. 环境与工具准备一个最小可跑的起点2.1 本地环境怎么搭最省心如果你电脑上从来没装过Python环境直接去官网下载Python 3.10以上版本安装时记得勾选Add Python to PATH。这一步非常重要我刚入门的时候就是忘了勾选后面每次执行python命令都要输一长串路径心态直接崩掉。装完以后打开终端输入python --version能正常打印版本号环境就算通了。代码上我不建议一上来就装Flask、Django那些Web框架我们这一阶段的目标是理解网络通信的原始形态而不是被框架封装掉的便利性。框架当然好用但框架把你的数据封装成HTTP请求你反而看不到socket在最底层是怎么收发数据的。先把裸socket跑通再回来看框架你会觉得那些东西都是纸糊的一眼就能看穿。准备两个终端窗口可以是你电脑上开两个终端tab也可以一个终端加一个VS Code内置终端后面要同时跑服务端和客户端。端口上我习惯用8000、9000这类比较常见的端口但注意避开已经被占用的端口Windows下可以用netstat -ano | findstr 8000去查Linux/macOS用lsof -i :8000。2.2 关于协议的选择先吃透TCP再考虑别的网络编程的协议选择第一次接触的人很容易迷失。UDP快、开销低但数据可能丢包可能乱序TCP慢一点要握手要确认但可靠。对新手我强烈建议从TCP入手因为TCP收发的行为更符合你线下聊天的直觉对方收到了你才放心偶发堵一下、等重传这些都是可以观察到的现象。UDP适合视频流、游戏同步那类场景但那是在你已经吃透TCP模型之后再去扩展的知识点不是起步阶段该碰的。TCP还有一个好处错误信息特别丰富。连接被拒绝、超时、对端关闭连接这些错误在调试的时候都能从异常类型里看出来。Python的socket模块已经把底层错误包成了socket.error、ConnectionResetError、TimeoutError这些异常类你在except里能拿到第一手信息排查起来非常直观。UDP的坑就比较隐蔽它没有连接这个概念你发出去的数据包丢了代码里完全没有任何异常你只会发现对端一直没回应排查半天都找不到原因。2.3 核心概念预习端口、IP、协议栈在写第一行socket代码之前先花5分钟把三个概念捋清楚。IP地址解决的是数据要送到哪台机器每一台联了网的设备都有一个IP就像你家门牌号。端口解决的是数据要交给这台机器上的哪个进程同一台服务器上可能同时跑着Web服务、数据库服务、监控服务它们通过端口来区分这就是为什么HTTP默认走80MySQL走3306。协议解决的是数据按什么规则处理TCP/UDP就是两种不同的快递规则TCP是三通一达要签收、可追溯UDP是闪送够快但丢了没人管。这三个概念搞清楚了你再看socket它就是操作系统提供给应用层的一套接口。你的程序调用socket()创建套接字然后bind()绑定端口connect()发起连接listen()监听accept()接受连接——每一步都对应着一次真实世界的场景行为。理解了这些你写代码的时候就不会觉得只是死记API了。3. 从零写一个TCP通信程序服务端与客户端3.1 第一个服务端绑定端口、监听、接受连接我们直接上代码边写边解释。这个服务端的逻辑很简单创建一个socket绑定到本机的某个端口监听进来的连接然后接受连接、收数据、原样发回去。你可能觉得回显服务太简单了但它把TCP服务端最核心的骨架全包含在内。import socket server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((127.0.0.1, 8000)) server_socket.listen(5) print(服务端已启动等待客户端连接...) while True: client_socket, client_addr server_socket.accept() print(f收到来自 {client_addr} 的连接) data client_socket.recv(1024) print(f收到数据: {data.decode()}) client_socket.sendall(f服务端已收到: {data.decode()}.encode()) client_socket.close()这里逐行拆解。socket.socket(socket.AF_INET, socket.SOCK_STREAM)表示创建一个IPv4的TCP套接字AF_INET是IPv4地址族SOCK_STREAM是流式套接字。如果是UDP就换SOCK_DGRAM但我们现在只说TCP。setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)这个很多人容易忽略它的作用是让服务端在重启时快速复用TIME_WAIT状态下的端口少了这行你可能一关掉程序再启动就报Address already in use错误。bind((127.0.0.1, 8000))是绑定本机的IP和端口。这里用127.0.0.1也就是只监听本地回路意思是只有你自己这台机器能连局域网里的其他机器连不上。如果你希望局域网内可连这里要绑定0.0.0.0表示监听本机所有网卡上的连接。listen(5)的含义经常被误解很多新手以为5代表最大连接数实际上它是系统内核中挂起的连接队列最大长度超过这个数量的连接会被内核拒绝。accept()是阻塞函数代码会停在这一行直到有客户端主动连上来。它返回两个值client_socket和client_addr前者是操作系统为这次新连接专门创建的一个套接字用于和这个客户端单独通信后者是客户端的IP和端口。这里要注意之后收发数据用的都是client_socket而不是原来那个server_socket。3.2 第一个客户端主动发起连接、收发数据服务端写完客户端更简单。客户端不需要bind也不需要listen它只需要创建socket然后connect到服务端的地址。import socket client_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect((127.0.0.1, 8000)) message 你好服务端我是客户端 client_socket.sendall(message.encode()) response client_socket.recv(1024) print(f服务端回应: {response.decode()}) client_socket.close()connect的时候系统会完成一次TCP三次握手。三次握手这个名词听着唬人其实就是客户端和服务端各发一个确认包确认双方都活着、都愿意建立连接。你在代码层面看不到这个过程但connect返回的成功与否就代表握手的结果如果对端没有监听这个端口connect会抛ConnectionRefusedError如果中间链路有问题可能卡一段时间然后抛TimeoutError。运行的时候注意顺序先启动服务端再启动客户端。服务端启动后停在accept()等待客户端启动后主动连接然后发送数据服务端收到数据后原样返回客户端收到回应后关闭套接字程序结束。整个过程走一遍你就看到了TCP通信的完整闭环。我建议你把两个终端并排放在屏幕上左边跑服务端右边跑客户端亲手执行一次看到两边的打印输出比你看十篇博客都有用。3.3 为什么服务端响应一次就退出这行代码暴露的问题上面那段服务端代码有一个明显问题while循环每次accept到一个客户端处理完一单就进入下一次循环看起来好像能接受多个客户端但实际上每次只服务一个客户端而且每次处理完就关闭连接客户端想发第二条消息就得重新连一次。这在真实场景中是行不通的。一个生产环境下的服务端需要同时服务大量客户端比如一个聊天服务器可能同时有几千人在线。怎么解决两个方向一是使用多线程每个连接分配一个线程去处理二是使用异步IO用事件循环在单线程内切换处理多个连接。这两个方向后续都可以深入但现在先别着急一个客户端一个服务端的模型已经足够你理解socket的核心操作了。我见过很多新手卡在这一步以为服务端只能处理一个连接服务端一旦循环accept就认为这是最终方案了。其实单线程阻塞式accept只是入门教学模型它的价值在于让你看清一次完整交互的每一步。你心里要清楚这个模型是演示用的不是生产级的。4. 实操过程中的三个大坑粘包、拆包与阻塞4.1 粘包与拆包这不是bug这是TCP特性如果你只是把上面的代码跑一遍大概率看不到粘包问题。但如果你把sendall调用两次比如连续发送两条消息你在接收端会发现两条数据是连在一起收到的。这就是所谓的粘包。粘包的本质是TCP是一个字节流协议它不关心你发送的数据边界在哪里只保证字节序正确。你把两个字符串依次写入连接中间没有任何分隔符号接收端收到的是连在一起的字节流。它不知道第一个字符串在哪里结束第二个字符串从哪里开始除非你用特殊的方法把边界画出来。拆包就是反过来的问题你发送一个很大的数据块比如10MBTCP不可能一次把这10MB都交给应用层它会根据发送缓冲区和MTU限制把数据切成一片一片地发送接收端要做的是把这10MB重新拼起来。所以recv(1024)的1024只是一个单次接收的最大长度不代表你调用一次recv就能拿到一整条完整消息。我在实际开发中处理粘包最常用的方案是消息头消息体在每条消息前面固定用4个字节放消息体的长度接收端先读4字节拿到长度再按长度去读消息体。这种方案看起来简单但几乎所有应用层协议都是这么干的HTTP的Content-Length头也是这个思想。4.2 recv阻塞与超时服务端卡死不响应的真相recv()是阻塞的这是新手最容易忽略的一点。上面的服务端代码里accept()是阻塞的recv()同样也是阻塞的。如果客户端连接上之后一直不发数据服务端就会永远停在recv()那一行后面什么事都干不了。这就是单线程模型最大的软肋。你可以做一个实验把服务端跑起来然后启动客户端但不要发消息你会在服务端终端看到它已经accept了连接但程序卡在recv上。这时候你再想启动另一个客户端去连接服务端会发现服务端根本没有响应因为主线程被第一个连接占住了。解决思路有两个。一是设置超时client_socket.settimeout(5.0)如果5秒内没有数据recv会抛出一个socket.timeout异常你可以捕获后决定是继续等还是放弃连接。二是换线程模型用threading模块给每个client_socket创建一个新线程去处理主线程只负责accept这样即使某个客户端不发数据其他客户端也能照常通信。4.3 客户端断线检测你以为对方走了其实只是没发数据网络编程里一个经典陷阱是如何判断对端已经断开连接。你可能会说那还不简单recv返回空数据不就代表断开了吗这个理解对了一半。如果对端正常close()recv确实会返回一个空字节串b这确实是对端主动关闭信号。但问题是对端的程序可能因为网络故障突然掉线网线被拔了、对面机房断电了这些情况TCP协议本身是察觉不到的。因为TCP的关闭检测依赖于收到FIN包或RST包如果链路断了这些包根本传不到。你本地看到的recv仍然是阻塞状态你对端已经不存在了但你的socket认为连接还活着。所以生产环境下的服务端都会做心跳检测每隔一段时间发一个心跳包如果连续几次收不到对端的响应就判断连接已经死亡主动清理掉这个socket。这个在底层细节上涉及KEEPALIVE和心跳机制现阶段你只需要知道recv返回b是正常断开但要加心跳检测才能应对异常断线。5. 进阶实操多客户端并发与缓冲区处理5.1 用threading实现多客户端并发突破单连接限制最直接的手段就是多线程。主线程循环accept每接到一个连接就把client_socket扔给一个新线程去处理主线程立刻回到accept等待下一个。import socket import threading def handle_client(client_socket): try: while True: data client_socket.recv(1024) if not data: print(客户端已断开连接) break message data.decode() print(f收到消息: {message}) client_socket.sendall(f服务端回复: {message}.encode()) except ConnectionResetError: print(客户端强制断开连接) finally: client_socket.close() server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((0.0.0.0, 9000)) server_socket.listen(5) print(多客户端服务端启动...) while True: client_socket, client_addr server_socket.accept() print(f新连接: {client_addr}) thread threading.Thread(targethandle_client, args(client_socket,)) thread.start()这段代码加了一个while循环让recv会反复接收消息直到对端关闭连接。if not data的判断很关键当客户端正常关闭连接后recv会返回空字节串它代表对方已经说完了。这时候break跳出循环执行close清理资源。多线程方案虽然能解决并发但代价是每个线程都要占用系统资源。连接数量少的时候没问题几千个连接就会开始吃力上万连接线程切换的开销会成为瓶颈。所以生产级高并发服务端往往会用异步IO的方案比如Python的asyncio、selectors模块或者是干脆换个语言换套模型。这个话题往深了讲能写一本书但现阶段你只需要知道多线程的方式在入门场景绰绰有余。5.2 缓冲区与sendall的细节别用send裸奔很多教程里会写client_socket.send(data)但我不建议你直接用send。send调用返回的数值并不一定等于你传入数据的长度尤其是在发送大文件时它会因为发送缓冲区满了而只发送一部分字节。正确做法是循环发送直到发送完或者直接用sendall。sendall内部帮你循环处理了发送缓冲区的逻辑更加安全。接收端的recv也是一样你传一个1024它最多返回1024字节但真实数据可能只剩100字节也可能更大——但这个更大会被截断剩下部分还在内核缓冲区里等着你下一次recv。所以写网络程序一定要心里有数recv拿到多少不代表消息结束更多时候你还需要额外的长度信息来做拆包。分享一个我在工作里写的简易工具函数它按照指定字节数可靠地读数据def recv_exact(sock, size): buffer b while len(buffer) size: chunk sock.recv(size - len(buffer)) if not chunk: raise ConnectionError(连接已断开数据不完整) buffer chunk return buffer这个函数读取完整size字节才返回。配合自定义消息头可以做到精准拆包。你自己写代码时也可以直接参考这个小工具比裸recv稳太多。5.3 编码问题中文为什么在另一端变成乱码网络编程里的编码坑十个有九个是中文搞出来的。上面的代码里我用的是str.encode()Python3默认使用UTF-8编码。发送端encode成UTF-8字节流接收端decode也用UTF-8格式那就是安全的。但如果你在代码里用了gbk编码发送或者接收端误用ISO-8859-1去decode中文就会变成一串乱码。调这个问题的时候我建议直接在协议层面约法三章所有文本消息统一使用UTF-8。这样接收端decode(utf-8)必然能还原。还有一种情况是消息里包含二进制数据比如文件内容这时候你不能盲目decode要明确区分文本帧和二进制帧或是在消息头里标记数据格式。别等到线上乱码了再排查那时候你连是自己的编码问题还是对端编码问题都分不清。6. 问题排查实录我踩过的坑和总结出的经验6.1 服务端启动报Address already in use怎么办如果你点击运行程序后立刻退出马上再启动十有八九会遇到这个报错。原因是程序虽然退出了但操作系统还没完全清理掉这个连接此时端口处于TIME_WAIT状态需要60秒左右才能释放。解决方案就是我前面说的那行server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)。不过要注意SO_REUSEADDR只能解决本机重启程序绑定端口的问题如果你真的有一个完全不同的第二个进程想要绑定同一个端口那是不行的一个端口只能被一个socket的bind成功绑定。如果这行设置后仍然报错先排查是不是真的有两个进程在跑用netstat或lsof找出来。6.2 客户端连不上服务端先排查基础网络客户端连不上服务端原因从上到下要捋一遍。第一步看端口你在服务端绑定了127.0.0.1客户端连的却是局域网IP那肯定连不上第二步看防火墙本机杀毒软件或系统防火墙可能拦截了非本地回环的访问第三步看监听的网卡如果你bind(0.0.0.0)客户端用本机IP去连接应该没问题如果bind(127.0.0.1)那只有回环地址能连。我见过很多新手折腾半天最后发现自己服务端根本没启动成功或者端口写错了也浑然不知。一个有用的调试技巧在服务端和客户端代码里多打印关键路径信息比如服务端启动时打印绑定端口、accept到连接时打印客户端地址客户端connect成功后打印连接成功提示。日志一打问题定位就快了一大半。6.3 数据发过去了但收不到检查接收缓冲区假设客户端sendall成功服务端recv却一直没返回先别急着重启程序。可能是你的网络有延迟也可能数据已经到服务端的内核缓冲区但还没被应用层读取。recv是应用层去内核缓冲区取数据如果数据量很小而且网络时延不高一般秒到。你可以试着在服务端recv前后加打印语句确认程序是否卡在recv之前还是卡在recv这一行。如果确认recv一直阻塞那多半是对端压根没发数据或者发了但没到服务端机器比如NAT发生问题、路由丢包之类。网络层的问题就比较深了建议先用ping确认连通性再用nc或者telnet手动模拟连一次看端口通不通把问题范围缩小再回来调代码。6.4 我认为新手最值得养成的两个习惯第一个习惯是每个socket操作都要考虑异常分支。开发时你只调通了正常路径线上环境一定会有各种边界情况客户端中途掉线、对端半关闭、收到不完整数据、发送超时。写代码时把这些错误处理都考虑进去虽然代码会多写一部分但这部分才是真正体现工程经验的地方。第二个习惯是用抓包工具去验证你的理解。如果你用的是macOS或Linuxtcpdump是对外抓包利器Windows下装一个Wireshark跑一次回显程序你能亲眼看到握手包、数据包的来回传输比代码里打印日志直观得多。我在教新人网络编程时一定会让他们用Wireshark抓一次包那次看完之后他们对TCP三次握手的理解才算真的落地。6.5 后续扩展往这个方向继续怎么走跑通了socket基础后你可以按下面这条路线继续扩展。第一步把协议服务改造成自定义的消息帧加上长度头解决粘包拆包问题第二步用selectors或asyncio把多线程服务端改成单线程异步模型感受一下高并发IO的处理方式第三步去读一遍常用协议比如WebSocket协议的握手过程或者自己在TCP上封装一个简单的二进制协议感受协议设计的取舍。如果你后续要做真正的服务端开发建议再看一遍HTTP协议在socket层之上是怎么封装的HTTP请求本质上是在TCP流上写一段符合格式的字符流服务端解析这个字符流再返回另一段字符流。你理解了这层就会明白框架帮你做了什么——明白了这些你就已经比单纯会调用框架API的开发者高一个层次了。网络编程这条路上保持好奇心和动手能力很重要。写代码卡住了属于常态别怕前面走的每一步弯路都是经验踩过几次之后你回头看会发现这些报错信息全都似曾相识解决起来也越来越顺手。
返回列表