十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Python Socket编程入门到实战:TCP/UDP通信、粘包处理与多线程聊天室

Python Socket编程入门到实战:TCP/UDP通信、粘包处理与多线程聊天室 1. 写给打算入坑网络编程的你socket到底是个什么鬼东西每次一提到socket网上一堆教程上来就是“套接字”、“文件描述符”、“三次握手四次挥手”直接把刚想动手的新手劝退了。但你要真把网络通信搞明白socket是绕不过去的一道坎。我在几年的开发里有个很深的体会socket说白了就是操作系统给应用层提供的一根“网线插口”。你不需要懂路由器怎么转发、TCP怎么实现可靠传输只要会往这个插口里塞数据、从插口里取数据两台设备就能通信。这个概念用大白话讲就是你家要和邻居家说话不需要自己造一根网线、不需要自己实现信号编码物业操作系统已经帮你在墙上装好了电话线接口socket你只需要拿起电话拨号、说话、挂断就行。这篇博文的内容适合这样的读者刚学完Python基础语法想写点真正能跑起来的东西用Python做爬虫但只懂requests库想知道底层发生了什么工作中需要写点网络监控、数据上报、设备联调的小工具但没系统学过socket编程。我会从原理一路讲到能直接抄的实战代码最后把新手最容易踩的坑一个个拆开给你看。整篇读下来大概需要25分钟读完你能独立写出一个可靠的双机通信程序以后遇到“两台电脑UDP通信”“TCP长连接请求”这类需求不会再一头雾水。先说清楚我们用Python学socket有天然优势Python的socket模块是对C socket API的一层清爽封装用法和C几乎一一对应但省掉了指针、手动管理内存这些破事。你在Python里学会了socket将来去看C、Java的socket代码基本上能无缝迁移理解。反过来如果你一上来就啃C的socket光是一个struct sockaddr_in的地址结构就能让你怀疑人生。所以用Python入门socket是性价比最高的路线。2. 从一次“打电话”说起TCP通信的完整生命周期要理解socket通信最经典的类比就是打电话。我们以TCP为例把一次完整通信拆开看这是后面所有代码的基础。你在写任何socket程序之前脑子里得有这张通话流程图。2.1 服务端的三个动作bind、listen、accept服务端程序的角色相当于“总机接线员”它要做三件固定的事情第一步是bind绑定。电话总机得先占一根电话线绑定的意思就是告诉操作系统“我要在这个IP地址的这个端口上等电话这个入口归我管了。”IP地址是要自己的哪块网卡端口是要在这个网卡上开哪个门。如果你不bind操作系统会随手给你分配一个随机端口那客户端就不知道往哪儿打。所以bind是服务端的第一步必须显式做。第二步是listen监听。绑定好了号码得把电话铃声打开告诉操作系统“这个入口我准备好了有电话进来你叫我。”listen的参数是backlog表示最大排队等待人数——如果当下有5个人同时在打电话第6个人打进来他会在门口排队这个值决定了门口最多能排几个人。第三步是accept接受。这是最容易被新手误解的一步。很多人以为accept就是“接起电话”实际上不是。accept是从等待队列里取一个已经建立好的连接然后返回一个新的socket专门和这个客户端聊。原来的监听socket继续等着下一个来电。这里有个核心概念很多教程讲不明白一个服务端程序至少有两个socket。一个是监听socket负责守门一个是通信socket负责聊天。accept()的返回值才是真正用来收发数据的那个。等这个通信过程结束不需要这个连接了就调用close()挂断电话。如果后面还有人来监听socket还活着可以继续accept。2.2 客户端的两个动作connect、send/recv客户端的角色是“打电话的人”逻辑比服务端简单很多。它只需要知道服务端的IP和端口然后调用connect()去拨号。这个拨号过程在TCP层面就是传说中的三次握手——SYN、SYNACK、ACK。但这些你完全不需要手动处理操作系统内核全包了。连接建立成功之后双方的地位就是对等的了都可以用send()发数据、用recv()收数据。不需要区分谁是“主”谁是“从”谁有数据谁就发。这里我要特别强调一个新手最容易产生幻觉的地方TCP不是“你发一次我收一次”的消息协议。它是流式协议——你的数据发出去之后就变成了一串连续的字节流。send和recv之间没有任何边界概念。你发了两条“你好”和“世界”对方可能一次就把“你好世界”全收走了也可能第一次只收到半个字。这个特性会在后面引出网络编程最著名的问题——粘包。2.3 理解socket是阻塞的还是非阻塞的默认情况下Python的socket是阻塞模式。什么叫阻塞你调用recv()去读数据如果对方没发数据这个调用就会一直卡在那里等到天荒地老直到有数据进来或者对端断开。类似你打电话说完话对方不吭声你拿着听筒一直等着。阻塞模式的好处是编程简单符合直觉坏处是如果对方一直不发数据你的程序就卡死了。解决思路有两种一是用多线程主线程accept每个连接丢给一个子线程去处理子线程里recv卡住也不影响别人二是把socket设置成非阻塞或超时socket.settimeout()设一个秒数超过这个时间recv会抛socket.timeout异常。小白阶段建议先按阻塞模式写通再用多线程最后再去折腾非阻塞和异步IOasyncio一步一个脚印。3. Python socket模块逐层拆解不需要背API理解这套组合拳就够了网上关于socket API的表格很多但光背参数没有意义。我自己总结了一套“组合拳”记忆法只要记住每端各要做什么动作代码就是动作的翻译。3.1 核心API映射表动作服务端调用客户端调用生活类比创建插座socket.socket(family, type)socket.socket(family, type)拿一个新电话机绑定地址bind((ip, port))一般不写给电话机插上电话线开启监听listen(backlog)不写打开铃声开关等待来电accept()不写接起排队中的电话发起连接不写connect((ip, port))拨号发数据send(data)send(data)说话收数据recv(bufsize)recv(bufsize)听对方说挂断close()close()挂电话family参数用来指定地址族写AF_INET就是IPv4还有AF_INET6对应IPv6。type用来指定socket类型写SOCK_STREAM是TCP流式写SOCK_DGRAM是UDP数据报。判断一个需求该用TCP还是UDP有一个简单粗暴的标准如果你丢一条数据心里会发慌那就用TCP如果丢一条数据无所谓、丢了就再发一次就用UDP。具体来说文件传输、网页请求、指令下发这类要求不能丢的必须用TCP视频直播、语音通话、游戏位置同步这类丢一两帧不影响的用UDP省去重传的延迟反而体验更好。3.2 send和sendall的区别别用错了这是Python socket里一个特别容易踩的坑。send()的行为是“尽力发送”——它返回的是实际发送出去的字节数这个数字可能小于你要发的内容长度。比如你要发1024字节网络拥堵可能一次只发出去500字节剩下的需要你手动再调一次send。而sendall()帮你在内部循环调send直到全部发完或者出错。实操中我几乎永远用sendall()只有在极少数需要精细控制发送节奏的场景才用send()。新手如果用send经常遇到“数据好像丢了”的问题其实就是没把数据发完。注意一个细节sendall()发送成功时返回None发送失败直接抛异常没有中间状态。3.3 recv的返回值是判断连接断没断的关键recv(bufsize)的返回值规则可以说是整个socket编程里最需要刻进DNA的知识点返回的字节串长度大于0正常收到数据。返回的字节串长度为0对端已经关闭了连接或者对端调用了close。这在TCP里意味着你收到了EOF文件结束符。抛异常连接出错或者超时。很多教程讲到这里就停了但我要强调一个实操教训必须在收数据循环里对“返回0”做处理否则程序会陷入死循环。比如你写了一个死循环while True: data recv(1024)如果对端断开后你收到一次空字节串没有break下一次循环recv还是返回空字节串就这么永无止境地空转下去CPU占满连接却早就死了。判断对端断开还有一个更彻底的方式调用send()往一个已经断开的连接上发数据系统会触发ConnectionResetError或BrokenPipeError但这个方法不总可靠最可靠的还是看recv返回0。3.4 设置超时的三种方式阻塞的socket在recv时如果对方一直不说话程序就彻底卡住了。解决办法import socket # 方式一创建socket后设置 client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(5) # 5秒内没有数据就抛socket.timeout # 方式二只对本次connect设置超时 client.connect((192.168.1.10, 8888)) # 先不设超时connect可能挂很久实操技巧settimeout要在connect之前还是之后设置建议在connect之前就设置因为connect一个不通的IP地址比如对方关机、网络断开默认情况下会挂好几十秒才报错用户体验极差。设置了超时之后连接不上会快速失败方便你重试或换下一个地址。4. 手写一个TCP服务端和客户端从单机回显到可靠通信前面讲了半天原理这一节直接上手写代码。我会带着你写一个完整可跑的TCP通信程序服务端接收客户端发来的消息然后把消息原样返回给客户端回显服务。4.1 最简单的服务端能跑通就行先写一个单机版服务端监听本机的8888端口import socket # 1. 创建TCP socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 允许端口复用避免程序重启时报Address already in use server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 3. 绑定地址和端口 server.bind((127.0.0.1, 8888)) # 4. 开始监听最多排队5个连接 server.listen(5) print(服务端已启动监听 127.0.0.1:8888 ...) # 5. 接受客户端连接 conn, addr server.accept() print(收到来自 {} 的连接.format(addr)) # 6. 接收数据并回显 while True: data conn.recv(1024) if not data: # 重要对端关闭连接时会返回空字节串 print(客户端已断开) break print(收到消息: {}.format(data.decode(utf-8))) conn.sendall(data) # 回显给客户端 # 7. 清理 conn.close() server.close()这段代码里有一个必须强调的细节bind和listen中间的setsockopt。新手写服务端时经常遇到一个问题——程序用CtrlC停掉之后马上重新启动结果报错Address already in use端口被占用。这是因为TIME_WAIT状态占着端口加一行SO_REUSEADDR就能解决。这一行的重要性怎么强调都不过分我做网络联调时几乎每一段服务端代码都会先加它。4.2 服务端接住多个客户端多线程才是正经姿势上面的服务端只能处理一个客户端——第二个客户端来的时候第一个没断开accept就一直阻塞在那里第二个连不上。真实场景里服务端必须能同时处理大量客户端标准方案就是每来一个连接就开一个线程处理。import socket import threading def handle_client(conn, addr): 处理单个客户端的收发 try: while True: data conn.recv(1024) if not data: break print([{}] 收到: {}.format(addr, data.decode(utf-8))) conn.sendall(data) except ConnectionResetError: print([{}] 连接被重置.format(addr)) finally: conn.close() print([{}] 连接已关闭.format(addr)) server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8888)) server.listen(5) print(服务端启动等待连接...) while True: conn, addr server.accept() print([{}] 新连接.format(addr)) t threading.Thread(targethandle_client, args(conn, addr)) t.start()注意这里bind的IP我写的是0.0.0.0不是127.0.0.1。这是一个很容易被忽略的区别127.0.0.1只允许本机自己访问外部设备连不上。用于本机调试。0.0.0.0监听本机所有网卡外部设备可以通过你的局域网IP访问。用于真机联调。具体某个IP如192.168.1.100只监听指定网卡上的连接。如果你想让另一台电脑连上你的服务端bind的地址必须写成0.0.0.0否则对方永远连不上。这是“两台电脑UDP/TCP通信”这类需求里最常见的坑之一。4.3 客户端的完整写法带上异常处理和超时import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(5) try: client.connect((127.0.0.1, 8888)) except (socket.timeout, ConnectionRefusedError) as e: print(连接失败: {}.format(e)) exit(1) try: while True: msg input(请输入要发送的内容输入exit退出: ) if msg exit: break client.sendall(msg.encode(utf-8)) data client.recv(1024) print(服务端回显: {}.format(data.decode(utf-8))) finally: client.close()ConnectionRefusedError是什么场景就是服务端程序没启动或者IP端口写错了客户端connect过去系统直接回了一个RST包客户端这边就抛这个异常。遇到这个错第一反应是检查服务端有没有在运行、端口对不对而不是怀疑代码写错了。4.4 两台电脑怎么联调常见的三个坑很多新手在自己的电脑上测试没问题换成两台电脑就联不通了百分之九十九是下面三个原因服务端bind的是127.0.0.1。改成0.0.0.0前面已经说过。防火墙拦截了端口。Windows和Linux的防火墙默认都会拦掉外部入站的陌生端口。最粗暴的测试办法暂时关闭防火墙自己电脑的临时测试可以生产环境别这么干或者放行指定的TCP/UDP端口。如果关了防火墙就能通那基本就是防火墙的锅。IP地址填错了。客户端填的必须是服务端电脑的局域网IP比如192.168.x.x不是它的主机名也不是127.0.0.1。可以在服务端电脑上执行ipconfigWindows或ip addrLinux查看。两端最好能互相ping通这是网络连通性的最低保障。另外还有一个看似不起眼的问题服务端要最先启动而且要在connect之前启动完毕。很多人习惯先跑客户端报ConnectionRefusedError后一脸懵其实只是服务端还没就绪。联调前养成一个好习惯先启动服务端看到“等待连接”的提示后再启动客户端。5. 粘包问题新手迟早会撞上的第一堵墙如果你写完上面的程序开始尝试一次发送多条消息用不了多久就会撞上一个诡异现象服务端收到的消息粘在一起了。比如客户端连续发了三次sendall(bhello)、sendall(bworld)、sendall(b!)服务端第一次recv可能直接返回了bhelloworld!三次发送一次就收完了。这就是著名的粘包问题。5.1 为什么TCP会粘包不是bug是特性TCP是面向字节流的协议它是流不是消息。TCP本身根本不知道“消息”的边界在哪里它只负责把字节按顺序可靠地交付给接收方。而socket的接收方也就是应用层通过recv从内核缓冲区里抓数据抓多少取决于你指定的bufsize和缓冲区里当前有多少数据。发送方如果连续两次sendall第一次的数据还和第二次的数据待在同一个内核缓冲区里接收方一次recv(1024)就把两段都取走了。这就是粘包现象的来源。如果用的是UDP就不存在粘包问题因为UDP是数据报协议每条sendto的数据自带边界接收方的recvfrom一次只取一条完整的消息。但前面说了UDP不保证可靠丢包自己兜着。5.2 解决粘包的标准姿势先发长度再发内容彻底解决粘包问题的方案业界总结下来就一句话应用层自定义协议用“包头包体”的方式标记消息边界。最经典的做法是在发送真实数据之前先发送一个固定长度的包头里面记录这条消息的字节数。接收方先读到这个长度再按长度去读数据体就能精确地切分出每一条消息。import struct def send_message(sock, data: bytes): 先发4字节的消息长度大端序再发消息本身 header struct.pack(I, len(data)) sock.sendall(header data) def recv_exact(sock, n: int) - bytes: 可靠地接收恰好n个字节 chunks [] remain n while remain 0: chunk sock.recv(remain) if not chunk: raise ConnectionError(对端连接关闭无法收满 {} 字节.format(n)) chunks.append(chunk) remain - len(chunk) return b.join(chunks) def recv_message(sock) - bytes: 先读取4字节包头再按包头里的长度读取完整消息 header recv_exact(sock, 4) (length,) struct.unpack(I, header) return recv_exact(sock, length)struct.pack(I, ...)这里有两个知识点I表示“大端序的无符号整数占4字节”。为什么用4字节因为I对应的长度范围最大到2^32-1约4GB一般的小消息绰绰有余。如果你传的是文本还要在收完body后再做一次.decode(utf-8)。这套“先长度后内容”的方案是网络编程里最底层的公共约定理解了它你以后看很多现成协议比如HTTP的Content-Length、消息队列入库时的长度字段都会有一种“啊原来如此”的通透感。5.3 另一个容易混的坑recv(1024)里的1024到底代表什么recv(1024)表示最多读取1024字节。如果内核缓冲区里只有100字节那这一行会返回100字节不会傻傻地凑到1024。所以看到recv返回值不等于1024是再正常不过的。在“先长度后内容”的协议里recv_exact必须用循环去收因为recv(4)可能只返回2个字节剩下的2个字节要再收一次才能凑够。很多新手在这里翻车——只调了一次recv(4)就理所当然认为拿到了完整的4字节包头结果后面全乱套。记住在流式协议里任何一次recv都可能“没读完”可靠收满n个字节必须用循环。6. UDP通信不要连接直接扔数据包有些场景不需要TCP那么重的机制比如心跳上报、传感器数据推送、游戏位置同步。这时候UDP反而比TCP合适。UDP的socket代码比TCP更简洁因为它压根不需要listen和accept也谈不上“连接”。6.1 UDP服务端和客户端的最小实现UDP服务端一个socket走天下import socket udp_server socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_server.bind((0.0.0.0, 9000)) print(UDP服务端已启动监听 0.0.0.0:9000) while True: data, addr udp_server.recvfrom(1024) print(来自 {} 的消息: {}.format(addr, data.decode(utf-8))) # UDP可以回发数据 udp_server.sendto(data, addr)UDP客户端import socket udp_client socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_addr (192.168.1.100, 9000) for i in range(5): msg 第{}条UDP消息.format(i 1).encode(utf-8) udp_client.sendto(msg, server_addr) data, _ udp_client.recvfrom(1024) print(收到回复: {}.format(data.decode(utf-8))) udp_client.close()UDP和TCP的对应关系一目了然TCP用send/recvUDP用sendto/recvfromTCP要先connectUDP直接sendto带上对端地址就能发TCP服务端要accept拿到connUDP服务端一个socket就能和所有人通信每次从recvfrom里拿到对方的addr再sendto回去。6.2 UDP会丢包程序里要自己兜底必须反复强调UDP不保证送达、不保证顺序、不保证不重复。在局域网里丢包率通常很低但在公网上丢包是常态。用UDP做关键数据传输时务必要自己加应用层机制确认机制收到消息后回ACK、重传机制超时没收到ACK就重发、序号机制客户端给消息编号服务端检测是否有漏号。我见过很多新手用UDP传文件传了几秒钟就发现内容缺失跑到网上发帖问“UDP传文件丢数据怎么解决”。答案很简单UDP不是一个为“全量可靠传输”设计的协议如果你发现丢包不可接受请换TCP或者在此基础上实现可靠协议比如参考QUIC的思路。工具用错了不代表你有问题换工具就好。7. 实战升级写一个带协议的消息聊天室TCP你已经会了粘包你也了解了长度字段协议你也知道了。这一节我们把它们组合起来写一个结构相对完整的局域网聊天室。这是检验你有没有真正掌握socket编程的好题目——这个项目包括服务端多线程处理、自定义协议、客户端交互三块核心内容。7.1 协议设计我们定义一下消息格式用JSON做消息体可读性好调试方便外面套上固定长度的包头4字节大端整数表示body的长度body变长JSON字符串包含name昵称和text消息内容两个字段用JSON而不是自定义二进制格式的考虑是开发调试时一眼能看懂消息内容不需要额外写解析代码。将来数据量大了、对性能有要求了再换protobuf这类序列化方案。7.2 服务端代码多线程广播import socket import threading import struct import json clients [] # 保存所有活跃连接的conn def send_message(sock, data: bytes): sock.sendall(struct.pack(I, len(data)) data) def recv_exact(sock, n: int) - bytes: chunks [] remain n while remain 0: chunk sock.recv(remain) if not chunk: raise ConnectionError(连接已断开) chunks.append(chunk) remain - len(chunk) return b.join(chunks) def recv_message(sock) - dict: header recv_exact(sock, 4) (length,) struct.unpack(I, header) body recv_exact(sock, length) return json.loads(body.decode(utf-8)) def broadcast(sender_conn, message: dict): data json.dumps(message, ensure_asciiFalse).encode(utf-8) for conn in list(clients): if conn is sender_conn: continue try: send_message(conn, data) except (ConnectionError, BrokenPipeError): # 连接出错的客户端从列表里移除 if conn in clients: clients.remove(conn) def handle_client(conn, addr): name None try: # 客户端连接后的第一条消息是昵称 first recv_message(conn) name first.get(name, 匿名) broadcast(conn, {type: system, name: , text: {} 加入了聊天室.format(name)}) while True: msg recv_message(conn) text msg.get(text, ) if text quit: break broadcast(conn, {type: chat, name: name, text: text}) except (ConnectionError, ConnectionResetError): pass finally: if conn in clients: clients.remove(conn) conn.close() if name: broadcast(None, {type: system, name: , text: {} 离开了聊天室.format(name)}) server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8888)) server.listen(10) print(聊天室服务端已启动端口 8888) while True: conn, addr server.accept() clients.append(conn) threading.Thread(targethandle_client, args(conn, addr), daemonTrue).start()这段代码里有一个非常关键的技巧把“连接断开”和“正常业务”统一用异常和返回值去控制流程。recv_exact在对端断开时抛出ConnectionError上层handle_client捕获后把该做的清理都做掉。这样不管是对端主动close、网络中断、还是异常崩溃服务端都不会因为某个客户端的故障而整体挂掉。broadcast里的list(clients)为什么要拷贝一份因为循环过程里可能有新的客户端加入或离开如果直接遍历列表又同时删除元素会抛RuntimeError: dictionary changed size during iteration。这是多线程共享可变数据结构时很典型的坑。7.3 客户端代码两线程一收一发import socket import threading import struct import json def send_message(sock, data: bytes): sock.sendall(struct.pack(I, len(data)) data) def recv_exact(sock, n: int) - bytes: chunks [] remain n while remain 0: chunk sock.recv(remain) if not chunk: raise ConnectionError(连接已断开) chunks.append(chunk) remain - len(chunk) return b.join(chunks) def recv_message(sock) - dict: header recv_exact(sock, 4) (length,) struct.unpack(I, header) body recv_exact(sock, length) return json.loads(body.decode(utf-8)) def receiver_thread(sock): 子线程负责接收服务端的所有消息 while True: try: msg recv_message(sock) except (ConnectionError, ConnectionResetError, OSError): print(与服务端的连接已断开) break if msg.get(type) system: print(\n[系统] {}.format(msg[text])) else: print(\n[{}] {}.format(msg[name], msg[text])) print(请输入消息输入quit退出: , end, flushTrue) def main(): sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) try: sock.connect((127.0.0.1, 8888)) except (socket.timeout, ConnectionRefusedError) as e: print(连接失败: {}.format(e)) return my_name input(请输入你的昵称: ) send_message(sock, json.dumps({name: my_name, text: }).encode(utf-8)) threading.Thread(targetreceiver_thread, args(sock,), daemonTrue).start() try: while True: text input(请输入消息输入quit退出: ) send_message(sock, json.dumps({name: my_name, text: text}).encode(utf-8)) if text quit: break except (KeyboardInterrupt, ConnectionError, BrokenPipeError): pass finally: sock.close() if __name__ __main__: main()客户端的难点在于input()是阻塞的recv消息也会阻塞两者没法同时跑。解决办法就是用子线程专门干接收消息这件事主线程专心处理输入。这个“一发一收两个线程”的结构是几乎所有长连接客户端程序的通用骨架。将来你要写WebSocket客户端、IM客户端、MQTT客户端都可以参考这个架构。7.4 这个聊天室还缺什么如果你正打算把它扩展成自己的项目我列几个可以继续完善的方向每个都是真实项目里绕不开的点心跳机制。现在服务端怎么判断一个客户端到底是“下线了”还是“只是不说话”靠recv返回0吗不行客户端拔网线、断电服务端可能永远等不到任何数据也不会收到RST包。标准做法是客户端每隔几秒发一个心跳包服务端超过N秒没收到任何数据就判定掉线踢掉连接。这在长连接里是保命级的设计。消息序号和ACK。现在我们用的TCP已经保证了有序不重复所以“发过去对方就一定收到了”这件事是成立的。但如果将来换到UDP实现类似聊天室就必须引入序号和ACK机制不然消息丢了都不知道。身份认证。现在任何人都能直接连上来报个昵称。真实系统里第一步是登录鉴权这个可以在协议的最前面加一个握手消息token验证。这些问题不是复杂度在吓唬人而是提醒你写一个demo和写一个“生产可用”的程序之间隔着一整个知识体系。但Socket的核心你已经掌握了剩下的都是在这个骨架上添砖加瓦。8. 新手一定会踩的坑从报错信息反推原因网络编程的报错信息往往很吓人但对英文报错的解读恰好是排查问题的捷径。我把新手最常遇到的几个错误整理成一个速查表以后看到类似报错直接对照。8.1 报错速查表报错信息片段实际原因排查方向ConnectionRefusedError: [Errno 111] Connection refused目标端口没有程序监听或IP不对启动服务端、检查端口、检查IPTimeoutError: [Errno 110] Connection timed out网络不可达或防火墙丢弃了包ping测试、关防火墙测试、检查IPOSError: [Errno 98] Address already in use端口被占用或TIME_WAIT未结束换端口、启用SO_REUSEADDR、杀旧进程ConnectionResetError: [Errno 104] Connection reset by peer对端强制关闭了连接代码里对recv空值和异常做处理BrokenPipeError: [Errno 32] Broken pipe往已关闭的连接上发数据发送前确认连接状态、捕获异常socket.timeout: timed out设置了超时但超过时限没有数据检查对方是否发送、调超时时间8.2 绑定地址报错“only one usage of each socket address”详解这是热搜词里出现频率极高的一条报错值得单独拿出来讲。完整的报错通常是这样的OSError: [Errno 98] Address already in use或者 Windows 下的OSError: [WinError 10048] Only one usage of each socket address (protocol/network address/port) is normally permitted翻译成人话就是你想bind的这个IP端口组合已经被另一个socket占用了。一个端口同一时间只能被一个socket独占监听这是操作系统的规则。出现这个错误通常有几个场景你上次运行的服务端没关干净。程序被CtrlC中止后除非代码里写了finally去close否则内核里的socket会在短时间内处于TIME_WAIT状态端口被占着不放。解决加上SO_REUSEADDR或者等一两分钟或者直接杀掉旧的python进程。两个服务端程序用了同一个端口。比如你开了两个聊天室脚本都监听8888。解决改成不同端口。服务端没有设置SO_REUSEADDR且上次异常退出。前面代码里的server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)就是专门治这个的我几乎每段服务端代码都会带它。在Windows上还有自己的SO_EXCLUSIVEADDRUSE行为差异但对我们日常学习来说加上SO_REUSEADDR基本能解决99%的端口复用问题。8.3 排查链路的完整示范假如你现在遇到“客户端连不上服务端”的问题不要慌按下面这个顺序走一遍第1步服务端有没有启动启动日志里有没有“监听”字样 第2步服务端监听的是127.0.0.1还是0.0.0.0外部设备能不能访问 第3步客户端填的IP是服务端的实际局域网IP吗ipconfig/ip addr 查询 第4步两端能互相ping通吗ping不通先解决网络问题。 第5步服务端电脑的防火墙有没有放行该端口临时关掉防火墙测试最快 第6步用网络调试助手小工具替代客户端去连一次看能不能通。这套链路我称之为“联调六步法”能覆盖九成以上的连接失败问题。不要一上来就怀疑代码先排除环境因素效率会高很多。9. 收尾关于Python版本、编码和一行好用的调试技巧最后分享几个我在实际使用中的零碎经验它们不构成完整章节但都是能救命的细节。Python版本问题。Python 2的socket写法比如s.sendall(hello)和Python 3有很大差别——Python 3里socket.sendall要求传bytes字符串必须.encode(utf-8)。我见过很多从旧教程抄代码的人卡在一行TypeError: a bytes-like object is required, not str上。如果你现在还在用Python 2学习立刻换成Python 3网上大量新资料和库都只支持3.x了。装新版本Python时记得勾选“Add Python to PATH”否则命令行里找不到python命令这个坑每天都有大量新人在踩。关于编码的规范。网络通信只在字节层面工作你好.encode(utf-8)和b\xe4\xbd\xa0\xe5\xa5\xbd是同一件事。发消息格式的约定要双方一致如果服务端按UTF-8解码客户端就必须按UTF-8编码。如果一方用了GBK另一方用UTF-8你们看到的就会是一堆乱码。协议里写清楚用UTF-8最好也在代码注释里标明。一个小技巧利用网络调试助手做联调。网上搜“网络调试助手”是一个免费的Windows小工具可以快速模拟TCP服务端或客户端。当你自己的代码连不上时先用这个工具连一下如果工具能通而你的代码不能通那就是代码问题如果工具也不通那就是网络或服务端问题。这个二分法排查思路能帮你快速定位问题边界。我经历过无数次socket联调失败之后最大的体会是网络编程的大部分坑都来自人对“流”和“包”的认知偏差。你以为发一条就收一条其实它是一条流淌的河你以为连接断了receive会报错其实它只是默默返回一个空串。把这一层想明白了后面的路就顺了。希望这篇拆解能帮你少走几段弯路去写出自己的第一个真正能用的网络程序。
返回列表