资讯动态

基于TCP Socket与事件循环的局域网聊天室实现与调试

发布时间:2026/10/26 20:05:08 来源:尧图企业网站定制
1. 局域网聊天室的技术底座与方案选型局域网里想搞一个能立刻开口说话的群聊工具最省事的路子就是自己写一个 TCP 服务端再用 Socket 把几台机器连起来。这套东西不依赖任何第三方平台断网也能跑路由器下挂着几台笔记本、一台常开的迷你主机、几块开发板只要在同一个网段服务端一启动客户端填个 IP 就能进房间聊天。我做过好几次类似的小工具有给团队内部做值班通知的也有纯粹为了在没有外网的环境里传文件路径和日志片段的核心结构都是同一套。先把话说清楚这里的局域网内聊天室指的不是那种带账号体系、带历史消息、带群管理的完整 IM 产品。它的定位更接近一个局域消息总线——服务端维护一份在线连接表任何一个人发言消息就转给表里的所有人附带昵称、上下线提醒、心跳保活这几样基本就够用了。适合谁来参考我觉得三类人最合适一是刚学完 Socket API、想找个能跑起来的完整项目练手的二是需要在封闭网络环境里自建轻量沟通工具、又不想引入重型中间件的三是想搞清楚 TCP 流式传输里粘包到底怎么回事、事件循环怎么写的人。这三类读者的关注点不一样我在下面会分别照顾到。选型这件事值得多说两句因为它决定了后面所有的代码形态。HTTP 轮询当然也能做聊天室客户端每隔一秒问一次服务端有没有新消息实现起来简单到不需要动脑子。但它的代价很明显没人说话的时候也在发请求延迟被轮询间隔锁死服务端还要扛住 N 倍于实际消息量的空查询。局域网环境本来就没有公网带宽的顾虑可延迟和资源占用是实打实能感觉到的。用 Socket 建长连接服务端一收到消息就能立刻推给所有人延迟基本等于网络往返时间通常在一两毫秒这个量级体验完全不是一回事。1.1 一个消息总线的最小模型我习惯把整个系统的骨架抽象成三个角色监听者、连接表、转发器。监听者是一个绑定了 IP 和端口的 TCP 套接字它的唯一职责是把新进来的连接接住连接表是一份从套接字到客户端状态的映射每条记录里存着昵称、接收缓冲区、发送缓冲区、最后一次活跃时间转发器则负责把某一条连接产生的消息按照规则复制给表里的其他连接。这三块拆清楚之后代码怎么组织都不会乱。这个模型里最容易写歪的地方是接收缓冲区和发送缓冲区必须分开。新手常见写法是收到数据就立刻send给所有人看着没问题实际上有两个隐患一是 TCP 的send并不保证一次把数据全部发出去返回值是实际写入内核缓冲区的字节数剩下的部分得自己排队重发二是当某个客户端网络状况差、读得慢内核发送缓冲区塞满之后send就会阻塞或者返回EAGAIN如果你的服务端是单线程的这一卡就把所有人都卡住了。所以从第一天起就应该把要发出去但还没发完的字节攒在一个 per-connection 的outbuf里由可写事件驱动着慢慢吐。另一个抽象上的选择是消息的边界由谁定义。TCP 给你的是一条字节流它只保证顺序和可靠不保证你调用一次send对方就正好recv一次。你在客户端连发三条你好服务端完全可能一次性收到你好你好你好这十八个字节。所以协议层必须自己划边界这就是后面要讲的帧结构。把这件事想明白后面的代码就顺了。1.2 为什么是 TCP 而不是 UDP 广播发包有人会问局域网里用 UDP 广播不是更简单吗一个人喊话所有人天然都能收到连服务端都省了。这个思路在局域网内发现设备这类场景里确实好用我自己也在后面的自动发现里用了它。但把它当成聊天室的主传输通道会碰到几个绕不开的麻烦。第一是可靠性。UDP 不保证送达也不保证顺序。在办公室的 Wi-Fi 环境下丢包是常态尤其是有人在下载大文件、或者离 AP 比较远的时候。聊天消息丢一条可能就丢了一句关键的服务器密码是 xxx而用户根本不知道自己漏了什么。要做可靠就得自己在应用层实现确认、重传、去重、排序工作量比直接用 TCP 大得多而且很容易写出难以调试的边界情况。第二是消息长度。UDP 单个数据报有大小上限以太网 MTU 通常是 1500 字节减去 IP 头和 UDP 头实际能携带的载荷在 1472 字节左右。超过这个长度IP 层会分片只要有一片丢了整个数据报就作废。聊天里偶尔贴一段日志、一个报错堆栈很容易就超了。TCP 没有这个限制你想发多大都行只是需要自己做分帧。第三是身份和状态。UDP 是无连接的服务端没法天然知道这个人是新来的还是老用户也没法在他掉线时感知到。要做在线列表、进出提醒就得额外维护一套会话状态。用 TCP 的话连接建立和断开本身就是最自然的状态信号。所以我的结论是主通道用 TCPUDP 只用来做房间发现。这个组合在两三台到三五十台机器的规模上都挺合适实现复杂度也可控。1.3 局域网场景带来的三个特殊约束局域网听起来比公网简单实际上有几个约束是公网服务不太会遇到的提前想到能省不少时间。第一个是地址不确定。开发机可能同时有有线网卡、无线网卡、虚拟网卡、容器网桥127.0.0.1、192.168.x.x、10.x.x.x全都在。服务端如果绑定在127.0.0.1上别的机器永远连不上这个坑我见过太多次了。绑定0.0.0.0才是正解它表示监听本机所有网卡地址上的这个端口。但绑完之后你要告诉别人连哪个 IP这就需要一个获取本机局域网地址的小工具函数后面我会给。第二个是防火墙和网络隔离。很多系统默认会拦截入站的监听端口第一次运行时弹出的对话框如果被点了拒绝后面就一直是连不上而且没有任何提示只能看到连接超时。还有一些办公 Wi-Fi 开了客户端隔离AP isolation同一个 SSID 下的两台设备互相看不见这种情况下再怎么改代码都没用只能换网络或者接有线。第三个是设备休眠。笔记本合盖、手机锁屏、无线网卡省电模式都会让 TCP 连接进入一种假死状态——服务端那边 socket 还在但对面已经不响应了。TCP 本身不会立刻发现这种情况可能要好几分钟才超时。所以应用层心跳不是可选项是必须项。2. 协议设计把说人话翻译成字节流动手写第一行代码之前我建议先把协议定下来。聊天室的协议其实非常简单但它一旦定错后面所有代码都要跟着返工。我在早期版本里吃过这个亏一开始图省事直接send用户输入的字符串服务端原样转发跑起来一切正常。直到有人在输入框里粘贴了一段带换行的多行文本客户端按行切分的逻辑当场崩掉整条连接的数据全乱了。2.1 粘包不是bug是TCP的本来面目先纠正一个说法。很多资料管这个叫粘包好像 TCP 有什么缺陷似的。其实 TCP 就是一个字节流管道它对消息这个概念一无所知你把 100 字节写进去对面可能分 3 次读到 30、50、20也可能一次读到 100还可能和下一次写入的数据合并在一起被读到。这是流式协议的正常行为不是错误。真正的问题在于应用层没有定义边界。想象一下你往一根水管里倒水对面接水的人怎么知道哪一段是这一杯、哪一段是下一杯办法只有两个要么每次倒固定容量定长帧要么在每杯水前面贴个标签写清楚这杯有多少毫升长度前缀。定长帧对聊天场景不合适因为消息长度天然参差不齐短消息要补齐浪费带宽长消息又放不下。所以业界通用做法是长度前缀。还有一个隐藏得更深的坑半包。假设对方先发了一个 200 字节的帧因为网络原因你第一次recv只收到 120 字节。这时候你不能急着解析因为剩下 80 字节还在路上。正确的做法是把收到的字节全部追加到该连接的接收缓冲区然后循环尝试从缓冲区头部解出一个完整帧解不出来就停手等下一次数据到达。这个循环必须写成while而不是if因为一次recv可能正好包含两个半帧解完第一个之后缓冲区里还有数据。提示接收缓冲区用一个bytes就够了每次self.inbuf data然后切片。消息量不大的时候这点拷贝开销可以忽略如果你要跑很大的吞吐可以换成bytearray配合del buf[:n]来减少中间对象。2.2 长度前缀 类型字节的帧结构我用的帧头是 5 个字节前 4 个字节是网络字节序大端的无符号整数表示类型字节 载荷的总长度第 5 个字节是消息类型。载荷是 UTF-8 编码的文本。用表格看得更清楚字段长度说明length4 字节大端 uint32值为 1 len(payload)type1 字节1聊天 2系统 3心跳请求 4心跳响应payloadlength-1 字节UTF-8 文本类型 3/4 时为空为什么长度字段要把类型字节算进去而不是只算载荷因为这样解析时整帧长度就是4 length一个加法就够不用再记 5 这个魔法数字少一个出错点。为什么用大端因为网络传输的通用约定就是大端struct的!前缀会自动处理和对面用什么机器无关。解析逻辑写成这样Python 里struct.unpack_from可以直接在缓冲区上操作不需要先切片复制import struct HDR struct.Struct(!IB) # 4字节长度 1字节类型共5字节 def decode_frames(buf): 从 buf 中尽可能多地解出完整帧返回 (消息列表, 剩余buf) msgs [] offset 0 total len(buf) while total - offset HDR.size: length, mtype HDR.unpack_from(buf, offset) if length 1 or length 1024 * 1024: raise ValueError(非法帧长度: %d % length) frame_end offset 4 length if frame_end total: break # 半包等下次数据 payload buf[offset 5:frame_end] msgs.append((mtype, payload)) offset frame_end return msgs, buf[offset:]这段代码里有两个防御性检查值得说一下。length 1是防止对面发来畸形数据导致无限循环length 1MB是设一个上限避免恶意或者损坏的连接声明一个 2GB 的帧把内存打爆。这个上限该设多大取决于你的场景聊天室 1MB 绰绰有余如果你要传文件就另说。编码封装同理一条消息拼成HDR.pack(len(payload)1, mtype) payload。注意payload必须是bytes字符串要先.encode(utf-8)。中文在 UTF-8 下是 3 个字节长度按字节算而不是按字符算这一点务必别搞混——用len(你好)得到 2 然后拿去当字节长度是一定会出问题的。2.3 心跳、超时与优雅下线的设计取舍心跳的间隔和超时阈值怎么定这个没有标准答案但有个取舍逻辑可以讲。间隔太短浪费流量也浪费服务端的唤醒次数间隔太长掉线感知慢。我一般取20 秒发一次心跳服务端 60 秒没收到任何数据就判定掉线。这组数字的依据是三倍于心跳间隔的容忍度能扛住一两次偶发的丢包或调度延迟同时最坏情况下 1 分钟内就能发现掉线并广播某某离开了。心跳包用独立的类型3 和 4载荷为空这样服务端不需要解析内容就能识别。服务端收到 PING 就回一个 PONG同时更新该连接的last_active。客户端收到 PONG 也可以更新自己的计时如果连续三次 PING 都没收到回应就主动断开重连——这一点很重要因为有时候是服务端挂了客户端傻等着不会有任何结果反而让用户以为没人说话。优雅下线包括两种情况。一种是用户主动退出客户端先发一条类型为 1 的聊天消息内容是我走了然后调用shutdown(SHUT_WR)关闭写方向再读一会儿最后close。这样服务端能明确知道这是正常离开而不是网络故障可以给个友好的提示。另一种是直接关窗口或者断电服务端只能靠心跳超时来发现。对于广播某人离开了这件事我建议服务端在两种情况下都发同一句文案用户不关心技术上是怎么区分的。还有一个细节服务端在清理连接时要记得先把outbuf里剩下的数据尽量发完再关否则别人看到某某离开了这条消息的时候当事人自己可能都没收到上一条回复。简单做法是先shutdown(SHUT_WR)然后注册一个短时间的定时器到点再真正close。嫌麻烦的话直接关也能接受只是偶尔会丢最后一条消息。3. 服务端实现从多线程到事件循环服务端的写法有好几档从最笨的到比较讲究的我按复杂度递增说。选哪一档取决于你的规模预期十个人以内最笨的写法完全够用代码还更好懂超过二十个人或者想让服务端常驻跑几个月事件循环那一档更稳。3.1 最朴素的多线程阻塞模型与它的天花板思路很简单主线程accept到新连接就threading.Thread(targethandle, args(conn,))起一个线程这个线程里循环conn.recv(4096)收到就解码然后遍历全局的客户端列表挨个send。代码总共不到六十行十分钟能写完。好处是可读性极高每个连接对应一个线程写起来几乎和在纸上画流程一样直白。对于临时验证、做 demo、给同事演示我一直觉得这个版本最合适没必要为了看起来专业上一堆抽象。但它有明确的天花板。每个线程在内核里都要占栈空间和调度开销Python 还有 GIL虽然recv/send这类 IO 调用会释放 GIL可真正处理消息的时候还是串行的。几十个连接没问题几百个就开始吃力连接数再多线程创建销毁本身就成了负担。更麻烦的是共享状态客户端列表在多个线程里被读写不加锁会有各种诡异的竞态加了锁又容易写出死锁。还有一个隐性问题——线程里抛出的异常不会传播到主线程某个客户端触发的错误可能静默地把线程干掉连接却没清理造成泄漏。所以我的判断是多线程版用来验证协议、用来教学、用来做一次性工具都没问题要长期跑着不动的建议上事件循环。3.2 用 selectors 写单线程事件循环Python 标准库里的selectors模块把 epoll、kqueue、select 这些底层机制统一了接口用它可以写出跨平台的单线程事件循环。核心思想是不要阻塞在任何单个连接上而是问操作系统哪些套接字现在可读/可写然后只处理那些就绪的。先定义连接状态对象把所有 per-connection 的数据装进去import selectors, socket, struct, time HDR struct.Struct(!IB) T_CHAT, T_SYS, T_PING, T_PONG 1, 2, 3, 4 MAX_OUTBUF 256 * 1024 IDLE_TIMEOUT 60.0 class Conn: __slots__ (sock, addr, inbuf, outbuf, name, last_active, alive) def __init__(self, sock, addr): self.sock sock self.addr addr self.inbuf b self.outbuf b self.name None self.last_active time.time() self.alive True服务端主体构造时把监听套接字注册进 selector事件类型是EVENT_READdata设为None用来区分这是监听套接字和这是普通连接class ChatServer: def __init__(self, host0.0.0.0, port9527): self.sel selectors.DefaultSelector() self.lsock socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.lsock.bind((host, port)) self.lsock.listen(128) self.lsock.setblocking(False) self.sel.register(self.lsock, selectors.EVENT_READ, None) self.conns {} # sock - Conn self.room_name 默认房间SO_REUSEADDR这行不能省。服务端刚崩过一次、TIME_WAIT 状态还没过的时候不加这个选项重新启动会报地址已被占用。这在开发阶段特别烦人因为你会反复重启。主循环def run(self): while True: for key, mask in self.sel.select(timeout1.0): if key.data is None: self._accept_all() continue conn key.data if mask selectors.EVENT_READ: self._on_readable(conn) if conn.alive and (mask selectors.EVENT_WRITE): self._on_writable(conn) self._sweep_idle()select(timeout1.0)里的超时参数是为了让循环至少每秒醒一次好去做空闲连接扫描。如果你只是纯 IO 驱动、不做定时任务可以传None一直阻塞CPU 占用接近零。_accept_all必须用while True循环直到抛出BlockingIOError为止。因为可能同时有多个连接排队只accept一次的话剩下的要等下一轮事件虽然也能跑但会平白多出一轮延迟def _accept_all(self): while True: try: sock, addr self.lsock.accept() except BlockingIOError: return sock.setblocking(False) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) conn Conn(sock, addr) self.conns[sock] conn self.sel.register(sock, selectors.EVENT_READ, conn)TCP_NODELAY这一句是为了关掉 Nagle 算法。Nagle 算法会攒着小包等确认再发对文件传输是好事对聊天这种每条消息都很短、又要立刻送达的场景就是负担最坏情况能凭空加出几十毫秒延迟。打开TCP_NODELAY之后小包立刻发出代价是包数量变多局域网里这个代价可以忽略。读事件的处理是整段代码里最需要小心的def _on_readable(self, conn): try: data conn.sock.recv(8192) except BlockingIOError: return except (ConnectionResetError, OSError): self._drop(conn, 连接异常断开) return if not data: self._drop(conn, 客户端主动关闭) return conn.last_active time.time() conn.inbuf data try: msgs, conn.inbuf decode_frames(conn.inbuf) except ValueError: self._drop(conn, 协议错误) return for mtype, payload in msgs: self._handle(conn, mtype, payload) if not conn.alive: break三种异常分开处理是有讲究的。BlockingIOError说明内核缓冲区暂时没数据正常现象直接返回ConnectionResetError说明对面是强行断开的比如进程被杀这条连接已经废了recv返回空字节串是 TCP 的有序关闭信号对端调用了close或者shutdown。三种情况的处理动作其实一样都是清理但日志里区分开排查问题时会省很多时间。写事件的处理负责把outbuf里的积压吐出去def _on_writable(self, conn): try: if conn.outbuf: sent conn.sock.send(conn.outbuf) conn.outbuf conn.outbuf[sent:] except BlockingIOError: pass except OSError: self._drop(conn, 发送失败) return if not conn.outbuf: self.sel.modify(conn.sock, selectors.EVENT_READ, conn)最后那句modify是关键。当outbuf空了要把监听事件改回只读否则会一直触发可写事件循环空转CPU 直接跑满。这个坑我在第一次写事件循环的时候就踩了现象是服务端日志刷得飞快但什么也没干。3.3 广播的实现与慢客户端背压处理广播逻辑本身很简单遍历所有连接把同一份字节追加进各自的outbuf然后给有数据待发的连接注册可写事件def _broadcast(self, frame, excludeNone): for conn in list(self.conns.values()): if not conn.alive or conn is exclude: continue conn.outbuf frame if len(conn.outbuf) MAX_OUTBUF: self._drop(conn, 发送积压过多) continue self.sel.modify(conn.sock, selectors.EVENT_READ | selectors.EVENT_WRITE, conn)这里的MAX_OUTBUF就是背压backpressure的体现。设想某个客户端连上了但完全不读数据比如被挂起、或者卡在断点里服务端会不停地往它的outbuf里塞消息内存一路涨。设一个上限超了就把这个连接踢掉这样代价可控。256KB 对一个文本聊天室来说意味着积压了几千条消息正常情况下不可能达到一旦触发基本可以确定这条连接已经病入膏肓。exclude参数用来支持这条消息不发给发送者自己。有两种做法一是服务端广播给所有人含自己客户端自己过滤二是服务端直接排除。我更倾向后者省带宽也省客户端逻辑。但要注意如果客户端要做我的消息已发送这种本地回显那本地已经显示过一遍了再收回来就会重复。_handle里根据类型分发def _handle(self, conn, mtype, payload): if mtype T_PING: self._send_frame(conn, T_PONG, b) elif mtype T_CHAT: if conn.name is None: name payload.decode(utf-8, replace).strip()[:16] or 匿名 conn.name name self._broadcast(sys_frame(%s 加入了房间 % name)) self._send_frame(conn, T_SYS, (欢迎 %s % name).encode()) else: text payload.decode(utf-8, replace) self._broadcast(chat_frame(%s: %s % (conn.name, text)), excludeconn)注意decode(utf-8, replace)里的replace参数。网络异常或者客户端 bug 可能送来不合法的 UTF-8 字节默认的strict会直接抛UnicodeDecodeError如果你没接住整个处理函数就断了。用replace把非法字节换成替换符虽然消息内容会有瑕疵但连接还活着用户体验上比突然掉线好得多。4. 客户端实现与局域网发现客户端比服务端要处理的事情更杂因为它要面对用户输入、界面刷新、断线重连这些非技术的问题。我的建议是客户端代码宁可写得笨一点也不要在 UI 同时和网络打架。4.1 收发分离一个线程收一个线程发最自然的结构是两个线程。主线程负责从标准输入读一行编码成帧发出去后台线程负责recv解出消息后打印到屏幕。两个线程共享套接字是安全的因为一个只读一个只写send和recv本身是线程安全的。import socket, struct, threading, sys HDR struct.Struct(!IB) def recv_loop(sock, on_message): buf b while True: try: data sock.recv(8192) except OSError: break if not data: break buf data try: msgs, buf decode_frames(buf) except ValueError: break for mtype, payload in msgs: on_message(mtype, payload.decode(utf-8, replace))发送端要注意一个细节sendall是阻塞式地把所有数据发完它内部会循环调用send。在客户端这种单个连接、消息量很小的场景用sendall完全没问题不用像服务端那样自己维护发送队列。但如果你的客户端也要处理大量并发发送比如拖拽文件还是老老实实上队列。def send_frame(sock, mtype, text): payload text.encode(utf-8) sock.sendall(HDR.pack(len(payload) 1, mtype) payload)登录流程我做得比较土连接成功后先发一条类型为T_CHAT的消息内容就是昵称。服务端约定这个连接的第一条聊天消息当昵称处理后面的才当正文。这个设计不优雅但够用省掉了一种专门的类型定义。如果你要做得正规一点可以加一个T_LOGIN类型参数用 JSON 或者keyvalue形式编码将来扩展字段也方便。4.2 命令行界面不撕裂的刷新技巧这里有个很实际的问题用户正在输入框里打字后台线程突然打印一条新消息光标和文字就会错位屏幕看起来像被撕开一样。这个问题在纯print的世界里没有完美解法但有几档方案可选。最简陋的是每次收到消息就print(\r text \n current_input)——先回车清掉当前行打印消息再把提示符和用户已经输入的内容重新打出来。这需要主线程把当前已输入的半截内容共享出来用一个全局变量加锁保护。效果凑合能用但如果恰好赶上用户按回车的瞬间还是会乱。好一点的做法是用标准库的readline模块。它接管了输入行编辑可以用readline.set_pre_input_hook或者直接在打印前调用readline.redisplay()之类的接口把当前缓冲区重绘出来。需要引入rlcompleter处理一下平台差异代码多二十行效果明显好。最稳的是curses。它把整个终端当成一块画布你负责划分区域上面是消息滚动区下面是输入区库负责差异刷新绝对不会有撕裂。代价是代码量翻几倍而且 Windows 上要装额外的包。我一般只在真的要做个像样的终端 UI 时才用它。还有个偷懒但有效的办法把消息直接追加到一个日志文件同时在屏幕上只打印有新消息按 L 查看。这显然不是聊天室该有的体验但在我只需要能不能通的验证阶段我会先这么干省得调 UI 调半天。4.3 UDP广播自动发现房间手动输入 IP 是件很烦的事。局域网里有个现成的办法服务端额外开一个 UDP 端口监听广播客户端往整个网段发一个探测包服务端回一个应答里面带上房间名和 TCP 端口号。# 服务端 import socket, threading def discovery_server(tcp_port, room_name): u socket.socket(socket.AF_INET, socket.SOCK_DGRAM) u.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) u.bind((0.0.0.0, 9528)) while True: data, addr u.recvfrom(256) if data bDISCOVER: reply CHAT|%d|%s % (tcp_port, room_name) u.sendto(reply.encode(utf-8), addr) threading.Thread(targetdiscovery_server, args(9527, 默认房间), daemonTrue).start()客户端这边要开广播权限u socket.socket(socket.AF_INET, socket.SOCK_DGRAM) u.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) u.settimeout(1.5) u.sendto(bDISCOVER, (255.255.255.255, 9528))SO_BROADCAST不设的话发送广播地址会直接报权限错误。255.255.255.255是受限广播地址能不能传出去取决于路由器配置有些设备会拦。更可靠的写法是算出本网段的定向广播地址比如网段是192.168.1.x/24就发到192.168.1.255。有些系统会把收到的应答里的源地址当成服务端地址直接用那个 IP 去连 TCP 就行不用另外解析。顺便说下获取本机局域网 IP 的小技巧。遍历所有网卡太麻烦最简单的是建一个 UDP 套接字连一个外部地址然后读getsockname()def local_ip(): s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect((8.8.8.8, 80)) # UDP 的 connect 不发包只是让内核选路 return s.getsockname()[0] except OSError: return 127.0.0.1 finally: s.close()这行connect不会真的发出任何数据包UDP 的connect只是绑定一个默认目的地址让内核去查路由表。查出来的源地址就是内核认为出网走这张网卡的那个 IP通常也是别的机器能访问到的那个。在完全离线的环境里这一步会失败那就得退回遍历网卡的方案了。5. 实机调试与高频问题排查代码写完只是开始真正的活是把它在自己的机器上跑通、再在别人的机器上跑通。我接触过的项目里局域网工具卡住的时间有八成不在业务逻辑而在连不上和连上了但收不到这两件事上。5.1 先把网络通路打通IP、端口、防火墙排查一定要分层别一上来就怀疑代码。我习惯的顺序是先用ping确认两台机器能通再用端口探测确认端口是开的最后才看应用日志。ping不通的情况一般不是代码问题是网络本身的问题不在同一个网段、Wi-Fi 开了客户端隔离、虚拟机网络模式选错NAT 模式下宿主机访问不了虚拟机的服务要改成桥接。这一步花两分钟能排除掉一半的可能性。ping通了但连接超时八成是防火墙。服务端绑定了0.0.0.0但系统级的入站规则把 9527 端口拦掉了。这时候的表现很有欺骗性telnet或者nc探测一直挂着服务端日志一条accept都没有看起来像是服务端没启动。我现在的做法是启动服务端之后立刻在本机用nc -vz 127.0.0.1 9527测一次本机能通说明监听没问题问题出在网络的中间环节。还有一个特别隐蔽的情况服务端绑了127.0.0.1。这种情况下本机客户端连得通别的机器永远连不上而且服务端日志一切正常。所以我把0.0.0.0当成默认值写死在代码里不给这个错误留空间。# 本机确认监听 netstat -an | grep 9527 # Linux / macOS # 远端确认端口可达 nc -vz 192.168.1.20 95275.2 常见问题速查表下面这张表是我自己遇到和帮别人解决的次数最多的一些问题按现象索引出问题时可以直接对号入座。现象最可能的原因排查动作本机能连别人连不上绑定在 127.0.0.1检查bind的第一个参数完全连不上无任何日志防火墙拦截 / 不同网段 / Wi-Fi 客户端隔离先 ping再 nc 测端口启动报地址被占用上次进程没退干净TIME_WAIT加SO_REUSEADDR或换端口中文变成一串问号编码不一致服务端按 latin-1 解统一 UTF-8解码加errorsreplace偶尔消息粘成一条没有做长度前缀分帧检查帧头和decode_frames自己发的话收不到广播时排除了发送者确认是否要本地回显服务端 CPU 跑满可写事件没取消循环空转outbuf空了就modify回只读长时间空闲后连接失效无心跳被中间设备清掉会话加 20 秒应用层心跳粘贴大段文字后崩溃单帧超长或超过缓冲区上限提高上限或做分片某个客户端卡住拖慢所有人慢客户端导致send阻塞改非阻塞 发送队列 积压上限表里最后一条我想再展开一句。如果服务端是阻塞式的多线程模型某个客户端卡住其实影响有限因为每个连接有独立线程最多是那个线程卡着。但换到单线程事件循环之后一旦你写成了阻塞send一个慢客户端就能把整个循环卡死所有人都收不到消息。所以事件循环版本里任何 IO 调用都不能是阻塞的这条底线不能破。5.3 小规模压测怎么做功能跑通之后我会做一次简单的压力验证目的不是测出极限而是确认多几个人同时说话不会崩。做法很土写个脚本开 N 个连接每个连接以固定频率发消息观察服务端的 CPU、内存和消息延迟。import socket, threading, time, struct HDR struct.Struct(!IB) def worker(idx, host, port, n): s socket.create_connection((host, port)) s.sendall(HDR.pack(len(u%d % idx) 1, 1) (u%d % idx).encode()) for i in range(n): text (m%d % i).encode() s.sendall(HDR.pack(len(text) 1, 1) text) time.sleep(0.05) time.sleep(2) s.close() threads [threading.Thread(targetworker, args(i, 192.168.1.20, 9527, 100)) for i in range(30)] [t.start() for t in threads] [t.join() for t in threads]跑 30 个连接、每个发 100 条总共 3000 条消息。我一般关注三个数字服务端进程的 CPU 占用峰值、内存增长是否收敛、以及消息从发出到被别的客户端收到的时间。CPU 偶尔冲到几十个百分点是正常的只要回落到个位数就说明没有空转内存如果持续线性上涨不回落说明有连接或者缓冲区没被回收大概率是_drop里漏了从字典里删条目或者漏了unregister。还有一个容易被忽略的点连接数上来之后操作系统的文件描述符上限会先扛不住报Too many open files。Linux 上ulimit -n看一眼默认可能只有 1024。局域网聊天室的量级肯定够但如果你照着这个结构做别的东西记得把这个数调一下。最后分享一个我在实际使用中的体会这套东西真正难的从来不是把消息从 A 发到 B而是把异常路径处理干净——对面拔网线、防火墙中途改规则、笔记本合盖、IP 被 DHCP 重新分配。我后来的习惯是每次写完核心流程专门花半天时间去做破坏性测试拔线、杀进程、断网重连、把服务端挂了再开。这半天花下去换来的是一个能连续跑几个星期不用管的服务端比后面反复救火划算得多。另外如果你打算把这个工具长期挂着用记得把日志写到文件里而不是只打屏幕出问题时那几行带时间戳的记录往往比任何调试器都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑