拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python多端口聊天室:从socket编程到多线程消息路由的实战指南

简介一套基于C实现的多端口服务器与多客户端实时聊天示例包适合正在学习网络编程、socket通信与服务端并发模型的人群。项目展示了如何通过监听多个端口接收客户端连接并借助线程或异步方式同时处理不同客户端的数据让多个客户端相互转发消息形成聊天室效果核心知识点覆盖socket创建绑定监听、数据收发、消息广播及基础错误处理。压缩包共8个文件包含4个cpp源码文件和4个对应exe可执行程序整体仅113KB便于直接阅读、编译与运行对照源码与可执行程序成对提供方便初学者边看代码边验证效果。已有385人学习浏览可作为理解C网络编程、非阻塞I/O或简单聊天室设计的入门参考。通过该项目能直观掌握多客户端连接管理、消息中转与并发处理的基本流程适合课程设计或网络编程进阶练习时拆解使用。 最近整理旧项目时翻出一个压缩包名字就叫“多端口服务器多个客户端相互聊天.zip”解压一看是前几年在内网环境里练手写的Python聊天程序。当时的需求很朴素办公区里几台电脑不在同一个交换机下拉个微信群又嫌动静太大传一句“到会议室开会”还得翻半天通讯录干脆自己写一个局域网聊天室。写完之后发现最有意思的设计点是——服务端不是只监听一个端口而是同时监听多个端口不同端口进来的客户端还能在同一个聊天室里互相喊话。这篇文章适合三类人看一是刚开始学 socket 编程、想搞懂服务器到底怎么“同时”服务一堆客户端的同学二是需要在局域网内快速搭一个内部交流工具、但又不想引入重型 IM 的运维或开发三是对 TCP 粘包、断线处理这些实操问题感兴趣的读者。我会从设计取舍、核心原理、完整代码到实测踩坑全流程拆开讲清楚。1. 需求拆解一个端口能聊天为什么偏要多端口1.1 单端口聊天室的痛大多数入门教程里的聊天室都是单端口版服务端在某个固定端口上 listen所有客户端都来连这一个端口消息到了之后由服务端统一广播。这种写法跑通容易但放到真实局域网环境里会有几个不明显却很烦的问题。如果所有人都挤在一个端口上线上排查时很难分清“谁是从哪进来的”。比如有人报障说“我连不上”你得先问IP、再查连接状态所有连接像一锅粥一样混在一起没有任何维度可以快速分流。更实际的问题是把服务做成多入口时单端口根本没有扩展空间——你想给 A 组的人走 8000 端口、B 组的人走 8001 端口单端口方案只能再起一个进程而不是在同一个进程内统一管理。1.2 多端口带来的三个实际收益把服务端改成多端口监听之后我体会到几个实打实的好处第一是入口分流。不同楼层、不同网段的人可以固定连不同端口服务端这边打印日志时直接带上端口号谁在那个区域一眼就清楚。排障时不用再听天由命直接问一句“你连的是哪个端口”就能缩小范围。第二是模拟多服务实例。很多人以为“多端口”等于“多开几个进程”实际上我是在同一个进程里开了多个监听 socket共享同一份在线用户表。这种结构很适合测试环境一台服务器、一个进程就能模拟集群入口的效果又不需要真的去部署多套服务。第三是协议升级过渡。老客户端只认旧端口新客户端走新端口两边都在同一个聊天室里互通。这就是多端口设计的杀手级场景——不需要停服新旧版本自然共存。1.3 项目最终效果整个项目跑起来之后大概是这样的画面服务端启动后同时监听 8000、8001、8002 三个端口客户端连接时自行选择端口输入昵称后进入公共聊天室。任意一个人发消息其他所有在线的人都能收到如果指定了目标昵称就能实现私聊。核心就三样东西多端口监听、连接管理、消息路由。2. 服务端设计多端口监听与连接表的组织方式2.1 为什么选多线程而不是 epoll做服务端并发摆在我面前的有三条路多线程、asyncio、selectors。我最后选了多线程理由很直接——这个项目的连接规模是百级以下多线程完全扛得住而且代码逻辑对新手最友好。每个客户端连接进来之后分配一个独立线程去处理收发听着就直观。asyncio 和 selectors 更适合几千上万个连接的场景但代价是事件驱动的写法会把一个简单的“收消息-广播”流程拆成回调或协程状态机心智负担大很多。我见过不少人一上来就上 epoll结果被 IO 模型的细节绕晕。技术选型的第一原则是匹配规模100 个连接以内用多线程代码短、好调试出问题也好定位等到连接数上来再换 selectors 也不迟我在第 5 章会讲到底什么时候该换。2.2 连接表到底存什么服务端必须知道“当前有哪些人在线每个人是谁从哪个端口进来的”所以我设计了一个全局字典作为连接表CLIENTS { conn对象: { nick: 张三, addr: (192.168.1.10, 52341), server_port: 8000 } }key 直接用 socket 连接对象省去自己生成连接 ID 的麻烦。value 里存昵称、客户端地址、入口端口。广播的时候遍历这个字典把所有连接都发一遍私聊的时候根据 nick 去查目标连接对象。字典的增删全程要加锁因为多线程同时收发时Python 的 dict 虽然单个操作是原子的但“先判断存在再修改”这种复合操作不是很容易在极端情况下出问题。2.3 每个端口一个监听线程的启动细节多端口监听的核心写法非常直白每个端口单独创建一个 socket绑定、监听、accept然后各自跑在一个死循环线程里。关键是 bind 的地址用0.0.0.0表示监听本机所有网卡这样无论客户端从哪个 IP 来都能连上同时设置SO_REUSEADDR否则程序崩溃重启时端口可能还处于 TIME_WAIT 状态bind 会直接失败。def listen_on_port(port): server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, port)) server.listen(64) ...这里还有个容易被忽略的点监听 socket 与已连接 socket 是两码事。server.accept()返回的conn是已连接的 socket真正收发数据靠它监听 socket 只负责“接客”。所以我启动 N 个监听线程每个线程 accept 到新连接后再起一个客户端处理线程这样监听线程永远不会被某个连接的收发阻塞住。3. 消息路由从一行输入到全员刷屏中间发生了什么3.1 协议消息格式聊天室里的消息如果裸发字符串解析的时候会非常痛苦。比如“大家好”和“张三 你好”格式上没法区分私聊和群聊。我采用的方案是结构化消息——每条消息都是一个 JSON 对象为了接收方方便切分JSON 序列化之后再拼接一个换行符\n。消息类型字段示例说明register{type: register, nick: 张三}客户端连接后第一条消息上报昵称chat{type: chat, target: all, content: 大家好}群聊消息target 为 allchat{type: chat, target: 李四, content: 下午开会}私聊消息target 为目标昵称system{type: system, content: xxx 已上线}服务端自动生成的通知消息3.2 群发和私聊的路由逻辑服务端收到一条 chat 消息后先看 target 字段。如果等于all走群发逻辑遍历连接表把消息发给除发送者之外的所有人。如果是一个昵称就查连接表里有没有这个昵称对应的连接有就给目标连接单独发一条同时给发送者回执“已私聊发送给 xxx”目标不在线就回复“用户 xxx 不在线”。这个路由逻辑看起来简单但有一个细节值得注意私聊是否要显示“这是私聊”标记。我在转发私聊消息时加了一个private: true字段客户端收到后打印成“ [来自 张三 的私聊] 内容”这样双方都清清楚楚知道这是私聊不会和群聊混在一起刷屏。3.3 一个私聊消息的完整生命周期假设张三连着 8000 端口李四连着 8001 端口张三给李四发私聊“下午开会”整个过程是这样的张三客户端把内容封装成{type: chat, target: 李四, content: 下午开会}序列化加换行符通过 TCP 发到服务端 8000 端口。8000 端口的客户端处理线程收到数据按行解析出 JSON识别出是聊天消息。服务端调用find_by_nick(李四)在连接表里找到李四的 conn 对象这个连接挂在 8001 端口的心跳线程上。服务端往李四的 conn 发送{type: chat, from: 张三, content: 下午开会, private: true}。李四客户端在接收循环里解析到 private 字段按照私聊格式打印出来。服务端同时给张三回执“已私聊发送给 李四”。整个链路跨了两个不同端口但连接表是全局共享的所以端口只是入口差异消息路由完全不受影响——这正是多端口设计的核心价值。4. 完整代码与启动步骤照着抄就能跑4.1 服务端代码# server.py import socket import threading import json import time CLIENTS {} LOCK threading.Lock() def send_json(conn, payload): 发送JSON消息末尾补换行符。返回是否发送成功。 try: conn.sendall((json.dumps(payload, ensure_asciiFalse) \n).encode(utf-8)) return True except Exception: return False def remove_client(conn): with LOCK: if conn in CLIENTS: CLIENTS.pop(conn, None) try: conn.close() except Exception: pass def find_by_nick(nick): with LOCK: for conn, info in CLIENTS.items(): if info[nick] nick: return conn return None def broadcast(sender_conn, payload, excludeNone): 群发消息收集发送失败的连接并清理。 with LOCK: targets list(CLIENTS.keys()) dead [] for conn in targets: if conn exclude: continue if not send_json(conn, payload): dead.append(conn) for conn in dead: remove_client(conn) def handle_client(conn, addr, server_port): # 第一步接收昵称 try: raw conn.recv(4096).decode(utf-8).strip() nick json.loads(raw).get(nick, addr[0]) except Exception: nick f游客-{addr[1]} with LOCK: CLIENTS[conn] {nick: nick, addr: addr, server_port: server_port} print(f[] {nick} 从端口{server_port} 上线地址 {addr}当前在线 {len(CLIENTS)} 人) broadcast(None, {type: system, content: f{nick} 加入了聊天室。}, excludeconn) send_json(conn, {type: system, content: f欢迎 {nick} 加入聊天室输入 昵称 内容 可私聊。}) buffer while True: try: data conn.recv(4096) if not data: break buffer data.decode(utf-8) while \n in buffer: line, buffer buffer.split(\n, 1) line line.strip() if not line: continue msg json.loads(line) if msg.get(type) chat: target msg.get(target, all) content msg.get(content, ) if target all: broadcast(conn, {type: chat, from: nick, content: content}) else: target_conn find_by_nick(target) if target_conn: send_json(target_conn, {type: chat, from: nick, content: content, private: True}) send_json(conn, {type: system, content: f已私聊发送给 {target}}) else: send_json(conn, {type: system, content: f用户 {target} 不在线}) except Exception as e: print(f[-] 处理消息异常{e}) break with LOCK: nickname CLIENTS.get(conn, {}).get(nick, 未知用户) remove_client(conn) broadcast(None, {type: system, content: f{nickname} 离开了聊天室。}) print(f[-] {nickname} 下线当前在线 {len(CLIENTS)} 人) def listen_on_port(port): server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, port)) server.listen(64) print(f[*] 端口 {port} 开始监听) while True: conn, addr server.accept() t threading.Thread(targethandle_client, args(conn, addr, port), daemonTrue) t.start() if __name__ __main__: PORTS [8000, 8001, 8002] for p in PORTS: t threading.Thread(targetlisten_on_port, args(p,), daemonTrue) t.start() print(f[*] 聊天服务已启动监听端口{PORTS}) while True: time.sleep(1)4.2 客户端代码# client.py import socket import threading import json def receive_loop(sock): buffer while True: try: data sock.recv(4096).decode(utf-8) if not data: print(\n[*] 连接已断开) break buffer data while \n in buffer: line, buffer buffer.split(\n, 1) if not line.strip(): continue msg json.loads(line) if msg.get(type) system: print(f\n[系统] {msg.get(content, )}) elif msg.get(private): print(f\n[来自 {msg.get(from, 未知)} 的私聊] {msg.get(content, )}) else: print(f\n[{msg.get(from, 未知)}] {msg.get(content, )}) except Exception: break sock.close() def main(): host input(服务器IP默认127.0.0.1).strip() or 127.0.0.1 port int(input(连接端口8000/8001/8002).strip() or 8000) nick input(你的昵称).strip() or 游客 sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.sendall((json.dumps({type: register, nick: nick}, ensure_asciiFalse) \n).encode(utf-8)) threading.Thread(targetreceive_loop, args(sock,), daemonTrue).start() print(\n输入格式) print( 群聊直接输入内容例如大家好) print( 私聊昵称 内容例如张三 下午开会) print( 退出输入 exit 或 q\n) while True: text input().strip() if not text: continue if text.lower() in (exit, q): break target all content text if text.startswith(): parts text.split( , 1) if len(parts) 2: target parts[0][1:] content parts[1] else: print([!] 私聊格式昵称 内容) continue msg {type: chat, target: target, content: content} sock.sendall((json.dumps(msg, ensure_asciiFalse) \n).encode(utf-8)) sock.close() print(已退出) if __name__ __main__: main()4.3 运行与验证服务端放在一台 Linux 服务器上跑Windows 也一样能跑执行python server.py然后开三个终端窗口分别执行python client.py第一个客户端填端口 8000 起名“张三”第二个填 8001 起名“李四”第三个填 8002 起名“王五”。在张三那边输入“大家好”能看到李四和王五的窗口同步刷出消息再用李四发一条张三 下午开会只有张三能收到私聊格式的消息王五那边毫无动静。这个验证过程同时证明了跨端口互通和私聊路由两个关键特性。5. 实测排错粘包、掉线、端口起不来5.1 粘包与半包2Byte 数据被拆成两次的怪现象第一次跑通之后我让两个客户端连发十几条消息结果服务端偶尔报 JSON 解析错误。排查后发现是经典的 TCP 粘包问题。TCP 是流式协议recv(4096)读到的数据不一定恰好是完整的一条消息可能两条消息黏在一起也可能一条消息被拆成两半。解决办法就是代码里的缓冲区按行解析服务端维护一个buffer字符串每次 recv 后先累加然后按\n切分切出来的每一行才是一条完整 JSON。客户端接收循环也用同样的逻辑。这个方案简单可靠足够应付聊天室这种小包高频场景。5.2 客户端强退后的连接泄漏一开始我以为客户端直接关窗口服务端会自动清理连接。实测发现不是那么回事——某些情况下客户端进程被杀掉服务端这边recv才会返回空数据并触发下线逻辑但如果网络异常比如客户端电脑休眠、网线拔掉服务端可能很长时间感知不到连接已经死了连接表里会积攒一堆僵尸连接广播时越拖越慢。我的处理策略是发送失败即清理send_json返回 False 时就把该连接从连接表移除并关闭。如果对实时性要求更高可以再加一个心跳机制——客户端每隔 30 秒发一个 ping 消息服务端超过 90 秒没收到某个连接的任何数据就主动踢掉。我在这个版本里没有加心跳靠发送失败检测已经能覆盖大部分场景。5.3 端口绑定失败的几种原因有次我在一台新服务器上部署8000 端口死活起不来报Address already in use。第一反应是换个端口但换到 8001 也一样。最后还是netstat -tunlp查了一下发现是内网监控程序占用了 8000-8005 这一段端口根本不是代码问题。如果SO_REUSEADDR没设置程序崩溃重启时也会经常碰到 TIME_WAIT 导致的绑定失败。还有防火墙的问题——服务端在 Linux 上监听成功了但客户端从别的机器连不上十有八九是系统防火墙没有放行对应端口。我后来写了个固定的检查顺序先本机telnet 127.0.0.1 8000验证服务端再跨机器验证防火墙两步就能把问题范围缩小一半。5.4 线程模型的扩展边界多线程方案在连接数 100 以内非常舒服但到了 500 以上线程切换开销就开始冒头再往上Python 的 GIL 会让多线程优势大打折扣。如果你预计在线人数会破千建议换成单线程 selectors 或 asyncio 的事件驱动模型连接表结构和消息协议完全不用改只把“每个连接一个线程”改成“每个连接注册事件回调”即可。我在这个聊天室里预留了这一点协议统一用 JSON 换行分隔后续换 IO 模型不会动到消息格式。另外这套多端口思路不只是聊天能用。把它改造成一个多端口监控代理、多端口日志收集器或者在同一台服务器上给不同业务模块开独立端口都是顺手的事。核心就是那三件套多端口监听、共享连接表、按消息内容路由。理解透了很多网络程序的结构就都能看懂了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门