简介压缩包内是基于Python网络编程实现的多人聊天室项目采用客户端-服务器架构实现多客户端唯一昵称接入、消息群发广播、服务器主线程管理与会话线程创建、界面显示及连接/断开通知等完整功能。资源包共9个文件核心为3个Python脚本聊天室服务器、客户端及初始化模块辅以项目说明Markdown文档、.idea工程配置XML/IML文件及.gitignore等压缩后仅7KB体积小巧。目前已有362人学习下载适合计算机相关专业学生用于毕业设计、课程大作业或网络编程进阶练习。源码附有超详细注释描述中记录从UI搭建到线程池优化的开发日志可帮助读者对照理解Socket通信、线程管理、wxPython界面设计等关键实现并支持在此基础上二次扩展。1. 多人聊天室的架构选型为什么是socket 线程池 wxPythonTCP是点对点的连接A客户端发给服务器的消息B客户端默认什么都看不到。多人聊天室的本质是在服务器上把这条消息复制发给聊天室里的每一个客户端。这个基于Python网络编程实现的聊天室项目用wxPython给服务器和客户端都做了图形界面服务器单线程accept加每连接一线程的经典模型并在后期把会话线程迁到了ThreadPoolExecutor线程池上。它演示的socket bind/listen/accept、recv循环、广播遍历、GUI与网络线程同步是课程设计和社招笔试里反复出现的知识点。想从会写socket回显进阶到能独立写一个可多人使用的IM服务器这个项目可以作为起点。项目以__init__.py组织成标准Python包两个核心py文件直接放在工程根目录PyCharm打开即可运行没有额外依赖注入。2. 服务器端实现accept主循环、会话线程与广播并发控制先明确服务器端的职责边界。服务器启动后只做一件事在0.0.0.0:8888上监听TCP连接。主线程进入accept()死循环每拿到一个新连接就把这个连接交给线程池去执行handle_client。主线程不参与收发数据也不直接操作全局客户端列表这样从代码逻辑上就把接入和会话两件事拆开了。2.1 服务端socket初始化与监听参数socket初始化里有两个参数值得说明。AF_INET表示使用IPv4地址族SOCK_STREAM表示面向连接的TCP流式套接字。SO_REUSEADDR的作用是让服务器重启时能立刻重新绑定端口否则刚关闭的socket会处于TIME_WAIT状态要等2MSL大约1到4分钟才能重新bind开发调试时非常难受。绑定地址用0.0.0.0而不是127.0.0.1是为了让局域网内其他机器也能访问。如果只做本机演示用127.0.0.1更安全如果要多台电脑联调就必须监听所有网卡地址。import socket import threading from concurrent.futures import ThreadPoolExecutor SERVER_HOST 0.0.0.0 SERVER_PORT 8888 # 全局客户端表{socket对象: 用户名} clients {} clients_lock threading.Lock() executor ThreadPoolExecutor(max_workers20, thread_name_prefixchat-session) def start_server(): server_sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_sock.bind((SERVER_HOST, SERVER_PORT)) server_sock.listen(5) print(f聊天室服务器已启动端口 {SERVER_PORT}) while True: client_sock, addr server_sock.accept() print(f收到新连接{addr}) executor.submit(handle_client, client_sock, addr)listen(5)里的5是TCP全连接队列长度不是最大连接数。多出来的连接会先在协议栈排队队列满后新的SYN会被丢弃。聊天室场景下5已经够用但如果预期有大量瞬时连接可以把值调大到64或128。executor.submit(handle_client, client_sock, addr)这一步把新连接插入线程池任务队列空闲线程会取出任务执行。这意味着accept()主循环不会被某个慢客户端阻塞服务器处理连接的速度只取决于线程池的吞吐能力。2.2 handle_client注册用户名并进入消息循环每个新连接要先告诉服务器自己的名字。服务端会在界面上显示新客户端上线信息同时要把上线通知广播给聊天室内所有人。项目里约定的方式是连接建立后客户端发送的第一条消息就是用户名服务器端先检查名字是否唯一再决定把它加入clients表还是拒绝连接。def handle_client(client_sock, addr): try: username client_sock.recv(1024).decode(utf-8).strip() if not username: client_sock.close() return with clients_lock: # 用户名唯一性检查必须在持锁区间内完成 if username in clients.values(): client_sock.send(用户名已被占用.encode(utf-8)) client_sock.close() return clients[client_sock] username broadcast(f{username} 加入了聊天室.encode(utf-8)) while True: data client_sock.recv(1024) if not data: break # 给每条消息拼接发送者昵称后广播 broadcast(f{username}: {data.decode(utf-8)}.encode(utf-8)) except socket.error: pass finally: remove_client(client_sock)这里有两个小细节。第一用户名唯一性检查要放在持锁区间里完成否则两个客户端同时提交同一个名字可能两次都检查通过。第二clients的键是socket对象值是用户名发送消息时遍历的是键检查重名时查的是值这个双向映射关系要理清楚。recv(1024)返回空字节串b表示对端已经正常关闭TCP FIN包到达后recv会先读完缓冲区数据再在下一次调用时返回空。这是标准语义拿到空数据后必须break否则recv在死循环里反复返回b线程空转占满CPU。2.3 广播函数持锁遍历与延迟清理broadcast是聊天室的核心路径。每一条聊天消息都要走一遍完整遍历把数据复制发送给除了发送者之外的每个客户端。多个会话线程同时广播时对clients字典的并发修改就是主要风险点。def broadcast(message, exclude_sockNone): with clients_lock: for sock in list(clients.keys()): if sock is exclude_sock: continue try: sock.send(message) except socket.error: # 发送失败说明对端已断开不发生删除操作关闭socket触发recv返回 sock.close() def remove_client(client_sock): with clients_lock: username clients.pop(client_sock, None) if username: broadcast(f{username} 离开了聊天室.encode(utf-8)) client_sock.close()list(clients.keys())这步很关键。遍历字典的同时如果另一个会话线程调用了clients.pop()会抛RuntimeError: dictionary changed size during iteration。先拍成列表快照迭代期间字典即使变了遍历的仍是旧数据这比用copy()更省内存。broadcast里异常分支只close不remove原因是remove_client内部要再拿同一把锁。如果在这里直接调用同一个线程对非重入锁二次acquire会把整个会话线程卡死。延迟到消息循环的finally块去清理既避开重复加锁又能通过关socket让对应会话线程的recv尽快退出。3. 客户端设计wxPython界面与socket收发线程的协作方式客户端的重点不是socket本身而是界面线程不能被网络请求阻塞。wxPython的主线程负责事件循环如果直接把recv()放在主线程里一条消息到达之前整个窗口都卡死反过来如果让网络线程直接改控件内容wxWidgets会在事件循环外操作控件造成随机崩溃。项目的做法是网络数据用独立线程接收收到之后通过wx.CallAfter投递回主线程更新UI。3.1 wxPython界面布局与BoxSizer分配策略界面按从上到下的顺序分三块历史消息区、输入区、发送按钮。所有控件挂在wx.Panel上用BoxSizer做垂直排列。TextCtrl的wx.TE_MULTILINE让它变成多行文本框历史区再加wx.TE_READONLY防止手滑改掉聊天记录。import wx import socket import threading class ChatClientFrame(wx.Frame): def __init__(self, host, port, username): super().__init__(None, titlef多人聊天室 - {username}, size(520, 620)) panel wx.Panel(self) sizer wx.BoxSizer(wx.VERTICAL) self.msg_area wx.TextCtrl( panel, stylewx.TE_MULTILINE | wx.TE_READONLY) self.input_area wx.TextCtrl(panel, stylewx.TE_MULTILINE) send_btn wx.Button(panel, label发送) send_btn.Bind(wx.EVT_BUTTON, self.on_send) sizer.Add(self.msg_area, proportion3, flagwx.EXPAND | wx.ALL, border5) sizer.Add(self.input_area, proportion1, flagwx.EXPAND | wx.ALL, border5) sizer.Add(send_btn, proportion0, flagwx.ALIGN_RIGHT | wx.ALL, border5) panel.SetSizer(sizer) self.Show()proportion参数控制垂直空间占比。历史消息区为3输入区为1发送按钮固定高度窗口拉大时多出来的空间主要给历史区。flag里的wx.EXPAND让控件填满分配到的格子border5是控件间5像素内边距。Windows下wxPython控件默认走系统中文字体不需要额外设置Linux桌面下如果中文显示成方块需要手动构造wx.Font并SetFont到两个文本控件上。3.2 网络接收线程与wx.CallAfter的配合连接建立后立刻发用户名随后启动一个守护线程跑recv循环。这个循环的生命周期和窗口保持一致服务器断开、网络异常、窗口关闭都会导致循环退出。界面显示用的append_message和show_offline都通过wx.CallAfter切回主线程执行。self.sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((host, port)) self.sock.send(username.encode(utf-8)) threading.Thread(targetself.receive_loop, daemonTrue).start() def receive_loop(self): while True: try: data self.sock.recv(1024) if not data: break wx.CallAfter(self.append_message, data.decode(utf-8)) except socket.error: wx.CallAfter(self.show_offline, 与服务器的连接已断开) break def append_message(self, text): self.msg_area.AppendText(text \n) def show_offline(self, text): self.msg_area.AppendText(text \n) self.input_area.Disable()wx.CallAfter的本质是把一个可调用对象投递到wx事件队列主线程空闲时执行。它不保证立即执行所以网络线程不会因为界面刷新慢而阻塞。如果不用它直接在线程里调AppendTextGIL存在时小概率能工作但只要消息一多刷新和控件销毁撞在一起就会触发wxWidgets的断言崩溃release版里还很难抓堆栈。daemonTrue也有讲究。窗口先关时wx主线程退出后进程结束守护线程会被强制终止避免出现窗口关了但进程还挂着的僵尸进程。随后在EVT_CLOSE里补一个self.sock.close()让接收线程在recv处抛错退出整个生命周期就闭合了。3.3 发送消息与Enter键的绑定方式发送按钮的回调只有两件事从输入框拿文本、通过socket发出去。发完清空输入框焦点留在输入框里方便连续输入。如果输入为空直接return不做无意义发送。def on_send(self, event): text self.input_area.GetValue().strip() if not text: return try: self.sock.send(text.encode(utf-8)) self.input_area.Clear() except socket.error as e: self.show_offline(f发送失败{e})如果发送时socket已经被对端关闭send()会抛BrokenPipeError或ConnectionResetError两者都是socket.error子类一个except socket.error统一处理即可。客户端在显示发送失败后还应把输入框禁用否则用户会以为消息发送成功继续输入。实际开发里可以给发送按钮绑定回车快捷键常见做法是在__init__里加self.Bind(wx.EVT_CHAR_HOOK, self.on_key_down)在回调里判断event.GetKeyCode() wx.WXK_RETURN并且没有按住Shift就调用on_send。Shift加回车保留换行输入能力聊天体验会接近主流IM客户端。4. 线程池优化与会话断开时的清理逻辑早期的聊天室实现大多直接threading.Thread(targethandle_client, args(...)).start()。在连接数少的演示场景里没问题但连接一多线程的创建和销毁开销占掉大量CPU时间而且每多一个线程就多一份栈空间上千连接时内存压力非常明显。项目在2023年9月引入线程池这一步很务实。4.1 ThreadPoolExecutor控制线程峰值ThreadPoolExecutor内部维护固定数量的工作线程和一个任务队列submit()只把任务放进队列由空闲工作线程消费。任务来不及处理时会在队列里等待不会像裸线程那样无限增长。thread_name_prefix参数让线程有可读的名字排查问题时能直接从chat-session_3定位到第几个会话任务。from concurrent.futures import ThreadPoolExecutor MAX_ONLINE_USERS 20 executor ThreadPoolExecutor( max_workersMAX_ONLINE_USERS, thread_name_prefixchat-session )max_workers是同时处理连接数的上限不是聊天室人数上限。第21个连接进来时任务会排在队列里等某个会话结束再被处理。课程设计级别20够用要支持更多在线用户常见做法是把max_workers提至50同时在accept()前做一次在线数预检超过阈值直接拒连并返回提示。线程池也引入了新的问题accept()无限循环里调用executor.submit()如果任务队列无界瞬时大量连接会把内存堆爆。把任务队列换成有界队列需要自定义ThreadPoolExecutor子类重写submit在队列满时返回False这已经是生产级聊天室才需要考虑的优化。4.2 断线清理顺序与锁粒度客户端异常断开时服务器端的recv()会抛异常或返回空数据。清理动作有两步把socket从clients字典移除、关闭socket文件描述符。顺序不能反否则会出现竞态窗口socket已关闭但还在字典里广播遍历到它时send()抛OSError异常没捕获整个会话线程就崩了。def remove_client(client_sock): with clients_lock: username clients.pop(client_sock, None) if username: broadcast(f{username} 离开了聊天室.encode(utf-8)) client_sock.close()注意broadcast要放在锁外面调。它内部也要拿锁如果放在with clients_lock里面会加长持锁时间。多个线程同时广播时其他所有客户端操作都会卡在锁上表现就是聊天室突然全部线程阻塞。锁的粒度最小化是并发编程的基本要求只保护共享字典的单个操作不要包住整个业务逻辑。4.3 高频异常速查表异常触发场景处理建议ConnectionResetError客户端进程崩溃或网络中断捕获后走remove_client清理BrokenPipeError对端关闭后本端继续send发送前检查连接状态捕获后清理TimeoutError设置settimeout后客户端无响应区分空闲与死连接引入心跳机制OSError: [Errno 10054]Windows下对端强制关闭连接统一捕获socket.errorRuntimeError: dictionary changed size during iteration遍历字典时另一线程删除了键遍历前用list(clients.keys())拍快照排错时最该看accept()循环和handle_client的异常出口。会话线程只捕获socket.error而不处理KeyError这类非socket异常线程池里一个工作线程就没了。所以handle_client最外层建议加一个兜底except Exception把堆栈打到日志再照常执行remove_client。服务器的wx界面还可以增加一个实时日志区把客户端上下线和异常输出都写进去调试时比看控制台直观得多。5. 消息协议与心跳检测让聊天室从演示走向可用裸字符串广播虽然能跑但有一个明显隐患服务器无法区分一条数据是聊天消息还是控制指令。想加入员列表同步、私聊、表情、文件传输时就得给消息定义一个结构化格式。这个进阶改动对理解项目的边界特别有帮助。5.1 JSON信封与消息边界把原来直接send(text.encode())的裸字符串升级成统一JSON信封接收端按字段解析业务的网络收发就解耦了。import json import time def pack_message(msg_type: str, username: str, content: str ) - bytes: payload { type: msg_type, # join / leave / message / ping / pong username: username, content: content, ts: int(time.time()) } return json.dumps(payload, ensure_asciiFalse).encode(utf-8)客户端收消息时先json.loads(data)再按type字段分发message显示在聊天区pong更新最后活跃时间leave在界面上给系统提示。ensure_asciiFalse保留中文字面量否则日志里全是一串\u转义完全不可读。recv(1024)只保证每次收不超过1024字节不保证一条完整JSON能一次收齐。消息一长可能在两次recv里各收到一半。稳妥做法是借鉴HTTP的Content-Length思路先固定读4字节小端整数表示消息体长度再按长度循环读剩余内容。聊天消息通常几百字节内不处理也能跑但要清楚这个边界在哪。5.2 心跳检测与超时剔除没有心跳的聊天室断线只能依赖TCP超时重传机制发现弱网下可能要几分钟。常见做法是服务器每30秒向客户端发ping客户端收到回pong服务器检查超过90秒没有pong的连接并强制清理。last_active字典可以在handle_client每次收到数据时更新这样聊得热闹的客户端不会被误踢只有真正不活跃或断开的连接会被清掉。def start_heartbeat_checker(): while True: time.sleep(30) now time.time() with clients_lock: stale [sock for sock, last_ping in last_active.items() if now - last_ping 90] for sock in stale: remove_client(sock) print(心跳超时强制下线)心跳线程在服务器启动后单独拉起关闭服务器时要通过threading.Event通知退出不要让检查线程在进程退出时还跑。心跳周期和超时阈值要从实际场景调30秒的周期对局域网绰绰有余公网弱网环境可以放宽到60秒和180秒避免正常用户因网络抖动被踢出。5.3 验证聊天室状态的实用命令服务器GUI里放一个在线人数标签显示len(clients)这是最直观的验证手段。命令行下用netstat -an | findstr 8888观察ESTABLISHED数量是否与客户端数一致能快速确认TCP层会话是否正常建立。想验证线程池是否生效在服务器代码里定时打印threading.active_count()如果连接数涨了但活跃线程数稳定在max_workers附近说明线程复用成功了如果active_count跟着连接数一起涨就要回头检查是不是还有地方直接new了Thread。本文还有配套的精品资源点击获取
