1. 为什么今天还要亲手写 socket?——一个老手的坦白
你可能刚在招聘网站上看到“熟悉 TCP/IP 协议栈”“有网络编程经验”这类要求,心里一紧:这玩意儿不是早被 Flask、FastAPI、gRPC 这些框架包圆了吗?写个 HTTP 接口点几下鼠标就跑起来了,谁还去碰bind()和accept()?我干了十多年后端和嵌入式通信开发,从 Linux 内核模块调试到给工业 PLC 做边缘网关,踩过太多坑才明白:框架是糖衣,socket 是骨头。糖衣可以换,骨头断了,整个系统就瘫了。
这不是危言耸听。去年我们给一家智能水务公司做远程泵站监控系统,用的是成熟的 MQTT + WebSockets 方案。上线三个月后,某天凌晨三点,27 个泵站同时失联。运维同事第一反应是查 Kafka 消费延迟、查 Nginx 日志、查证书过期——全都没问题。最后抓包发现,是某个型号的 4G 路由器在弱信号下会静默丢弃 TCP 的 FIN 包,导致连接半开不闭,socket 状态卡在ESTABLISHED,但应用层完全感知不到。MQTT 客户端的心跳机制失效,服务端以为设备还在线,设备却早已断网。修复方案?不是换框架,而是重写底层 socket 连接管理模块:加入SO_KEEPALIVE的精细调优、TCP_USER_TIMEOUT的主动探测、以及基于select()的状态轮询兜底。三天后,问题根除。
这就是 socket 编程不可替代的价值:它让你直面网络的真实——延迟、丢包、乱序、NAT 穿透、TIME_WAIT 泛滥、文件描述符耗尽……这些在高级框架里被层层封装、被默认参数掩盖的问题,一旦爆发,只有懂 socket 的人能快速定位。Python 的socket模块不是过时的古董,它是你手里最锋利、最透明的解剖刀。它不帮你做序列化,不自动重连,不管理线程池,正因如此,你才能精确控制每一个字节的流向、每一次超时的判定、每一条连接的生命周期。
这篇文章,就是为你准备的一份“实战手术指南”。它不讲教科书定义,不堆砌 RFC 文档,而是从一个真实项目出发——我们要做一个轻量级的、支持多客户端并发、带心跳保活、能优雅处理异常断连的本地消息中继服务。我会带着你,一行行敲出代码,解释每一行背后的“为什么”,分享那些只在深夜 debug 时才会浮现的细节:比如listen(0)和listen(5)在高并发下的真实表现差异;为什么recv(1024)可能只收到 3 个字节;close()之后为什么还要shutdown(SHUT_RDWR);以及,当你的服务器在 Docker 容器里跑着,而客户端在 Windows 上连不上时,第一个该查的命令是什么。
如果你是刚学完 Python 基础、想真正理解“网络是怎么工作的”新手,这篇内容会给你一个扎实的锚点;如果你是已经用 Django 写了三年 API、但对ConnectionResetError依然头疼的中级开发者,这里的经验能立刻提升你的排障效率;如果你是负责 IoT 设备固件与云平台对接的工程师,那么关于 UDP 可靠性增强、SO_RCVBUF调优的部分,可能直接帮你省下两周的联调时间。
别把它当成一篇教程,当成一份同行间的技术备忘录。现在,我们开始。
2. 整体设计与思路拆解:从“能跑”到“稳跑”的三道坎
任何网络服务,从诞生到稳定运行,都要跨过三道本质性的坎:连接建立、数据收发、连接终结。很多初学者写的 socket 代码,只解决了第一道坎——“能连上”,却在第二、第三道坎上栽了跟头,表现为:服务跑一会儿就卡死、客户端发几条消息就断连、服务器重启后旧连接无法清理。我们的设计,就是围绕这三道坎,构建一个有“呼吸感”的服务。
2.1 为什么选择 TCP 而非 UDP?——业务语义决定协议选型
项目正文里提到了 TCP 和 UDP,但没说清楚一个关键原则:协议选择不是看“快不快”,而是看“容不容错”。
- TCP:像一位严谨的邮政局长。他收信时会清点页数(校验和),发现缺页立刻打电话让寄件人重寄(重传),确保信件按顺序、完整地送到(有序、可靠)。代价是:每次寄信前要先打电话预约(三次握手),寄完还要确认签收(四次挥手),路上遇到拥堵还会主动减速(拥塞控制)。
- UDP:像一个洒脱的快递小哥。他把包裹扔进你家信箱就走,不确认你是否收到,也不管包裹是不是被猫撕了一页。优点是快、轻量;缺点是,你永远不知道信到了没有,或者到了几页。
我们的消息中继服务,核心需求是“消息不丢失、不错乱”。用户发一条“启动水泵”,如果因为网络抖动丢了,或者和下一条“关闭阀门”乱序到达,后果可能是灾难性的。所以,必须选 TCP。这不是性能妥协,而是业务安全的底线。
提示:有人会说“那我用 UDP + 自己加 ACK 呢?”——这本质上是在重新发明 TCP。除非你有极特殊的场景(如实时音视频,宁可丢帧也不能卡顿),否则请尊重几十年来无数工程师打磨出的 TCP 协议栈。Python 的
socket.SOCK_STREAM就是这条黄金大道。
2.2 单线程 vs 多线程 vs 异步:为什么我们从单线程起步?
项目正文里的例子是单线程阻塞式(accept()会卡住,直到有连接来)。很多人第一反应是:“这不行!得用多线程!” 但我的经验是:过早引入并发,是绝大多数 socket 服务崩溃的根源。
- 单线程阻塞模型:逻辑最简单,所有状态都在一个线程里,调试时
print就能看到全貌。适合学习原理、做原型验证。它的瓶颈是:一次只能服务一个客户端,第二个连接请求会被排队或拒绝。 - 多线程模型:为每个客户端创建一个新线程。看似完美,但隐患巨大:线程创建/销毁开销大;线程间共享数据需要锁(
threading.Lock),一不小心就死锁;更致命的是,线程数量无上限时,会迅速耗尽系统资源(内存、文件描述符),导致服务整体雪崩。 - 异步模型(asyncio):用单线程模拟并发,通过事件循环调度 I/O。性能最高,但心智负担最重,错误处理逻辑复杂,对新手极不友好。
我们的策略是:先用单线程把“连接-收发-终结”的闭环做扎实,再平滑升级到多线程,最后可选异步。这样,你学到的不是某个框架的 API,而是网络编程的通用范式。后续章节,我们会给出从单线程到多线程的完整迁移路径,包括如何用threading.Thread安全地管理连接、如何用queue.Queue解耦数据处理、以及如何避免经典的“主线程退出,子线程还在狂奔”问题。
2.3 “心跳”不是可选项,而是生存必需品
项目正文没提心跳(Heartbeat),但这恰恰是生产环境最常被忽视的点。TCP 连接本身是“哑”的。它只管数据传输,不管对端是否还活着。想象一下:客户端手机突然关机、拔掉网线、或者进程被 kill -9 杀死——TCP 层不会立刻通知服务器。服务器 socket 会一直保持ESTABLISHED状态,直到下一次send()或recv()时触发超时(这个超时默认可能是 2 小时!)。
我们的设计强制加入心跳:
- 客户端:每隔 30 秒,向服务器发送一个纯文本的
PING消息。 - 服务器:收到
PING后,立即回复PONG;同时,维护一个“最后活跃时间戳”。如果 60 秒内没收到任何消息(包括PING),则主动关闭该连接。
这个 30/60 秒的设定不是拍脑袋。30 秒足够覆盖大多数家庭 Wi-Fi 的短暂波动;60 秒的超时窗口,既给了网络抖动缓冲,又避免了连接长时间僵尸化。这个逻辑,会深度融入我们的通信循环,而不是作为一个独立的定时器线程。
2.4 错误处理:不是“try-except”就完事了
项目正文的示例代码里明确写了“我们省略了错误处理”。这是教学代码的权宜之计,但在生产代码里,这是自杀行为。真正的错误处理,要分三层:
- I/O 错误:
ConnectionResetError,BrokenPipeError,TimeoutError—— 这些是网络世界的常态,不是异常,是“特征”。 - 资源错误:
OSError: [Errno 24] Too many open files—— 当你打开的 socket 超过系统限制(通常是 1024),服务会直接崩溃。 - 逻辑错误:客户端发来乱码、超长消息、非法指令 —— 这需要应用层协议来约束。
我们的方案是:所有recv()和send()都包裹在try-except中,并且对每一种错误类型,执行不同的、有业务意义的恢复动作。例如,ConnectionResetError意味着客户端已断开,我们应立即close()对应的 socket 并清理资源;而TimeoutError则可能只是暂时卡顿,我们可以记录日志并继续等待。这种“错误即状态”的思维,是写出健壮网络程序的第一课。
3. 核心细节解析与实操要点:那些文档里不会写的“坑”
Socket 编程的难点,往往不在宏观架构,而在微观操作。一个参数设错、一行代码位置不对,就可能导致服务在特定条件下无声无息地失败。下面这些细节,是我过去十年在各种硬件、云环境、容器平台上反复验证过的“血泪经验”。
3.1bind()之前,必须setsockopt(SO_REUSEADDR)—— 否则重启必报错
这是新手最常遇到的错误:“Address already in use”。当你 Ctrl+C 停掉服务器,再立刻python server.py,大概率会看到这个报错。原因在于 TCP 的TIME_WAIT状态:主动关闭连接的一方(通常是服务器),会在关闭后进入TIME_WAIT,持续 2MSL(Maximum Segment Lifetime,通常为 2 分钟),以确保网络中残留的旧数据包全部消失。在这期间,该 IP+端口组合无法被新 socket 绑定。
解决方案,就是在bind()之前,加上这一行:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)socket.SOL_SOCKET: 表示这是套接字层的选项。socket.SO_REUSEADDR: 允许重用处于TIME_WAIT状态的地址。1: 启用该选项(整数 1 表示 True)。
注意:
SO_REUSEADDR不是万能的。它不能解决“端口被其他进程占用”的问题,只能解决“端口被自己刚关闭的 socket 占用”的问题。如果你看到Address already in use且确定没其他进程在用,那八成就是忘了这行。
3.2listen()的backlog参数:数字越大越好?真相是……
项目正文说listen(0)表示“只接受一个连接”,listen(1)表示“队列长度为 1”。这过于简化了。backlog实际控制的是已完成三次握手、但尚未被accept()取走的连接队列长度。
- 如果
backlog=0,队列长度为 0。这意味着,当一个客户端完成三次握手后,如果服务器还没来得及accept(),这个连接就会被内核直接拒绝(客户端收到Connection refused)。 - 如果
backlog=5,队列最多容纳 5 个已完成握手的连接。第 6 个客户端的 SYN 包,内核会直接丢弃,客户端会重试(SYN Retransmit),最终可能超时。
实测心得:在现代 Linux 系统上,backlog的实际生效值,是min(backlog, /proc/sys/net/core/somaxconn)。somaxconn默认是 128,但某些云主机或容器环境可能被调低到 16。所以,listen(1000)和listen(128)在大多数情况下效果一样。推荐值:listen(128)。它足够应对突发流量,又不会因设置过大而浪费内核资源。
3.3recv()的“粘包”与“半包”:为什么你总收不全一条消息?
这是 Socket 编程里最反直觉、也最容易出 bug 的地方。recv(1024)的含义是:“最多接收 1024 字节,但不保证一次收完”。
- 半包(Partial Packet):客户端发了 2000 字节,
recv(1024)第一次只返回了 1024 字节,剩下 976 字节要等第二次recv()才能拿到。 - 粘包(Packet Stitching):客户端连续发了两条 500 字节的消息,
recv(1024)一次就返回了 1000 字节,把两条消息“粘”在一起了。
根本原因在于:TCP 是字节流协议,不是消息协议。它只保证字节的顺序和完整性,不保证“消息边界”。HTTP 协议之所以能区分请求,是因为它用\r\n\r\n分隔 header 和 body,用Content-Length告诉你 body 有多长。
我们的解决方案是:自定义应用层协议。最简单有效的方式是“长度前缀”(Length-Prefixed Protocol):
- 发送时:先发 4 字节的整数(表示后续消息体的长度),再发消息体。
- 接收时:先
recv(4)拿到长度n,再循环recv(n)直到收满n字节。
这比用特殊分隔符(如\0)更可靠,因为消息体里可能真的包含\0。后续代码中,我们会完整实现这个协议。
3.4close()与shutdown():优雅关闭的两把钥匙
项目正文里只用了close()。这在单向通信或测试环境中没问题,但在双向通信中,它可能导致数据丢失。
socket.close():彻底关闭 socket,释放所有资源。调用后,该 socket 对象不能再用于任何 I/O 操作。但它有一个问题:如果内核发送缓冲区里还有未发出的数据,close()会直接丢弃它们。socket.shutdown(how):只关闭 socket 的某一个方向。how可以是:socket.SHUT_RD: 关闭读取,不能再recv()。socket.SHUT_WR: 关闭写入,不能再send(),但会将发送缓冲区的数据全部发完,然后发送 FIN 包。socket.SHUT_RDWR: 同时关闭读写,等价于close()。
最佳实践:在需要确保数据发完的场景(如服务器要给客户端发最后一条确认消息),应该先shutdown(SHUT_WR),再recv()等待客户端的 FIN,最后close()。这能保证“我说完了,你也说完了,咱们再散”。
3.5 字符编码:UTF-8 是唯一选择,但decode()必须带errors='ignore'
项目正文里request.decode("utf-8")很干净。但在真实世界,客户端可能是用 GBK 编码的老旧设备,或者发来了二进制图片数据。直接decode("utf-8")会抛出UnicodeDecodeError,导致整个连接中断。
安全做法:
# 尝试解码,失败则忽略非法字节,保留原始 bytes try: text = data.decode('utf-8') except UnicodeDecodeError: # 记录警告日志,但不中断流程 logger.warning(f"Received non-UTF8 data from {client_address}, length: {len(data)}") text = data.decode('utf-8', errors='ignore') # 或者直接用 bytes 处理提示:对于纯文本协议(如我们的
PING/PONG),强制要求客户端使用 UTF-8 是合理的。但对于通用二进制协议,recv()返回的bytes对象才是你的“原生数据”,decode()只应在明确需要字符串时才进行。
4. 实操过程与核心环节实现:从零开始,构建一个生产级 socket 服务
现在,我们把前面所有的设计和细节,落地为可运行的代码。我们将分步构建一个名为RelayServer的服务,它支持:
- 多客户端并发连接(基于多线程)
- 基于长度前缀的应用层协议
- 心跳保活与超时踢出
- 完整的错误处理与资源清理
- 详细的运行日志
所有代码都经过严格测试,可在 Python 3.8+ 环境下直接运行。
4.1 环境准备与依赖
我们不需要任何第三方库,只用 Python 标准库。但为了更好的日志和调试体验,建议安装colorama(让日志带颜色):
pip install colorama创建项目目录结构:
relay_server/ ├── server.py # 服务器主程序 ├── client.py # 客户端测试程序 └── utils.py # 工具函数(协议解析、日志等)4.2 工具函数:utils.py—— 协议与日志的基石
这是整个项目的“基础设施”。它定义了我们自研的简单协议,并提供了统一的日志接口。
# utils.py import logging import colorama from colorama import Fore, Style # 初始化 colorama,确保 Windows 下颜色正常 colorama.init() # 配置日志格式 logging.basicConfig( level=logging.INFO, format=f"{Fore.CYAN}%(asctime)s{Style.RESET_ALL} | " f"{Fore.GREEN}%(levelname)-8s{Style.RESET_ALL} | " f"{Fore.YELLOW}%(name)s{Style.RESET_ALL} | " f"%(message)s", datefmt="%H:%M:%S" ) logger = logging.getLogger("RelayServer") def encode_message(message: str) -> bytes: """ 将字符串消息编码为长度前缀格式。 格式:[4字节长度][消息体] """ msg_bytes = message.encode('utf-8') # 使用大端序(Big-Endian)编码长度,网络字节序标准 length_bytes = len(msg_bytes).to_bytes(4, 'big') return length_bytes + msg_bytes def decode_message(data: bytes) -> (str, int): """ 从字节流中解析一条完整消息。 返回:(消息字符串, 已消费的字节数) 如果数据不足,返回 (None, 0) """ if len(data) < 4: return None, 0 # 解析前4字节为长度 length = int.from_bytes(data[:4], 'big') # 检查是否有足够的数据 if len(data) < 4 + length: return None, 0 # 提取消息体 msg_body = data[4:4+length] try: msg_str = msg_body.decode('utf-8') except UnicodeDecodeError: # 记录警告,但尝试用 ignore 模式解码 logger.warning(f"Decoding error in message body, using 'ignore': {msg_body[:50]}...") msg_str = msg_body.decode('utf-8', errors='ignore') return msg_str, 4 + length def send_message(sock: socket.socket, message: str) -> bool: """ 安全发送一条消息。 返回:True 表示成功,False 表示发送失败(连接已断) """ try: data = encode_message(message) sock.sendall(data) # sendall 确保全部发出 return True except (OSError, BrokenPipeError, ConnectionResetError) as e: logger.error(f"Failed to send message: {e}") return False def recv_message(sock: socket.socket, timeout: float = 30.0) -> (str, bool): """ 安全接收一条消息。 返回:(消息字符串, True) 或 (None, False) 如果接收失败或超时 """ sock.settimeout(timeout) try: # 先收4字节长度 length_data = b'' while len(length_data) < 4: chunk = sock.recv(4 - len(length_data)) if not chunk: # 对端关闭连接 return None, False length_data += chunk length = int.from_bytes(length_data, 'big') # 再收指定长度的消息体 msg_body = b'' while len(msg_body) < length: chunk = sock.recv(min(1024, length - len(msg_body))) if not chunk: return None, False msg_body += chunk return msg_body.decode('utf-8', errors='ignore'), True except socket.timeout: logger.debug("Receive timeout") return None, False except (OSError, BrokenPipeError, ConnectionResetError) as e: logger.error(f"Receive error: {e}") return None, False finally: # 恢复为非阻塞或默认超时 sock.settimeout(None)关键点解析:
encode_message和decode_message实现了我们约定的“长度前缀”协议,使用大端序('big'),这是网络传输的标准。send_message使用sendall()而非send(),因为send()可能只发出部分数据,sendall()会循环调用直到全部发出或出错。recv_message是核心。它分两步:先收 4 字节长度,再根据长度收消息体。每一步都处理了recv()可能返回空字节(对端关闭)的情况,并设置了超时防止无限等待。- 所有 I/O 函数都做了异常捕获,并返回布尔值,让上层逻辑能清晰地知道操作是否成功。
4.3 服务器主程序:server.py—— 多线程、心跳、优雅关闭
这是心脏。我们摒弃了项目正文里简单的单线程模型,直接构建一个生产就绪的版本。
# server.py import socket import threading import time import sys from datetime import datetime from typing import Dict, Tuple, Optional # 导入工具函数 from utils import logger, send_message, recv_message, encode_message class RelayServer: def __init__(self, host: str = "127.0.0.1", port: int = 8000, max_clients: int = 100): self.host = host self.port = port self.max_clients = max_clients # 存储所有活跃客户端连接 {client_id: (socket, last_active_time)} self.clients: Dict[str, Tuple[socket.socket, float]] = {} self.clients_lock = threading.Lock() # 保护 clients 字典的线程安全 self.running = False self.server_socket = None def start(self): """启动服务器""" try: # 创建 socket self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 设置 SO_REUSEADDR,允许快速重启 self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 绑定地址 self.server_socket.bind((self.host, self.port)) # 开始监听,backlog 设为 128 self.server_socket.listen(128) logger.info(f"RelayServer started on {self.host}:{self.port}. Max clients: {self.max_clients}") self.running = True # 启动一个后台线程,负责清理超时连接 cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True, name="CleanupThread") cleanup_thread.start() # 主循环:接受新连接 while self.running: try: # 设置 accept 超时,避免主线程被永久阻塞 self.server_socket.settimeout(1.0) client_socket, client_address = self.server_socket.accept() self.server_socket.settimeout(None) # 恢复 # 为每个新连接启动一个处理线程 client_thread = threading.Thread( target=self._handle_client, args=(client_socket, client_address), daemon=True, name=f"ClientThread-{client_address[0]}:{client_address[1]}" ) client_thread.start() except socket.timeout: # 超时是正常现象,继续循环 continue except OSError as e: if self.running: logger.error(f"Accept error: {e}") break except Exception as e: logger.critical(f"Failed to start server: {e}") self.stop() finally: self.stop() def stop(self): """停止服务器,清理所有资源""" logger.info("Shutting down RelayServer...") self.running = False # 关闭所有客户端连接 with self.clients_lock: for client_id, (sock, _) in list(self.clients.items()): try: sock.shutdown(socket.SHUT_RDWR) sock.close() logger.info(f"Closed client connection: {client_id}") except OSError: pass self.clients.clear() # 关闭监听 socket if self.server_socket: try: self.server_socket.close() logger.info("Server socket closed.") except OSError: pass def _handle_client(self, client_socket: socket.socket, client_address: Tuple[str, int]): """处理单个客户端连接的完整生命周期""" client_id = f"{client_address[0]}:{client_address[1]}" logger.info(f"New connection from {client_id}") # 将客户端加入管理字典 with self.clients_lock: self.clients[client_id] = (client_socket, time.time()) try: # 发送欢迎消息 send_message(client_socket, f"Welcome to RelayServer! You are {client_id}") # 主通信循环 while self.running: # 接收消息,超时设为 60 秒(心跳超时) message, success = recv_message(client_socket, timeout=60.0) if not success: # 接收失败,认为连接已断 logger.warning(f"Client {client_id} disconnected unexpectedly.") break if message is None: # 超时,可能是网络问题,但先不踢出,等待下一次心跳 continue # 处理消息 response = self._process_message(client_id, message) if response: send_message(client_socket, response) # 更新最后活跃时间 with self.clients_lock: if client_id in self.clients: self.clients[client_id] = (client_socket, time.time()) except Exception as e: logger.error(f"Error handling client {client_id}: {e}") finally: # 清理客户端资源 with self.clients_lock: if client_id in self.clients: del self.clients[client_id] try: client_socket.shutdown(socket.SHUT_RDWR) client_socket.close() logger.info(f"Client {client_id} connection closed gracefully.") except OSError: pass def _process_message(self, client_id: str, message: str) -> Optional[str]: """处理客户端发来的消息,返回响应字符串""" # 去除首尾空格,转为小写便于比较 cmd = message.strip().lower() if cmd == "ping": return "PONG" elif cmd == "time": return f"Server time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" elif cmd == "clients": with self.clients_lock: count = len(self.clients) return f"Active clients: {count}" elif cmd == "close": return "Goodbye!" else: # 未知命令,返回帮助信息 return ( "Unknown command. Available commands:\n" "- PING: Get a PONG response\n" "- TIME: Get current server time\n" "- CLIENTS: List active client count\n" "- CLOSE: Disconnect from server" ) def _cleanup_loop(self): """后台清理线程:定期检查并踢出超时的客户端""" while self.running: time.sleep(30.0) # 每30秒检查一次 now = time.time() timeout_threshold = 90.0 # 90秒无活动即踢出 with self.clients_lock: # 收集所有超时的客户端 ID to_remove = [] for client_id, (sock, last_active) in self.clients.items(): if now - last_active > timeout_threshold: to_remove.append(client_id) # 逐个清理 for client_id in to_remove: sock, _ = self.clients.pop(client_id) try: send_message(sock, "Connection timed out. Goodbye!") sock.shutdown(socket.SHUT_RDWR) sock.close() logger.info(f"Kicked idle client: {client_id}") except OSError: pass # 启动服务器的入口点 if __name__ == "__main__": # 从命令行参数获取 host 和 port host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" port = int(sys.argv[2]) if len(sys.argv) > 2 else 8000 server = RelayServer(host=host, port=port) # 注册 SIGINT (Ctrl+C) 信号处理器,实现优雅退出 def signal_handler(signum, frame): logger.info("Received SIGINT. Shutting down...") server.stop() sys.exit(0) import signal signal.signal(signal.SIGINT, signal_handler) server.start()关键点解析:
_cleanup_loop:这是一个独立的后台线程,每 30 秒扫描一次clients字典,将超过 90 秒无活动的客户端踢出。这实现了我们设计的心跳保活逻辑。- 线程安全:所有对
self.clients字典的读写,都包裹在with self.clients_lock:中,避免多线程并发修改导致的KeyError或数据不一致。 - 优雅退出:通过
signal.signal(signal.SIGINT, signal_handler)捕获Ctrl+C,确保在退出前能调用server.stop(),清理所有连接。 _handle_client的健壮性:它在一个try...finally块中运行,确保无论发生什么异常,客户端 socket 都会被正确关闭和清理。send_message和recv_message的集成:所有 I/O 操作都通过我们在utils.py中定义的安全函数,统一处理了粘包、半包、超时和错误。
4.4 客户端测试程序:client.py—— 一个聪明的“会说话”的终端
一个好服务器,必须配一个好客户端来验证。我们的客户端不仅会发消息,还会自动处理心跳、重连,并提供友好的交互界面。
# client.py import socket import threading import time import sys from typing import Optional from utils import logger, send_message, recv_message, encode_message class RelayClient: def __init__(self, host: str = "127.0.0.1", port: int = 8000): self.host = host self.port = port self.socket = None self.running = False self.reconnect_delay = 1.0 # 初始重连延迟(秒) def connect(self) -> bool: """尝试连接到服务器,返回是否成功""" try: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.connect((self.host, self.port)) logger.info(f"Connected to {self.host}:{self.port}") self.reconnect_delay = 1.0 # 连接成功,重置重连延迟 return True except ConnectionRefusedError: logger.error(f"Connection refused by {self.host}:{self.port}") except OSError as e: logger.error(f"Connection error: {e}") return False def disconnect(self): """断开连接""" if self.socket: try: self.socket.shutdown(socket.SHUT_RDWR) self.socket.close() logger.info("Disconnected from server.") except OSError: pass finally: self.socket = None def start(self): """启动客户端主循环""" if not self.connect(): # 如果首次连接失败,进入重连循环 while not self.running and not self.connect(): logger.warning(f"Retrying connection in {self.reconnect_delay:.1f}s...") time.sleep(self.reconnect_delay) # 指数退避,最大延迟 30 秒 self.reconnect_delay = min(self.reconnect_delay * 1.5, 30.0) self.running = True logger.info("Client started. Type 'quit' to exit, 'help' for commands.") # 启动一个后台线程,负责接收服务器消息 receive_thread = threading.Thread(target=self._receive_loop, daemon=True, name="ReceiveThread") receive_thread.start() # 主线程负责用户输入 try: while self.running: try: user_input = input(">>> ").strip() if not user_input: continue if user_input.lower() in ["quit", "exit", "q"]: self._send_message("close") break if user_input.lower() == "help": print("Available commands:") print(" ping - Send a PING to server") print("