
简介一份基于MFC的Socket聊天程序源码包面向C网络编程学习者演示如何利用异步Socket、多线程与双端队列构建多人即时通信系统。代码采用C/S结构涵盖TCP与UDP两种传输方式并重点展示收发线程的启动与终止、共享消息队列的同步保护以及聊天界面的事件刷新适合作为课程设计或网络编程进阶的参考项目。压缩包共31个文件大小约120KB包含4个cpp源文件、6个头文件以及5个bmp资源图片另有Visual Studio工程文件与说明文档结构紧凑、可直接打开编译。目前已有229人学习下载对于想快速理解Socket异步通信和线程协作的开发者而言是一份轻量且完整的实践样例。1. 一个 Socket 多人聊天的标题藏着通信编程最常用的三件套同事让我帮他改一个一直在翻车的局域网聊天小工具消息一多界面就卡死两条消息同时进来还会串行日志里隔几分钟冒一个 socket error 10054。折腾到半夜最后把方案收敛成标题里这套组合——Socket 异步通信负责收发不互相阻塞线程把连接、读、写拆成独立的执行流双端队列在收发两个方向的线程之间做缓冲。就这三样东西十几个人同时在线也能撑得住。这个标题本质是在描述一个很典型的从业方案用 UDP 做广播发现和在线探测用 TCP 传聊天消息用线程处理并发连接再用双端队列把「收到消息」和「发出去」剥离开。这篇就沿着这个拆法把这套骨架怎么搭、参数怎么定、哪些地方会翻车一次讲清楚。适合正在写多人聊天、局域网通信或长连接服务的人新手能照着跑起来老手可以直接跳到第 4、5 章对参数和踩坑。2. 多人聊天到底该用 TCP 还是 UDP先弄清协议分工再谈线程与队列2.1 局域网聊天场景下TCP 和 UDP 各自干哪份活做聊天服务第一个绕不开的问题是协议选型。很多人一上来就在 TCP 和 UDP 之间二选一实际做多人聊天常见的做法是两个都用各管一段。TCP 负责消息正文因为聊天消息不能丢、不能乱序。你说了一句「晚上八点集合」对方收到的是「晚上八集合」或者顺序颠倒这个聊天就没法用了。TCP 的可靠传输、按序到达、丢包重传正好覆盖这个需求。代价是面向连接、握手开销大但有连接这个概念在聊天里反而是优点——服务端能感知到谁在线、谁断线了。UDP 负责广播发现和在线心跳。TCP 没有广播的概念你要主动去connect一个目标端口但你要探测的是「局域网里有哪些人在跑这个聊天工具」这是一个一对多的操作UDP 的广播地址 255.255.255.255 天然适合干这活。UDP 无连接、不保证到达正好用在「丢了也无所谓」的场景——这次探测没收到两秒后再广播一次就行。维度TCPUDP连接状态面向连接需握手无连接直接发数据报可靠性可靠丢包重传尽力而为可能丢包顺序性按序到达不保证顺序广播多播不支持支持适用场景消息正文、文件传输服务发现、心跳、语音视频这里有个很容易犯的错想用 TCP 实现「自动发现局域网用户」。TCP 必须知道目标 IP 和端口才能建立连接你不知道网段里有哪些人在线怎么连所以一定要先靠 UDP 广播把对方的 IP 和端口拿到再用 TCP 去连顺序不能反过来。2.2 异步通信的本质收消息不该打断发消息线程才是基础件聊天的交互模型天然是异步的。你在输入框里打字同时别人可能在给你刷消息这两件事不能互相等待。如果你写的是同步阻塞模型——一个 while True 里 recv 然后立刻处理、立刻回显——那么一次网络抖动或者一个慢客户端就能让你的程序在 recv 上堵住界面停住所有消息都进不来。Socket 层面做异步有两条路。一条是用socket.setblocking(False)做非阻塞配合 select 或 epoll 轮询事件到了才处理另一条就是标题里的思路——把 socket 保持阻塞模式但放到独立线程里去 recv。线程在 recv 上阻塞是没问题的因为它不再占用主流程接收线程自己睡等数据主线程该渲染渲染、该发送发送。用线程做异步通信核心是把「读 socket」和「写 socket」拆到不同线程。这个拆法看似简单但拆开之后立刻会撞上一个新问题两个线程同时访问同一个连接对象、同一个列表、同一个字典数据会乱。这就是为什么要引入线程互斥和后面要说的双端队列。进程内的共享数据不是随便访问的你得有个明确的交接区。2.3 双端队列在收发之间扮演什么角色缓冲和背压都靠它标题里的双端队列在方案里承担的是「线程间消息缓冲」这个角色。接收线程从 socket 读到的消息不直接去调发送逻辑而是先往队列右侧 append发送线程从这个队列左侧 popleft取出来再逐个 send。这就是一个典型的生产者消费者模型双端队列是中间的传送带。为什么要用双端队列而不是普通 list第一是性能。list 头部 pop(0) 的时间复杂度是 O(n)每次出队都要把后面所有元素往前挪消息一多 CPU 全耗在搬数据上deque 的 popleft 是 O(1)首尾操作都是常数时间。第二是容量可控。聊天场景下消息是高频小包生产者速度可能瞬间冲很高队列如果无限涨内存会被打爆。deque 可以设maxlen满了自动丢最旧的消息这个行为对聊天来说很合理——你宁可丢伤几秒前刷屏的消息也不能让程序内存爆炸。需要强调的是deque 本身是线程安全的这个说法是有坑的。CPython 里单个 append 或 popleft 因为 GIL 的存在不会崩但「先判断长度再操作」「一次取出所有元素」这类复合操作仍然需要你手动加锁。这块在第 5 章会专门展开。3. 用 Python 把核心骨架跑起来UDP 发现、TCP 收发、双端队列缓冲3.1 目录与文件划分这个方向最常见的实现是用 Python开发快socket 库配合线程写到一块不超过两百行。文件按职责拆成四个别把所有逻辑塞进一个文件里不然排查问题时要上下翻几百行代码。文件职责关键对象protocol.py消息帧封包与拆包pack_message / unpack_messagediscovery.pyUDP 广播发现与在线探测Discoverer 类chat_server.pyTCP 监听、连接管理、读写线程read_loop / broadcastchat_client.py发起连接、发送消息、接收展示ChatClient 类3.2 UDP 广播发现与在线探测第一段最小代码先搞定用户怎么互相找到对方。客户端启动时往 UDP 广播地址发一条 JSON里面带上自己的昵称和 TCP 监听端口同时自己也开一个 UDP socket 监听同样的端口收到别人的广播就记录对方 IP。这就是局域网多人聊天最朴素的发现机制。# discovery.py —— UDP 广播发现与在线探测 import json import socket import threading import time DISCOVERY_PORT 37020 class Discoverer: def __init__(self): self.peers {} # ip - {name: str, port: int} self._lock threading.Lock() # 保护 peers 字典 def broadcast_loop(self, name: str, tcp_port: int, interval: float 2.0): 周期性广播自己的存在interval 是广播间隔秒数 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) payload json.dumps({name: name, port: tcp_port}).encode(utf-8) while True: # 255.255.255.255 是受限广播地址同网段所有主机都会收到 sock.sendto(payload, (255.255.255.255, DISCOVERY_PORT)) time.sleep(interval) def listen_loop(self): 监听 UDP 端口收别人的广播在线状态从这里刷新 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((, DISCOVERY_PORT)) # 绑空地址表示监听所有网卡 while True: data, addr sock.recvfrom(1024) info json.loads(data.decode(utf-8)) with self._lock: # 写 peers 之前必须拿锁 self.peers[addr[0]] info两个关键的 setsockopt 缺一不可。SO_BROADCAST不设sendto 到广播地址会直接抛 PermissionErrorSO_REUSEADDR不设程序重启时 bind 会报 Address already in use逼你等系统释放端口才能重新监听。广播间隔和端口选择是这里最重要的两个参数。间隔默认 2 秒太长会导致新人进来后要等好几秒才看到别人太短会变成小型广播风暴端口选 37020 这种 1024 以上的私有端口避开系统服务和常见软件占用的端口号。同一网段里如果跑着别的 UDP 应用端口冲突是最先要排查的点。3.3 TCP 连接管理与收发线程把异步通信落到线程模型UDP 发现拿到对方 IP 和端口后客户端向那个端口发起 TCP 连接。服务端的线程模型是一个主线程 accept 新连接每个连接分配一个读线程负责 recv一个全局写线程或者直接在 broadcast 里发送负责把消息发出去。这个模型简单直接最适合聊天这种连接数量几十个以内的场景。# chat_server.py —— TCP 监听与读写线程模型 import socket import struct import json import threading from collections import deque LISTEN_IP 0.0.0.0 LISTEN_PORT 37321 clients {} # conn - nickname连接字典 send_buf deque(maxlen500) # 双端队列消息发送缓冲满了丢最旧 buf_lock threading.Lock() # 保护 send_buf 的互斥锁 def read_loop(conn: socket.socket, addr): 每连接一个读线程负责 recv、拆包、丢进双端队列 stream bytearray() try: while True: chunk conn.recv(4096) # 4096 是单次 recv 上限见 4.1 if not chunk: # 对端正常关闭返回 b break stream.extend(chunk) while True: obj, stream unpack_message(stream) if obj is None: # 半包等下一个 chunk break with buf_lock: send_buf.append((conn, obj)) # 只入队不在读线程里直接发送 finally: conn.close() clients.pop(conn, None) def broadcast_loop(): 唯一执行 send 的线程从双端队列左侧取消息并发送 while True: with buf_lock: # 一次取空队列减少锁竞争发送动作在锁外执行 batch list(send_buf) send_buf.clear() for conn_src, msg in batch: for conn_dst in list(clients.keys()): if conn_dst is conn_src: continue # 不把消息回显给发送者自己 try: conn_dst.sendall(pack_message(msg)) except OSError: pass # 对端已断开发送失败忽略读线程会处理回收这个设计里有一个值得注意的细节读线程只负责把消息 append 进双端队列真正 send 的动作由一个独立的广播循环线程执行。原因很简单——如果让读线程直接 send某个连接发送缓冲区满导致阻塞就会把这个读线程拖住后面到达的消息全部卡住。把发送集中到一个线程阻塞面被控制在发送循环内部这是异步通信里典型的「缓冲 背压」思路。clients 字典同样存在并发访问读线程在 finally 里 pop广播循环在遍历 keys。Python 里 dict 的并发读写短期不会崩但遍历中删除可能跳过元素所以生产级代码应该在 clients 上也挂一把锁这里为了可读性先省略。3.4 双端队列做消息缓冲append popleft别自己写 pop(0)上面代码里双端队列已经用起来了。这里单独把它的用法和边界讲透因为很多人是第一次注意到这个数据结构的工程价值。# protocol.py —— 消息帧编解码4字节长度头 JSON 报文 import struct import json def pack_message(obj: dict) - bytes: 统一消息帧格式前4字节是大端长度后面是 UTF-8 的 JSON payload json.dumps(obj, ensure_asciiFalse).encode(utf-8) return struct.pack(I, len(payload)) payload def unpack_message(stream: bytearray): 从字节流里拆出一帧消息半包返回 None完整包返回 (消息, 剩余字节) if len(stream) 4: return None, stream (length,) struct.unpack(I, bytes(stream[:4])) if len(stream) 4 length: return None, stream # 半包等后续数据补齐 obj json.loads(bytes(stream[4:4 length]).decode(utf-8)) del stream[:4 length] return obj, stream双端队列用在这里有明确的选择理由。你不用list(0)是因为它 O(n) 的头部删除会让消息吞吐量随队列长度线性劣化deque 首尾操作是 O(1)。maxlen500限制队列容量广播风暴时最旧的消息会被自动覆盖内存占用有上界这是直接裸写 list 做不到的。TCP 是流式协议recv 拿到的字节不能保证正好是一整条消息。这里用「4 字节长度头 JSON 报文」的帧格式解决先读 4 字节拿到 payload 长度长度不足说明是半包等下一个 chunk 拼进来再处理。这就是 TCP 粘包和拆包的标准解法比用换行符做分隔符更稳因为 JSON 解析时不会跟正文里的换行打架。3.5 客户端怎么走完整的消息链路客户端要做的三件事UDP 广播发现拿到服务器地址TCP 连上去然后启动一个读线程持续 recv主线程循环读用户输入并 send。发现和连接是前置步骤这里只看消息发送的核心逻辑。# chat_client.py —— 消息发送一个线程负责读 socket一个线程负责读 stdin import socket import threading def input_loop(sock: socket.socket, name: str): 主线程循环读键盘输入并发送 while True: text input( ).strip() if not text: continue if text /quit: sock.close() break sock.sendall(pack_message({from: name, body: text})) def recv_loop(sock: socket.socket): 读线程持续接收服务端广播来的消息并打印 stream bytearray() while True: chunk sock.recv(4096) if not chunk: break stream.extend(chunk) while True: obj, stream unpack_message(stream) if obj is None: break print(f\n[{obj[from]}] {obj[body]})两条线程各阻塞各的recv_loop 睡在 socket 上等数据input_loop 睡在 input() 上等键盘。这其实就是异步通信的运行态——两个执行流互相不等待键盘输入慢不会阻塞网络接收网络抖动不会让打字卡住。把这两条线合并进一个线程你用 input 等输入的时候消息就收不进来了这就是很多人写的聊天工具「一发消息就收不到别人消息」的病根。4. 三个必调的参数与一组边界缓冲区、心跳、线程数4.1 recv 缓冲区设多大消息吞吐量由它决定不是由 send 决定4096 这个数字在 socket 编程里出现频率极高但它不是银弹。recv 的缓冲区大小决定单次系统调用最多能取回多少字节取少了就是多几次循环取多了浪费内存。聊天消息大多是几十到几百字节4096 完全够用一次 recv 通常能拿到好几条完整消息反正拆包循环会一条条拆出来。真正需要调的是消息帧的长度上限。长度头用I是 4 字节无符号整数理论最大 4GB但实际应该约定一个业务上限比如单条消息不得超过 64KB。服务端在拆包时发现 length 超过上限直接断开连接——这是防止有人故意发一个超大的长度头把你内存吃满的防护手段。UDP 包的尺寸限制更严格广播报文设计在 512 字节以内比较安全因为 UDP 数据报超大后可能被 IP 分片分片丢失整包就废了。4.2 心跳间隔与超时阈值在线状态是探测出来的不是假设出来的聊天软件的在线状态本质是一个「最近一次收到对方消息的时间」。UDP 广播本身就可以当心跳用每两秒广播一次自己的存在收到广播就更新 peers 里对应 IP 的时间戳。参数建议值调参方向UDP 广播间隔2 秒调小则发现更快广播负担加重调大则省流量但用户出现延迟离线判定阈值5 个广播周期即 10 秒阈值太小会误杀短暂网络抖动的用户UDP 报文大小512 字节以内超过 1472 可能触发 IP 分片丢包率上升TCP recv 缓冲4096 字节单条消息远小于 4KB 时不必调大双端队列 maxlen500 条调大抗瞬时突发更好内存占用线性上升离线判定不要做得太敏感。无线网络一个丢包就可能让某次广播没到如果 3 秒没收到就判离线在线列表会一直闪跳。把阈值放到 5 个周期以上用户掉线后最长也就晚几秒显示离线对聊天场景完全可接受如果要更实时可以加 TCP 层面的心跳包来配合判定。4.3 线程数控制在多少线程池还是每连接一个线程第 3 章那种每连接一个读线程的模型线程数量是 N 4 左右N 个连接加主线程、广播线程、两个 UDP 线程。几十人的聊天室三五十个线程跑起来毫无压力但到了几百人线程的创建销毁、上下文切换就不是免费的了这时候要考虑线程池或者换成 select / epoll 事件驱动。线程池的核心价值是复用线程避免频繁的创建销毁开销。常见的做法是维护一个固定大小的读线程池连接到来时把 socket 文件描述符丢进池子里的任务队列由工作线程去 recv。这个方案的问题在于recv 是阻塞的如果池子里 N 个线程都被慢客户端阻塞住后面来的连接没有线程处理就绪事件积压消息延迟暴涨。所以线程池配阻塞 recv 是一个容易踩的坑组合。我的建议是分阶段选型连接数稳定在几十个以内直接用每连接一线程代码直观好排查规模预期上百直接上 selectors 模块用事件驱动不要再想着多线程硬扛。这个标题的场景是多人聊天默认规模就是前一种先把线程模型跑通远没到需要优化线程池的地步。5. Socket 通信与线程的五个常见坑从死锁到 100545.1 多线程同时 recv 同一个 socket数据会被零散拆走现象服务端开了两个线程都去读同一个连接结果消息 A 被拆成前后两半两个线程各收了一半后续消息全部错位客户端报 JSON 解析失败。原因socket 文件描述符在内核层面没有内置的「同一时刻只允许一个线程 recv」的保证。两个线程同时调用 recv谁先抢到系统调用谁拿数据每次 recv 拿多少完全随缘数据就被撕成碎片。这个坑隐蔽在「多个线程同时读」看起来像是能加速实际是把有序的字节流拆成了无序竞争。解决一个 socket 只允许一个读线程碰它。所有连接进来在 accept 之后立刻把连接对象绑定到唯一一个读线程上其他任何线程不得调用这个 socket 的 recv。这是硬规则没有例外。排查方法是查日志里有没有同一个连接出现两条读路径或者用代码搜索所有.recv(的调用点逐个数每个 socket 被几个线程引用。5.2 deque 双端队列并发操作丢消息GIL 保护不了你的复合操作现象服务端压测时发现多个客户端同时发消息偶尔会出现某条消息队列里根本没有而发送端确认已经 sendall 出去了。原因deque 的单个 append 是原子的但「判断队列长度是否超过 maxlen 再决定覆盖哪个端」「一次读出来再 clear」这些复合操作不是。两个线程交错执行其中一个线程的赋值可能被另一个人覆盖掉表现为丢消息。GIL 只能保证单条字节码不被打断救不了你的多行业务逻辑。解决所有对 send_buf 的访问统一加一把互斥锁锁的粒度要小拿锁改队列、放锁发消息。上面 chat_server 的代码就是按这个原则写的——with buf_lock:只包住「拷贝队列 清空」sendall 放在锁外面执行。这样既保证队列不被并发写坏又不让慢发送阻塞住其他线程的入队操作。5.3 慢客户端拖死全队队头阻塞不是 TCP 的专利现象群里 20 个人在线突然消息整体延迟变得很大CPU 占用不高但每条消息都要好几秒才能发出去。把网速最差的那台机器断开延迟立刻恢复。原因TCP 的 send 是流控的对端接收窗口满了之后 sendall 会阻塞。如果广播循环里第一个人就是个慢客户端sendall 堵在那里后面 19 个人的消息全部排队等它。这就是发送路径上的队头阻塞——不是网络丢包是应用层串行发送被拖住了。解决三个手段按成本排序。第一发送时给 socket 设置超时conn.settimeout(2)超时直接断开这个连接走异常回收不让它无限期阻塞广播循环。第二把每个连接的发送缓冲拆开每连接一个独立的小队列慢客户端只拖自己的队列。第三粗暴方案连续超时两次就把这个客户端踢掉聊天场景的在线状态靠心跳重建踢错了代价很小。5.4 Windows 下看到 10054目标计算机积极拒绝别当成程序崩溃现象客户端主动关掉聊天窗口服务端日志立刻刷出一行 socket error 10054: 远程主机强迫关闭了一个现有的连接。原因这是 Windows 上最常见的 socket 异常之一。10054 发生在对端没有走正常的 close 流程比如直接杀进程、强制断电、点了窗口关闭但没执行优雅关闭就断开了 TCP 连接。你这边下一次 recv 或 sendall 时内核发现对端没了就抛出这个错。它本质是「连接已经死亡」的通知不是你的程序本身坏了。解决在 sendall 的外层捕获 OSError判断 errno 是不是ECONNRESET对应 Windows 的 10054是的话直接走连接清理流程关闭 socket、从 clients 字典移除、释放读线程。不要在这条路径上做重试对端已经没了重试只会再报一次同样错误。socket 编程里的很多「玄学报错」其实都是没做好「连接随时可能死亡」这个前提假设。5.5 线程死锁互相等对方的锁程序就像被按了暂停键现象服务端关闭时主线程调用线程的 join 等待所有读线程结束结果程序卡死在 join 上永远退不出去按 CtrlC 都没反应只能任务管理器强杀。原因读线程可能正持着 buf_lock 在等网络数据或者广播线程正持着锁在等 sendall 完成而主线程 join 时需要等待这些线程释放锁退出可这些线程内部又在等别的资源形成了一个互相等待的环。死锁的典型场景就一句话线程 A 持锁等线程 B线程 B 持锁等线程 A。解决三个约定第一条是「锁的顺序全局一致」所有线程拿锁的顺序都是 buf_lock - clients哪段代码都不许反过来。第二条是「不要在持锁的时候做 IO」sendall、recv、connect 这些带阻塞的操作全部放到锁外面。第三条是「退出用事件标志代替直接 join」主线程先设置 threading.Event让各读线程在 recv 超时后检查到退出标志自己退出再 joinjoin 设个 timeout 兜底。我的习惯是优雅退出逻辑单独写一个函数启动时的线程结构它最清楚退出时才不会被哪把锁卡住。6. 验证与进阶一台机器上三个终端压出一个可用的聊天室写完了不等于能上线通信程序尤其需要一套可重复的验证方式。我最常用的方法是在一台机器上模拟三个角色开三个终端一个跑服务端两个跑客户端叫 Alice 和 Bob先做最基础的功能验证。# 终端 1启动服务端 python chat_server.py # 终端 2生命 Alice注册时触发 UDP 广播 python chat_client.py Alice # 终端 3生命 Bob此时应看到服务端日志输出双方已连接 python chat_client.py Bob人工敲几条消息确认收发后再上压测脚本。业务逻辑不能用 UI 去测吞吐直接写个脚本打 TCP 端口绕过界面层# bench.py —— 不发 JSON 反序列化开销直连 TCP 端口压测服务端 import socket import struct import json import time def send_text(sock: socket.socket, text: str): payload json.dumps({from: bench, body: text}).encode(utf-8) sock.sendall(struct.pack(I, len(payload)) payload) sock socket.create_connection((127.0.0.1, 37321)) start time.time() for i in range(1000): send_text(sock, fbench-{i}) if i % 100 0: time.sleep(0.2) # 留出时间看服务端有没有丢消息 print(elapsed:, round(time.time() - start, 3))另一个观察视角是抓包。UDP 发现用 Wireshark 过滤udp.port 37020能看到广播周期是否稳定、有没有丢包重传TCP 消息用tcp.port 37321过滤重点看帧之间的时间间隔如果出现某条消息延迟明显多半是广播循环被慢连接阻塞了。抓包看到的现象比日志更接近真相特别是定位 10054 这类连接重置问题。进阶可以继续做的方向有三个私聊路由在消息结构里加 target 字段广播循环按目标定向发送、历史消息窗口利用双端队列的 maxlen 特性天然保留了最近 N 条、优雅退出把退出事件、锁顺序、连接清理规范成独立模块。踩过这么多坑之后我现在的习惯是开工前先把协议格式和参数表写成一页纸收发代码照着表写顺序一乱后面就是不断重启、抓包、看日志的循环。标题里这一套 UDP 发现、TCP 收发、线程拆分、双端队列缓冲是我见过的多人聊天方案里性价比最高的一种一旦把它跑通后面再碰长连接服务心里就有底了。希望帮到你。本文还有配套的精品资源点击获取