资讯详情

资讯详情

Python Socket编程实战:服务端客户端实现、三次握手与粘包处理

1. 先从一次失败的联调说起为什么两端程序都写了却连不上我最早做简单的服务端客户端实现这个练习的时候以为最大的难点在于写出socket相关代码。结果代码照着文档抄完了运行的时候却出现了最尴尬的一幕服务端启动后安安静静等在那里客户端一运行直接报错ConnectionRefusedError。当时我盯着屏幕愣了半天明明两边代码看起来都没问题怎么就连不上呢。后来排查了半天才发现服务端监听的是127.0.0.1客户端连接的也是127.0.0.1但服务端启动时忘记把监听循环跑起来accept那行代码根本没执行到程序就提前退出了。类似这种代码存在但没有生效的问题在入门网络编程时比语法错误更隐蔽。这个练习之所以值得认真做一遍是因为它几乎覆盖了网络编程最基础也最重要的几个知识点地址与端口、连接建立、数据收发、连接关闭以及大量只有实际运行才能体会到的坑。它适合任何刚接触网络编程的读者也适合那些已经用过框架但没搞明白底层原理的人——花一两个小时把最原始的socket流程跑通后面再去看什么框架都轻松得多。我不打算只贴一份代码然后让你复制运行完事。这篇文章会把服务端和客户端每一步背后的原理、常见的坑、以及如何把能跑的版本升级成能用来排错和扩展的版本全部拆开讲清楚。代码用Python标准库的socket模块来写不需要安装任何第三方依赖你只要有一个Python环境就能跟着操作。2. 服务端实现bind、listen、accept这三步各有各的脾气2.1 创建socket时参数怎么选直接决定你用TCP还是UDP服务端第一步是创建socket对象import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM)AF_INET表示使用IPv4协议族SOCK_STREAM表示流式套接字对应TCP协议。如果第二个参数改成SOCK_DGRAM就是UDP数据报套接字协议模型完全不同。对于入门练习我强烈建议先用TCP因为它提供了有序、可靠的字节流传输你不用处理丢包和乱序可以专心理解连接模型本身。这里顺便解释一个很多人忽略的点socket并不是Python发明的概念而是操作系统提供的网络编程接口。你创建的socket对象本质上是对操作系统内核里一个网络文件描述符的封装。这就是为什么后面能把它当文件一样read、write。理解这一点后再看到recv和send就不会觉得奇怪了。2.2 bind绑对地址和端口不然客户端永远找不到你创建好socket后服务端需要绑定一个地址server.bind((127.0.0.1, 8000))127.0.0.1表示只监听本机回环地址只有本机上的客户端能连接。如果你想局域网内其他机器也能连接这里有两种选择绑定0.0.0.0表示监听所有网卡接口或者绑定具体的局域网IP地址。新手经常犯的错是写了127.0.0.1然后把客户端放到另一台机器上跑怎么连都超时这时候99%是bind地址的问题。端口选择上我个人的建议是避开80、8080、8000这些常见端口尤其在做实验时很容易和本机其他服务冲突。用8500以上、20000以下的端口没那么容易撞车。绑定之前先确认端口空闲netstat -tlnp | grep 8500如果看到输出结果里有LISTEN状态的记录说明端口已经被占用了。还有一种开发时特别常见的情况程序上次运行后端口还处于TIME_WAIT状态导致你重启服务端时报Address already in use。解决方案是在bind之前设置地址复用server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)这行代码建议写服务端时无脑加上代价几乎为零但能帮你省掉无数端口莫名其妙起不来的烦恼。2.3 listen修改排队长度并不会让服务端同时处理更多请求bind之后是listenserver.listen(2)这个数字代表内核里未完成连接队列的最大长度。很多人以为写2就代表只能连接2个客户端这是流传很广的误解。它的意思是当服务端还没有调用accept取出连接时内核最多为多少个等待中的连接请求排队。一旦超过这个数字新来的连接会被拒绝。listen之后服务端就进入了被动打开状态。这时候TCP三次握手已经可以在内核层面完成一部分但真正能让应用程序读写数据还要等到accept之后。2.4 accept一个连接一个socket千万别让server socket去收发数据accept是阻塞的。程序执行到这里会停住直到有客户端发起连接它才返回两个东西一个专门用于和该客户端通信的新socket对象以及客户端的地址信息。while True: conn, addr server.accept() print(f收到来自 {addr} 的连接) conn.sendall(bwelcome) conn.close()这里有两个必须理解的关键点。第一accept返回的conn才是真正拿来收发数据的socket原来的serversocket继续留在循环里等待新连接。你可以把server想象成前台接待员它只负责接电话然后转给对应的人自己不会去处理通话内容。第二accept必须放在while循环里否则程序处理完一个连接就退出再也无法接收下一个客户端。很多入门版本的程序只写了一次accept运行后发现只能连一次就是这个原因。从代码层面说服务端的完整流程就是socket → bind → listen → accept → 收发数据 → close。每一步都有对应的为什么把这些问号拉直之后你才算真正会写服务端。3. 客户端实现connect成功背后是三次握手失败背后往往只有两个原因3.1 客户端不需要bind但必须知道要连接到哪个地址客户端的socket创建和服务端一模一样client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 8500))客户端不需要bind因为操作系统会在connect时自动为它分配一个临时端口。connect背后发生的是TCP三次握手客户端发SYN服务端回SYNACK客户端再回ACK然后connect返回连接建立。第一次联调失败通常只有两个原因要么服务端根本没监听在你要连的地址端口上要么两端使用的地址不匹配。前者排查方式是先确认服务端程序确实跑起来了而且没有因为异常直接退出后者排查方式是检查服务端bind的IP和端口与客户端connect的IP和端口是否完全一致。把这两个问题排除之后剩下的才是网络层的问题比如防火墙拦截。3.2 send和sendall为什么我坚持推荐用sendall连接建立后客户端要发送数据。很多老教材写的是conn.send(data)但send的返回值并不可靠——它表示实际发送出去的字节数这个数字可能小于你要发送的数据长度。TCP是字节流协议操作系统可能把一次send的数据拆分成多个数据段发送所以你需要自己循环把剩余数据发完msg bhello world total_sent 0 while total_sent len(msg): sent conn.send(msg[total_sent:]) if sent 0: raise RuntimeError(连接异常断开) total_sent sent中文编程网络教程里很早就有人反复提醒这一点但新手还是容易忽略。为了避免自己写错循环逻辑我建议直接用sendallconn.sendall(bhello world)sendall内部就是帮你循环发送直到全部发出除非连接中途断开否则它会保证数据完整发出去。还有一个几乎每个人都会踩的坑socket传输的数据必须是bytes类型字符串需要先编码。如果你直接conn.send(hello)会报TypeError: a bytes-like object is required。正确写法是conn.send(hello.encode())接收端拿到字节后要.decode()转回字符串。3.3 recv的阻塞特性数据没到程序就停在那里客户端和服务端接收数据都是通过recvdata conn.recv(1024)recv是阻塞的如果对端没有发送数据程序会一直等在那里。参数1024表示缓冲区大小即一次最多读取1024字节。理解recv的关键在于两个返回值场景第一个场景recv返回正常字节数据说明收到了数据。但注意这些数据很可能只是一条完整消息的一部分需要继续调用recv读取剩余部分。具体要读几次才能拿完整条消息取决于发送端发了多少数据以及网络分割情况。第二个场景recv返回空字节串b代表对端已经关闭连接。这是TCP协议一个非常重要的语义对端正常关闭后recv不会抛异常而是返回空串。如果你收到b还继续调用recv很快就会出现ConnectionResetError之类的异常。新手不习惯这组返回值的原因在于把socket当成了一次性的API调用。但TCP本质上就是一条持续的字节管道recv是在取管子里流过来的水水流完了管道就断了。明白这个比喻很多问题就都顺了。4. 联调通过之后别急着高兴粘包、半包、断连才是真正的考验4.1 为什么客户端发了两条消息服务端收到的却是一坨数据当你第一次让客户端和服务端正常通信之后马上就会遇到新的问题发送方连续发送多条消息接收方经常一坨一坨地收到数据消息边界完全对不上。这就是著名的TCP粘包现象。粘包的根源在于TCP是流式协议它只保证字节先后顺序不保证消息之间的边界。你在应用层调用两次sendall在TCP看来可能合并成一个数据段一次送过来反过来一次大块数据可能被拆成多个数据段分几次到达。接收方的recv次数和发送方的send次数根本没有对应关系。我当年第一次被这个问题搞疯的时候一度以为是程序写错了不停地调缓冲区大小。后来才明白缓冲区大小只影响单次recv能取到多少字节无法解决消息边界问题。解决办法只有一个在设计协议时自己定义消息边界。4.2 最朴素的粘包解决方案4字节长度头最简单的可工作协议是长度头消息体发送前先把消息体的字节长度打包成4字节拼在消息前面一起发送。接收方先读4字节得到长度再循环读取直到收完对应长度的数据。import struct # 发送端 msg 这是一条测试消息.encode() head struct.pack(I, len(msg)) conn.sendall(head msg) # 接收端 head_data b while len(head_data) 4: head_data conn.recv(4 - len(head_data)) body_len struct.unpack(I, head_data)[0] body_data b while len(body_data) body_len: body_data conn.recv(body_len - len(body_data))struct.pack(I, len(msg))的作用是把长度整数打包成4字节大端序数据。这里代表网络字节序大端I代表无符号整型。用4字节表示长度最大的消息体长度是4294967295字节日常使用完全足够。这个协议虽然简陋但它帮你养成了最宝贵的习惯不要假设一次recv能拿到完整消息永远通过循环和长度判断来确认数据收齐。后面你看成熟的网络框架时会发现它们处理粘包半包的思路万变不离其宗。4.3 shutdown和close的区别优雅关闭需要两个步骤连接关闭是入门阶段最容易被忽略的环节。新手通常直接调用close却发现频繁出现意想不到的错误。原因在于close只是把socket引用计数减一当计数归零时立刻释放整个连接并不会先通知对端我要把话说完了。而shutdown能指定关闭方向conn.shutdown(socket.SHUT_WR) # 关闭写方向之后不能再发送数据 conn.close()SHUT_WR表示我把要说的话已经说完了之后本端不能再发送数据但仍然能够读取对端送来的数据。对端收到这样的信号后recv会返回b从而知道当前数据已经接收完毕。这是TCP半关闭状态的典型用法在请求-响应模型里尤其常见。举个具体场景客户端发送完请求后调用shutdown(SHUT_WR)服务端的recv立即返回b就知道请求全部收完了这时可以放心处理并返回结果客户端收到服务端的响应后再close。整个过程干净利落不会出现数据还没发完就断开的问题。如果只调用close呢在大多数简单场景下也能跑通但它相当于摔门就走——正常结束的流程是说完最后一句话听到对方说再见再挂电话。养成先shutdown再close的习惯在长连接场景下能省掉大量排查对端异常断开的痛苦。5. 从单连接到多连接第一个版本暴露出来的阻塞问题是升级的起点5.1 一个accept循环为什么只能服务一个客户端如果你按前面最简单的while True accept写法很快会发现一个严重问题当第一个客户端连上之后如果它一直不发数据服务端会阻塞在recv第二个客户端的连接请求只能在listen队列里排队看起来就像服务端挂死了。原因就是阻塞式I/O。服务端在等第一个连接的数据时根本没有机会去accept第二个连接。这个问题不解决你的服务端就永远只能算是个玩具。有两种升级路径各有适用场景。第一种是每个连接开一个线程代码改动最小连接数不超过几百时非常够用import threading def handle(conn, addr): while True: data conn.recv(1024) if not data: break conn.sendall(data) conn.close() while True: conn, addr server.accept() t threading.Thread(targethandle, args(conn, addr)) t.start()这种方法实现简单但每个线程都有成本连接数上来后线程切换开销不可忽略。第二种是事件驱动用selectors模块统一监听多个socket哪个有数据就处理哪个。5.2 用selectors把服务端改成事件驱动Python标准库里的selectors模块封装了select/poll/epoll使用体验非常友好。核心思路是把所有socket设置为非阻塞模式然后注册到事件循环里import selectors import socket sel selectors.DefaultSelector() server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 8500)) server.listen(100) server.setblocking(False) sel.register(server, selectors.EVENT_READ, dataNone) def accept_conn(key, mask): conn, addr key.fileobj.accept() print(f新连接: {addr}) conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, dataaddr) def read_conn(key, mask): conn key.fileobj data conn.recv(1024) if data: print(f收到 {key.data}: {data.decode()!r}) else: print(f关闭: {key.data}) sel.unregister(conn) conn.close() while True: events sel.select(timeoutNone) for key, mask in events: if key.data is None: accept_conn(key, mask) else: read_conn(key, mask)这段代码把服务端从阻塞在某个连接上变成了哪个连接有事件就处理哪个连接。非阻塞模式加事件循环是理解后面高性能网络框架的必经之路。从最简单的阻塞版本升级到selectors版本你已经掌握了第一版能抗住多并发服务端的核心机制。5.3 资源释放和异常日志写服务端的基本素养不管是哪种升级路径都要注意资源释放。很多人写了accept循环但忘记在连接关闭后把socket资源回收。在测试里无所谓在真实环境里就是文件描述符泄漏连接数一多系统直接崩溃。一个简单习惯是凡是打开conn的地方确保所有路径最终都会close。用try/finally或上下文管理器包好。另外异常处理不要盲目except Exception: pass至少要打印日志否则线上出了问题连排查方向都没有。连接异常断开时抛出的异常类型不少常见的有ConnectionResetError、ConnectionAbortedError、BrokenPipeError。这些本质上都是对端不在了的不同表现不要被名字吓到统一处理为关闭这个连接并继续服务下一个就行。6. 往真实场景再走一步超时、缓冲区、半关闭、消息边界这四件事先想明白在把简单的服务端客户端实现升级成能给别人用的程序之前我建议你先掌握四个判断维度。它们看似简单却是几乎所有网络问题的根源。第一是超时控制。socket默认没有超时recv可能无限期阻塞。真实场景几乎不能接受这种等不到数据就永远挂着的行为。用connet.settimeout(5.0)可以设置等待数据的最长时间超时会抛socket.timeout异常。选一个合理的超时时长比如30秒到60秒能防止一个不活跃的连接拖慢整个服务。第二是缓冲区边界。永远不要假设一次recv就能拿到完整消息。上一段提到的长度头方案就是对缓冲区边界问题的正面回答。如果你使用第三方库也要搞清楚它内部是怎么处理半包和粘包的。第三是半关闭清理。发送方说完话之后主动shutdown接收方在recv返回空字节串时优雅关闭连接让对端及时感知到状态变化。这套逻辑看似繁琐实则是TCP协议设计者有意保留的机制善用它能让连接生命周期变得可控。第四是消息边界。这是协议设计的核心。简单练习可以用长度头文本消息也可以用换行符分隔接收端用recv_until这类方法按行读取更复杂的场景则要考虑序列化协议。无论用哪种方案核心都只有一个接收端必须知道一条消息在哪里结束。我后来发现一个很有意思的现象凡是能把这四件事讲清楚的人再看不懂任何网络库的文档都很难。因为你已经不是在背API了而是在理解协议栈的行为。网络编程的入门门槛从来不在语法而在于从写代码到理解数据是怎么流动的这个转变。如果你能流畅回答recv为什么可能返回0、sendall究竟做了什么、select为什么能同时监听多个连接那恭喜你这一关已经真正过了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →