1. 从一条消息被转发出去说起:局域网聊天室的真实骨架
用 Socket 在局域网内做聊天室,是很多人接触网络编程的第一个"能摸得着"的项目。它的诱人之处在于:写完之后,同一间办公室、同一间宿舍、同一层楼里的几台设备真的能互相说话,那种"我造了一个网络应用"的实感,比任何教程里的回显服务器都强。但它也极容易翻车——大多数人写出来的第一版,能连上、能发出去,可消息偶尔少一句、中文变成问号、某个客户端一退出整间屋子就卡住。这篇东西不打算从"什么是 Socket"讲起,而是把这件事按工程视角拆开:服务端那几百行到底在管什么、客户端为什么会在两个线程之间打结、报文边界为什么是绕不开的坎、以及从 Demo 到"能让人用"之间还差哪几步。
先把最朴素的模型说清楚。局域网聊天室的本质不是"点对点聊天",而是一个中心转发节点加若干条长连接。每个人连上服务端之后,连接一直挂着,谁说了话,服务端把这句话原样复制给其他所有还挂着的连接。整个系统里只有服务端知道"现在有谁",客户端彼此之间根本不知道对方的存在,也不需要知道。这个"星形拓扑"是几乎所有人的起点,因为它把最难的寻址问题消掉了——局域网里动态分配地址很常见,让 A 直接找 B 的 50007 端口听上去很酷,但一旦设备换了个 IP,整个通讯录就废了。
所以正确的心智模型是:服务端是一台复印机,客户端是接纸盒。客户端只负责三件事——连上去、把自己的话投进去、把印出来的话显示出来。剩下的在线名单、消息分发、异常剔除,全是服务端的事。想清楚这一层,后面所有的线程与锁的争论都会变得简单,因为你知道了唯一需要为并发付代价的地方在复印机内部,而不是每个接纸盒里。
提示:如果你的第一版把"谁在线"存成了每个客户端各自维护的一份列表,那你迟早会面对列表不一致的 bug。名单必须是服务端单一事实来源,客户端只做展示。
1.1 TCP 还是 UDP:别在文字聊天上用 UDP 赌运气
几乎每次有人问这个问题,我都建议先用 TCP。理由不是"TCP 更高级",而是文字消息这个场景对顺序和完整性的容忍度极低。一句话拆成两个 UDP 包,第二个包先到或者干脆丢了,用户看到的就是一句缺字的怪话,而且没有任何机制提醒他"这句不完整"。TCP 帮你把重传、排序、去重全做完了,你要付的代价只是一点点延迟和每个连接一个文件描述符,在局域网里的机器上这点开销完全可以忽略。
UDP 真正的价值场景有两个:一是局域网发现,服务端不知道自己的地址,客户端也不知道服务端在哪,这时候用 UDP 广播往 255.255.255.255 打一个探测包,让服务端回一个应答,比让用户手输 IP 友好得多;二是语音、视频这类"晚到的数据没意义"的流。这两件事和文字聊天是两个问题,别混在一起做取舍。我的建议是:聊天主通道走 TCP,发现通道走 UDP 广播,两者互不干扰。
| 维度 | TCP | UDP |
|---|---|---|
| 文字聊天主通道 | 推荐,天然有序可靠 | 不推荐,丢包即内容损坏 |
| 局域网自动发现 | 不适合(无连接无法广播) | 推荐,一行 sendto 就能探测 |
| 服务端并发成本 | 每连接一线程/一事件 | 无连接状态,成本低 |
| 断线感知 | 读到空或异常即知 | 需要额外心跳超时 |
| 局域网内延迟 | 毫秒级,够用 | 略低,但差距肉眼不可见 |
这张表不是让你背,而是让你在"我要不要为了性能换 UDP"这种念头冒出来的时候有个判断依据。局域网带宽通常不是瓶颈,消息量也不大,性能从来不是这个项目的难点,正确性和可维护性才是。
1.2 很多人第一版就栽在"能连上但消息乱"上
这个现象极其普遍,表现为:两个人同时打字,对方的屏幕上出现半句自己的话;或者一个人发了一段长文本,接收端收到的内容被切成了两段,第二段跑到了下一条消息后面。新手往往会怀疑是"网络丢包了",然后用 ping 一通测,发现 0% 丢包,于是陷入迷茫。
真实原因九成是报文边界问题,也就是俗称的粘包、拆包。TCP 是字节流,不是消息流。你调三次sendall,对面完全可能一次recv就把这三段全读出来;你调一次sendall发一段长文本,对面也可能需要三次recv才读全。TCP 只保证字节顺序,不保证"一次 send 对应一次 recv",这是协议设计如此,不是 bug。
剩下的那一成原因,是多个线程同时往同一个连接上写。sendall在内部是循环调用send的,两个线程同时进入,写到一半被切走,另一个线程的数据插进来,接收端拼出来的就是一锅粥。这个坑特别隐蔽,因为单客户端测试时永远复现不了,一旦有三五个人同时说话就必现。
注意:报文边界和多线程写冲突是两个完全独立的问题,但它们的表现很像——都是"内容错位"。排查时先确认接收端有没有做缓冲区切分,再确认发送端有没有给每个连接配写锁。
2. 服务端的三个核心部件:连接表、读写循环、线程模型
把服务端拆成三个部件看,思路会清晰很多。连接表负责"现在有谁",本质上是一个字典,键是 socket 对象,值是这条连接附带的元信息(对端地址、昵称、上次活跃时间、写锁)。读写循环是每个连接一个的处理函数,它阻塞在自己的recv上,收到数据就切分、解析、分发。线程模型决定了这三者怎么协作,最简单的做法是主线程只管accept,每个新连接开一个守护线程跑读写循环。
这个模型的优点是极其好懂,两百行以内能写完。缺点是连接数上来之后线程数跟着涨,几百个连接就开始有点吃力,但局域网聊天室的实际规模通常是个位数到几十个人,这个开销完全在可接受范围。不要为了"以后可能有一万人用"提前上异步框架,那会让一个本来一天能写完的项目拖成一周,而且调试难度陡增。先把同步模型跑通,等真的遇到瓶颈再换,这是我这几年最坚持的一条工程直觉。
2.1 连接表用字典存 socket 对象,而不是存 IP
用一个字典clients记录在线者,键选什么很有讲究。用 IP 做键看起来很自然,但会踩两个坑:同一台机器开两个客户端(调试时非常常见)会互相覆盖;NAT 环境下多台设备可能共享同一个出口地址。用 socket 对象本身做键就没有这些问题,因为每个连接在系统里是唯一的,即便同一个人开了十个窗口,也是十条独立的连接,各自有独立的昵称。
值里建议至少放三个字段:对端地址(出错时打日志用)、昵称(可能还没设置,所以初始为None)、一把独立的写锁。这把写锁是后面所有并发问题的解药,一个连接一把,不是全局一把——全局锁会让所有发送串行化,虽然聊天室消息量下影响不大,但没必要的串行就是没必要的等待。
import socket import threading HOST = "0.0.0.0" PORT = 50007 SEP = b"\n" clients = {} # conn -> {"addr":.., "nick":.., "wlock":..} registry_lock = threading.Lock() # 保护 clients 字典本身的增删查registry_lock和每条连接的wlock是两把不同用途的锁,这点必须分清楚。前者保护"字典结构不被并发修改",后者保护"这条连接的字节流不被两个线程交错写入"。很多人只上一把全局锁,结果发送时把整个服务端卡住,别人连accept都进不来。
2.2 accept 主循环越长越好,别在里面干重活
主循环写得越短越好。它只做四件事:接受连接、设置一下TCP_NODELAY、登记到连接表、开线程。任何解析、任何日志落盘、任何业务判断都不要放进来,因为这段代码是串行的,它多花一毫秒,所有新连接就晚一毫秒被接受。
TCP_NODELAY这一行值得单独说。Nagle 算法会把小包攒起来再发,目的是减少网络上的小包数量,代价是延迟。聊天消息通常就几十个字节,正好是 Nagle 最喜欢攒的那种,在局域网里攒个几十毫秒完全没必要。关掉它,消息到达的即时感会明显好一截,用户能感知到"我按下回车对方就到了"。
def serve_forever(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(64) print(f"聊天服务已启动,监听 {HOST}:{PORT}") while True: conn, addr = srv.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) with registry_lock: clients[conn] = {"addr": addr, "nick": None, "wlock": threading.Lock()} threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()SO_REUSEADDR这一行也建议加上。它解决的是服务端重启时bind报"地址已被占用"的问题——上一个进程退出后,端口会进入一段时间的 TIME_WAIT,不加这个选项,你得等几十秒才能重新启动,调试期间非常折磨人。daemon=True保证主进程退出时子线程跟着走,不会留下孤儿进程占着端口。
2.3 阻塞读与缓冲切分:一条连接的处理函数长什么样
处理函数是这套代码里最有信息量的一段。它维护一个字节缓冲区,每次recv回来的数据都追加进去,然后在循环里按分隔符切出完整的行,剩下的半截留在缓冲区里等下一次。这个"切-留"的动作就是解决粘包拆包的全部秘密,没有更复杂的东西。
选\n做分隔符,是因为它几乎不会出现在正常的聊天文本里(用户想发换行可以约定一个转义写法),而且调试时用 telnet 直连就能手工发消息验证。分隔符的选择没有绝对优劣,长度前缀(先发 4 字节表示后续长度)更严谨、能容纳任意字节内容,但对新手不友好,还得处理字节序。文字聊天用\n足够。
def handle_client(conn, addr): buf = b"" while True: try: chunk = conn.recv(2048) except OSError: break # 连接被重置等异常 if not chunk: break # 对端正常关闭,recv 返回空字节 buf += chunk while SEP in buf: line, buf = buf.split(SEP, 1) if len(line) > 4096: # 单行上限,防恶意超长 continue text = line.decode("utf-8", errors="replace").strip() if text: process_message(conn, text) drop_client(conn)recv返回空字节是"对端优雅关闭"的唯一可靠信号,一定要处理,否则这个线程会永远卡在那里。异常分支也不能省,客户端崩溃、网线拔掉、系统回收连接,都会在这里抛出来。两个分支最终都导向同一个drop_client,把连接从表里摘掉、关掉、广播一条离场消息——清理逻辑必须只有一个出口,写成两处迟早会漏一个。
2.4 分发函数的写法决定了后面要不要重构
process_message是整个服务端的业务中枢,它要做的事其实只有三件:如果是设置昵称的指令,就更新连接表并广播;如果是普通消息,就加上昵称前缀转发出去;如果是心跳,就更新活跃时间、不转发。把这三件事写在一个函数里,用startswith或者简单的字符串切分判断指令类型就够了,不必上正则,也不必设计复杂的协议树。
转发函数broadcast的实现要点是:先在锁里把目标列表复制出来,然后出锁去逐个发送。如果拿着锁去发送,一旦某个接收端因为网络问题卡在sendall上,整个服务端就会被这一条坏连接冻住——这是我在早期项目里真真切切被咬过一次的教训,现象是所有客户端同时"消失",但服务端进程还活着,日志停在某一刻不动。
def broadcast(text, exclude=None): with registry_lock: targets = [c for c in clients if c is not exclude] for c in targets: send_line(c, text) def send_line(conn, text): with registry_lock: info = clients.get(conn) if info is None: return False data = text.encode("utf-8") + SEP try: with info["wlock"]: conn.sendall(data) return True except OSError: return Falsesend_line里查表也用同一把registry_lock,看起来多余,实际上是为了防止"发送到一半,连接被另一个线程摘掉了"这种竞态。代价是每次发送都要短暂抢一次锁,但抢锁的时间是微秒级,和网络发送的毫秒级比起来可以忽略。
3. 客户端的两个线程:为什么你的控制台界面总是卡住
客户端比服务端更容易写歪,因为多了一个input()这个阻塞到天荒地老的调用。新手最典型的写法是:读一行、发出去、再读一行、再发出去,中间穿插着recv。问题在于,input()阻塞的时候,recv根本没机会执行,对面发来的消息要等你按下回车才会被读出来,显示出来的效果就是"聊天室半天没动静,我一打字别人的话全冒出来了"。
正确的做法是把读和写彻底分到两个线程:主线程只负责input()和sendall(),一个后台线程专门跑recv循环并把内容打印出来。两个线程共享同一个 socket,但一个只读一个只写,方向不重叠,因此在 socket 层面是安全的,不需要额外锁。唯一需要协调的是终端输出——用户正在输入的时候,后台线程插进来打印一行,会把用户敲了一半的内容冲掉。
这个问题没有完美解,常见的两种处理:一是打印前先输出一个回车和提示符,让视觉上"重新开始一行";二是干脆把接收内容推到一个队列里,主线程每轮输入前检查队列并打印。后者更干净,但会让消息显示有延迟感。我一般选前者,加个\r和重新打印>提示符,够用了。
3.1 断线重连不是"加个 while True"就完事
网络断了之后自动重连,听起来是包一层while True: try: connect() except: sleep(1)。实际上这样写会带来两个问题:一是重连成功后,昵称丢了,得重新发送一次;二是重连期间用户敲进去的消息全丢了,用户会觉得"我刚才明明发了"。
比较务实的处理是:断线时在界面上明确打一行提示,把当前状态标成"已断开",输入的内容先缓存到一个列表里,最多缓存最近若干条,重连成功后统一补发。补发的消息会带上时间戳,接收端看起来就是几条挤在一起的历史消息,虽然不完美但至少没丢。同时把昵称自动重发一次,用户无感。
重连的间隔也不该是固定 1 秒。固定间隔在服务端重启的场景下会造成大量无效连接请求砸过去。用递增的退避策略更稳妥:1 秒、2 秒、4 秒、8 秒,封顶 30 秒。这个小细节在只有几个人的局域网里看不出差别,但只要有人挂着客户端过夜,第二天早上服务端重启,就能看出区别。
def recv_loop(sock): buf = b"" while True: try: chunk = sock.recv(2048) except OSError: print("\n[连接异常断开]") return False if not chunk: print("\n[服务端已关闭连接]") return False buf += chunk while b"\n" in buf: line, buf = buf.split(b"\n", 1) text = line.decode("utf-8", errors="replace") print("\r" + text) print("> ", end="", flush=True)注意这里的buf逻辑和服务端是一模一样的。只要涉及 TCP,接收端就必须自己切分报文,这条规则客户端服务端通吃,没有任何例外。
3.2 用户输入那一侧也有陷阱
input()有几个容易忽略的行为。其一,空输入按回车会返回空字符串,如果不判空直接发送,服务端会收到一个空行,触发一次无意义的分发。其二,用户可能粘贴一段带\n的多行文本,input()只读第一行,剩下的留在标准输入缓冲区里,会被下一次input()吃掉,表现就是"我粘贴了一段话,发出去只有第一行,后面几行莫名其妙被当成新消息发了"。
针对第二点,我的处理是发出去之前把所有换行符替换成一个约定的转义字符(比如⏎或者\n的字面两字符写法),接收端展示时再还原。这样既保留了消息完整性,又不破坏以\n为分隔符的协议。这个约定要写在协议文档里,客户端和服务端都遵守,否则会出现一端还原一端不还原的混乱。
还有一个小技巧:在input()之前先print("> ", end="", flush=True),让提示符一直挂着,用户视觉上就有"我随时可以输入"的确定感。这个细节对使用体验的影响,比多写一百行功能代码都大。
3.3 客户端也要处理"服务端没了"这件事
服务端进程被Ctrl+C终止时,所有客户端的连接会被系统关闭,recv立刻返回空字节。如果客户端没处理这个分支,后台线程会退出,但主线程还在input()上傻等,用户敲了半天发现发不出去,也没有任何提示。这种"静默失败"是最伤体验的。
解决办法是让recv线程在退出时设置一个共享标志位,主线程每次循环前检查这个标志,发现已断开就打印提示并把输入重定向到"待补发队列"。标志位用threading.Event就够,不需要更复杂的东西。任何时候,客户端都必须能回答"我现在到底连没连着"这个问题,否则用户无法判断是自己的问题还是网络的问题。
4. 自定义文本协议:一行字符串里塞进多少信息
协议这块,很多人一上来就想设计二进制包头,结果被字节序、对齐、变长字段折腾一整晚。局域网文字聊天室完全不需要这个复杂度,用纯文本、以|分隔字段、以\n结尾,就能表达绝大多数需求,而且调试时人能直接看懂。
我常用的字段款式是指令|参数1|参数2|正文。举几个具体的:NICK|张三表示设置昵称;MSG|大家好表示群发;PRIV|李四|你吃了吗表示私聊;PING表示心跳。解析时按|切分,取第一段判断指令,剩下的按位置取参数。这套格式的好处是:用 telnet 连上去手敲一行MSG|测试就能验证服务端逻辑,不用写专门的测试客户端。
字段分隔符的选择要注意冲突。如果正文里可能出现|,切分时split("|", 2)这种限定次数的写法就很关键——只有前几段是指令和参数,最后一段整体作为正文,不允许再切。这个细节不处理,用户发一句"a|b|c"就会把服务端的解析逻辑搞乱,轻则消息错位,重则触发意料之外的分支。
4.1 指令扩展的克制:先想清楚哪些是真需求
刚跑通群聊的人往往忍不住加功能:表情、图片、撤回、@提醒、消息引用、历史记录。加之前先问一句:这个功能的复杂度增长是不是线性的。有些功能是线性的,比如私聊,无非是多查一次表;有些功能是超线性的,比如"撤回",它要求服务端保存每条消息的归属和 ID,还要通知所有客户端更新已经渲染的内容,涉及协议改造、状态管理、客户端重绘三处联动。
我的建议是先把昵称、群聊、私聊、在线列表、系统通知这五件事做扎实。这五件事覆盖了 90% 的实际使用场景,而且它们的协议改动都是加法,不影响已有格式。在线列表用LIST|张三,李四,王五这种一次性推送就够了,别搞增量同步——人数是个位数的时候,每次变化全量推一遍的成本比维护增量状态低得多。
| 指令 | 格式 | 服务端动作 | 是否需要广播 |
|---|---|---|---|
| 设昵称 | `NICK | 名字` | 更新连接表,回一条确认 |
| 群聊 | `MSG | 内容` | 加昵称前缀后转发 |
| 私聊 | `PRIV | 对象 | 内容` |
| 在线列表 | LIST | 拼装当前昵称集合返回 | 否(只回请求者) |
| 心跳 | PING | 更新活跃时间戳 | 否 |
| 系统通知 | `SYS | 文本` | 服务端主动下发 |
这张表其实就是协议文档的全部。写代码之前先把它列出来,能省掉后面大量的临时起意和返工。
4.2 中文乱码的三种来源,别一股脑怪编码
乱码是这个项目最常见的求助点,但成因其实就那么几种,分清楚能省很多时间。
第一种是发送端和接收端编码不一致。Windows 的控制台默认代码页和 Linux 不同,如果一端用系统默认编码编码字节,另一端用 UTF-8 解码,中文必乱。解决办法是全程显式指定 UTF-8,text.encode("utf-8")和line.decode("utf-8", errors="replace")一个都不能省,绝不依赖默认值。
第二种是解码时用了严格的错误处理。默认的decode()遇到非法字节会直接抛异常,一个字节错位就能让整个接收线程崩掉。加上errors="replace"之后,非法字节变成替换字符,程序继续跑,虽然内容有损但不影响整体。这个参数在调试期尤其重要,能让你看到"哪里开始乱的",而不是"程序直接死了"。
第三种最隐蔽:Windows 终端的显示能力问题。程序内部编码解码全对,但终端字体或代码页不支持某些字符,显示成方块或者问号。这种"乱码"其实数据是对的,只是渲染不出来。判断方法很简单——把收到的内容同时写一份到文件里,用编辑器打开看,如果文件里是对的,那问题就在终端,不在你的代码。
4.3 单行长度上限:一个容易被忽略的防御点
协议里没写单行上限,就意味着理论上有人可以发一个 500MB 的单行,服务端的buf会一直涨,直到内存耗尽。这在局域网里发生的概率很低,但既然只是加一行判断的事,就没有理由不做。
我一般把上限设在 4KB,消息超过这个长度直接丢弃并回一条提示。同时缓冲区本身也要有个上限,如果buf累积超过几十 KB 还没切出一个完整的行,说明对端发的东西根本不符合协议,直接断开连接比继续等更合理。这类防御在内部工具里看起来多余,但只要项目会持续用下去,早晚会遇到某个客户端版本发错格式的情况,那时候这几行判断就是救命的。
5. 实测中那些不写在文档里的坑
代码跑通和"能给别人用"之间隔着一段距离,这段距离全是环境问题。我把这几年踩过的、以及帮别人排查过的整理出来,按出现频率排。
端口被占用。报错是OSError: [Errno 98] Address already in use或者 Windows 上的10048。原因通常是上一次运行的进程还没完全退出,或者端口被别的软件占了。排查方法:Linux 上ss -ltnp | grep 50007,Windows 上netstat -ano | findstr 50007,找到 PID 再决定是杀进程还是换端口。换端口也可以,但要记得客户端那边同步改,否则会出现"服务端起来了但没人连得上"的诡异现象。
防火墙静默拦截。这个最坑,因为没有任何报错。服务端打印"监听成功",客户端connect也不报错(局域网内某些配置下会被直接 reset 也可能超时),但消息就是不通。判断方法:先在服务端本机用127.0.0.1连一次,通了说明代码没问题,问题在网络层;不通说明是代码问题。这个"本机回环先验证"的习惯能帮你快速把问题二分。
服务端绑定了 127.0.0.1。新手常写HOST = "127.0.0.1",本机测试一切正常,换台机器就连不上。原因是这个地址只监听回环接口,外部根本不可达。要对外提供服务必须绑0.0.0.0或者具体的那张网卡的地址。这个坑和上一个经常一起出现,排查时一起看。
客户端异常退出导致连接泄漏。用户直接关掉窗口,如果没有SO_LINGER设置,系统会正常关闭连接,服务端的recv返回空,正常清理。但如果是进程被强杀、网线被拔,服务端这边可能长时间收不到任何信号,连接就挂在表里,在线列表里永远显示一个已经不存在的人。这就是为什么必须有心跳,光靠 TCP 自身的超时判断可能要几分钟甚至更久。
广播风暴式的重复转发。有一种写法是"收到消息就转发给所有人,包括自己",然后客户端收到自己的消息又原样再发一遍,形成死循环。这个问题在早期版本里出现过一次,几秒钟内把服务端打满。防御方法很简单:客户端收到消息只显示,不回发;服务端的broadcast显式排除发送者。两边各守一条规矩,就不会成环。
5.1 排查链路怎么走:从"连不上"到"消息不同步"
遇到问题不要乱试,按一条固定链路走,效率会高很多。
第一步,确认服务端在监听。ss -ltn看端口有没有在 LISTEN 状态,没有就是绑定时就失败了,往下走没意义。
第二步,本机自连。用127.0.0.1连一次,能连上说明服务端逻辑和端口都没问题,问题在跨机器的网络路径上。这一步能把排查范围砍掉一半。
第三步,换机器连。如果本机能连、别的机器不能,大概率是绑定地址或者防火墙。先在服务端机器上临时关掉防火墙测一次(测完记得开回来),确认是不是它。
第四步,telnet 或 nc 直连。nc 192.168.1.20 50007连上去手敲MSG|hello\n,看服务端有没有反应。这一步能区分"网络通了但协议不对"和"根本没通"。
第五步,看服务端日志。如果前面都通了但还是不对,问题就在业务逻辑里,日志是唯一的线索。这就是为什么前面强调每条连接的地址和昵称都要记进连接表,出问题时能一眼看出是谁发的、发到了哪。
5.2 局域网自动发现:让用户不用手输 IP
手动输 IP 这件事在演示时很尴尬——你打开电脑,发现自己也不知道自己的地址是多少。用 UDP 广播做一个发现通道就能解决。
思路是:客户端往255.255.255.255:50008发一个固定的探测字符串,服务端在一个独立的 UDP socket 上监听这个端口,收到探测后把{主机名, 服务端口}回给发送方。客户端收到应答就知道服务端在哪了,直接发起 TCP 连接。整个过程不需要用户输入任何东西。
# 服务端发现响应 import socket as sk disc = sk.socket(sk.AF_INET, sk.SOCK_DGRAM) disc.setsockopt(sk.SOL_SOCKET, sk.SO_REUSEADDR, 1) disc.bind(("0.0.0.0", 50008)) while True: data, peer = disc.recvfrom(256) if data.startswith(b"DISCOVER"): disc.sendto(f"CHAT|{PORT}".encode(), peer)需要注意两点:广播地址不一定是255.255.255.255,很多路由器会限制全局广播,用子网定向广播(比如192.168.1.255)更可靠,但这个地址要根据自己的网段算;另外广播通常在同一个二层网络内有效,跨网段会被路由器丢弃,这一点在设计时要接受,跨网段就老老实实手输。
6. 从"能聊天"到"敢让人挂着"的几项加固
前面五章做完,你手上已经有了一个能用的东西。但要让别人愿意开着它一整天,还得补三件事:心跳剔除、历史落盘、以及知道它能撑多少人。
6.1 心跳巡检线程:用时间戳做减法
心跳不用做得复杂。客户端每 20 秒发一个PING,服务端收到后把这条连接的last_seen更新成当前时间。另起一个巡检线程,每 30 秒扫一遍连接表,发现now - last_seen > 90的就调用drop_client。这三个数字(20/30/90)的选择逻辑是:发送间隔要小于超时阈值的三分之一,给网络抖动留出容错空间;巡检间隔和发送间隔不要成整数倍关系,避免长期相位对齐导致的误判。
时间源用time.monotonic()而不是time.time(),因为前者不受系统时间调整影响。如果用户改了系统时间,time.time()会突然跳变,导致所有人被误判超时踢掉,这个 bug 在笔记本上尤其容易触发。
提示:心跳包不要走广播路径,也不要计入未读消息,纯内部控制用。服务端回一个
PONG让客户端也能确认链路是活的,双向确认比单向更可靠。
6.2 消息落盘:追加写加一把锁就够
想保留历史消息,最省事的做法是每收到一条就追加写一行到日志文件。格式直接用最终的展示格式,比如2024-05-11 20:13:07 张三: 你好,前面加时间戳,后面接原文。这样用任何文本编辑器都能看,不需要额外的解析工具,也不需要数据库。
并发写的时候会出问题——多个线程同时open(..., "a")并写入,虽然单次写入一般是原子的,但行与行之间的顺序可能错乱,严重时会出现两行内容交叉。简单的处理是给写文件配一把全局锁,把"格式化好的整行 + 换行符"一次性写进去,文件用追加模式打开。频率不高的情况下,这点锁竞争完全可以忽略。
日志文件还应该按天切分。不做切分的话,跑上一年会变成一个巨大的文件,打开都费劲。按日期命名,每次写入前检查一下当前日期有没有变,变了就换文件,逻辑只有几行,但省心很多。
6.3 简单压测:怎么知道它能撑多少人
不要等到出事才知道极限在哪。写个脚本开 N 个连接,每个连接发若干条消息,观察服务端的 CPU、内存和消息延迟,就能大致摸到底。
import socket import threading import time def worker(idx): s = socket.create_connection(("127.0.0.1", 50007)) s.sendall(f"NICK|u{idx}\n".encode("utf-8")) for i in range(50): s.sendall(f"MSG|来自 u{idx} 的第 {i} 条\n".encode("utf-8")) time.sleep(0.05) s.close() threads = [threading.Thread(target=worker, args=(i,)) for i in range(50)] t0 = time.time() for t in threads: t.start() for t in threads: t.join() print(f"50 连接 x 50 消息,耗时 {time.time() - t0:.2f}s")跑下来你会看到几个典型现象:连接数在几十这个量级时,一线程一连接的模型毫无压力;到几百的时候,线程切换的开销开始显现,延迟从毫秒级爬到几十毫秒;再多就会撞上文件描述符上限或者线程数上限。有了这组数据,你就知道什么时候该考虑换成选择器模型,而不是靠感觉猜。
我个人的经验是,局域网聊天室这个场景,真实同时在线的活跃人数很少超过 30,而且大部分人挂机不说话。所以线程模型的实际上限远比理论值宽裕。真正会先出问题的往往不是并发数,而是某一条连接异常卡住导致的连锁反应——前面强调的"复制目标列表再发送"和"每条连接独立写锁",就是在防这个。
最后分享一个我在实际使用中的小习惯:服务端启动时打印一行带本机所有网卡地址的日志,把这些地址都列出来。演示的时候别人问"我连哪个 IP",你直接看日志报给他,比自己去找设置页面快得多。这个功能只需要几行socket.gethostbyname_ex(socket.gethostname()),但省掉的沟通成本相当可观。