简介实验三套接字编程代码包是面向计算机网络课程实验的完整实例适合需要掌握TCP与UDP编程、并发响应及多人聊天场景的在校生和自学者。rar压缩包内共14个文件以6个C语言源文件、2个Python脚本及编译生成的服务器/客户端可执行程序为主整体仅34KB按任务一、任务二、任务三模块组织便于对照实验步骤逐项学习。代码覆盖TCP一对多聊天、UDP多人聊天室广播、服务端接收数据并逆序回传、多客户端并发处理和异常捕获等典型场景同一功能给出C与Python两种实现可直观对比不同语言调用套接字接口的差异。已有1204人学习浏览资源体积小但知识点集中适合用来理解面向连接与无连接通信的区别、多客户端并发处理思路以及异常处理机制也可作为实验报告或后续网络应用开发的参考。1. 实验三为什么卡在“一对多”socket 编程的多人聊天室难点不在敲代码在并发模型我第一次做这个实验单机 TCP 收发只花半小时真要在实验室把 10 台电脑拉进一个聊天室才意识到 socket 编程里“一对多”比“一对一”难的不是一个量级。TCP 的连接是一对一的要撑起多人聊天室必须自己管理一组连接UDP 虽然天然支持向多台机器发数据但又没有连接状态服务器不知道要给谁转发。实验三这种 rar 里通常装了半成品 server 和 client可真正决定你能不能过验收的是这两件事并发模型选没选对消息边界定义没定义清楚。这个实验适合所有想搞明白 TCP/UDP 差别的计算机网络课学生也适合想把课堂代码改成真实聊天室的实习小题。2. 先画聊天室协议图连接管理、消息帧和一对多广播边界2.1 TCP 还是 UDP可靠与无序的取舍一眼看穿很多同学在实验报告里把“udp和tcp协议的区别”背得滚瓜烂熟但一写代码就选错。协议选择不该按“哪个高级”来而要看你这个聊天室对消息丢失的容忍度。TCP 提供可靠字节流消息一定按序到达客户端断开时服务器能通过异常及时感知。代价是实现一对多时服务器要维护一组连接对象还得处理并发。UDP 提供不可靠数据报一次recvfrom收到一条完整消息天然能往多个地址同时发但消息可能乱序、丢失客户端退出也没有任何通知。聊天室这种场景消息丢一条都可能让整段对话失去上下文所以实验若只要求交一个版本我会优先做 TCP。但如果实验特别标了“TCP/UDP 都要实现”UDP 版也别慌。它比 TCP 还少一层连接管理真正的难点只有一个服务器要知道当前有哪些客户端地址并把这些地址存下来。这个地址表就是 UDP 版聊天室里的“连接状态”。2.2 消息帧约定一行一帧还是长度前缀一对多聊天室最容易翻车的地方不在收发而在消息边界。TCP 是字节流sendall(hello)和sendall(world)可能被接收方一次recv同时读走也可能一个消息被拆成两次recv读完。如果两个人同时说话广播会把两条消息拼成一条整个聊天室都看不懂。我第一次做实验时直接用recv(1024)收然后解码就打印结果“你好”和“大家好”经常黏成“你好大家好”。后来老老实实定了帧格式“一行一条消息以\n结尾”。客户端每次从键盘读入一行发送时在末尾补\n服务器recv后按\n做切分。这个方案实现简单适合命令行聊天室。要是以后想在这个实验基础上加文件传输或更复杂的协议就要换成四字节长度前缀。示例格式是前 4 字节存消息体长度后面是 UTF-8 编码的消息体。这种帧格式能承载二进制数据也天然解决粘包缺点是代码量会多出一截。课堂实验能做到“换行分隔”就已经比大多数版本规范了。2.3 一对多的核心模型服务器中转不搞 P2P多人聊天室有一种误解以为每个客户端之间要彼此直连。真做 P2PN 个人就要维护 N-1 条连接而且每个人的公网地址暴露出来实验环境里还经常因为局域网隔离根本连不通。计算机网络实验的标准做法是集中式服务器中转所有客户端只连服务器发言统一发给服务器服务器把消息广播给除发送方以外的所有连接。这个模型下服务器要维护一张“在线成员表”内容至少包括昵称和连接对象。TCP 版里它是一张{昵称: socket}的字典UDP 版里它是一张{对端地址: 昵称}的字典。每次广播就是遍历这张表。实现时还要考虑并发安全一个客户端加入或退出时另一个客户端可能正在广播如果直接遍历字典又同时删改Python 会直接抛RuntimeError这也是后面要加锁的原因。有了协议图和广播模型代码怎么写都不会跑偏。下面两章分别给 TCP 和 UDP 的可运行版本。3. TCP 版一对多聊天室线程、连接字典与广播函数的完整落地3.1 服务器骨架一个监听 socket 和它派生的连接线程TCP 服务器的主循环很简单accept()接新连接每接一个就开一个线程处理。这个线程负责收这个客户端的消息并把消息转给广播函数。下面的代码是完整可运行的服务器关键点都用注释标了。# tcp_chat_server.py import socket import threading class ChatRoom: def __init__(self, host0.0.0.0, port8000): self.server socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.server.bind((host, port)) self.server.listen(16) self.clients {} # 键: 昵称, 值: 连接 socket self.lock threading.Lock() # 保护 clients 字典的锁 def broadcast(self, raw: bytes, except_name: str None): with self.lock: names list(self.clients.keys()) for name in names: if name except_name: continue conn self.clients.get(name) if conn is None: continue try: conn.sendall(raw) except (ConnectionResetError, BrokenPipeError, OSError): self.remove(name, conn) def remove(self, name, conn): with self.lock: if conn is self.clients.get(name): del self.clients[name] conn.close() self.broadcast(f[系统] {name} 离开了聊天室\n.encode()) def handle(self, conn, addr): name try: name conn.recv(64).decode().strip()[:12] or fuser-{addr[1]} with self.lock: self.clients[name] conn self.broadcast(f[系统] {name} 加入了聊天室\n.encode()) while True: data conn.recv(4096) if not data: break msg f{name}: {data.decode()} self.broadcast(msg.encode(), except_namename) except (ConnectionResetError, OSError): pass finally: if name and self.clients.get(name) is conn: self.remove(name, conn) def start(self): print(f聊天室运行在 {self.server.getsockname()}) while True: conn, addr self.server.accept() threading.Thread(targetself.handle, args(conn, addr), daemonTrue).start() if __name__ __main__: ChatRoom().start()这段代码的关键决策有两个。第一clients字典同时被多个线程读写所以所有修改操作都放在with self.lock里面广播时先取一个名字快照再遍历避免修改字典导致运行时异常。第二广播对每个连接都做try/except个别连接断掉不会拖垮整个聊天室。listen(16)中的 16 是等待队列长度不是最大客户端数在线客户端数由系统资源和线程数决定。课堂上交 10 个以内的客户端完全够用。recv缓冲区设成 4096 字节命令行聊天消息很少超过这个值超过了会被拆分对方会收到两条消息所以在客户端那边最好限制单条消息长度不超过 1024。3.2 广播与客户端管理锁的粒度直接决定并发安全如果你只写单线程阻塞版 TCP 服务器会发现第二个客户端连上来后整个程序卡死因为第一个客户端的recv把主循环堵住了。解决办法就是每来一个连接开一个线程。线程一多共享的clients字典就成“雷区”。有人会想给整个广播过程加锁是不是更安全不是。广播里还包含sendall网络慢的时候sendall可能阻塞很久锁会被一个慢客户端长时间占着其他线程全排队聊天室就会出现“一个人说话其他人卡几秒”的玄学现象。正确做法是先加锁复制names list(self.clients.keys())出锁后再遍历发送。字典的增删是微秒级操作锁临界区很短并发问题立刻小很多。但这里仍有一个小窗口线程 A 拿到names快照后线程 B 把某个客户端移除了。这时self.clients.get(name)可能拿到None所以我在代码里加了if conn is None: continue。这是很多开源聊天室都容易忽略的边界条件。3.3 客户端收包线程单独跑发送主循环只负责 input服务端一跑客户端反而要小心设计。如果客户端在主线程里recv那input()永远等不到用户输入如果主线程input那消息来了也没人收。所以客户端必须分成两个线程一个阻塞在recv上收广播并打印一个在主线程里读键盘输入并发送。# tcp_chat_client.py import socket import threading import sys HOST 127.0.0.1 PORT 8000 s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((HOST, PORT)) s.sendall(input(你的昵称: ).strip()[:12].encode()) def receive(): while True: try: data s.recv(4096) except OSError: break if not data: break print(data.decode(), end) sys.stdout.flush() threading.Thread(targetreceive, daemonTrue).start() while True: text input() if text.lower() /quit: s.close() break if text.strip(): s.sendall(text.encode())接收线程是 daemon 线程主线程输入/quit退出后进程不会因为收包线程阻塞而无法结束。发送前对空消息做了过滤避免sendall发空数据。昵称取前 12 个字符是给消息格式留出显示空间也让控制台更整齐。这里有个体验细节客户端发了自己的消息后服务器广播会跳过自己所以自己屏幕上不会立刻回显刚才说的话。这是刻意的因为终端上用户自己敲过什么本来就看得到再回显一次反而拥挤。如果实验要求所有人屏幕都显示完整聊天记录只需把broadcast(msg.encode(), except_namename)改成broadcast(msg.encode())。3.4 跑通整个实验流程的最小清单把tcp_chat_server.py和tcp_chat_client.py放到同一个目录先开一个终端跑服务器再开两个终端跑客户端。服务器打印出聊天室运行在 (0.0.0.0, 8000)后两个客户端依次输入昵称互相发消息就能看到一对多聊天效果了。实验验收时除了看功能还要重点看这四件事客户端非正常退出后服务器是否崩溃、服务器关闭后客户端能否感知、多个客户端同时说话会不会丢消息、重复昵称如何处理。我给的代码里重复昵称会直接覆盖旧连接因为self.clients[name] conn的赋值操作会替换同名 socket。如果想拒绝重名要先在加锁范围内检查name in self.clients。4. UDP 版多人聊天室无连接反而要维护地址表转发才能一对多4.1 UDP 服务器recvfrom 后先登记再把数据转给其他人UDP 版的核心不是收发而是地址表。服务器只有一个 socket不需要accept也不需要线程每次recvfrom都会拿到发送方的 IP 和端口。只要把这个地址存进字典服务器就有了“在线成员”的完整画像。# udp_chat_server.py import socket HOST 0.0.0.0 PORT 8000 clients {} # 对端地址 (ip, port) - 昵称 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((HOST, PORT)) print(fUDP 聊天室运行在 {HOST}:{PORT}) while True: data, addr sock.recvfrom(65535) if addr not in clients: # 第一次发言作为昵称登记不广播给其他人 clients[addr] data.decode().strip()[:12] notice f[系统] {clients[addr]} 加入了聊天室\n.encode() for other in clients: if other ! addr: sock.sendto(notice, other) continue # 普通聊天消息转发给除发送方以外的所有地址 msg f{clients[addr]}: {data.decode()}.encode() for other_addr in list(clients): if other_addr ! addr: sock.sendto(msg, other_addr)这段代码没有线程因为 UDP 的recvfrom本身不阻塞其他客户端。谁先到就先处理谁所有客户端共享一个 socket不会像 TCP 那样需要并发。这里的“无连接”是传输层意义上的应用层仍然要人为维护地址表否则服务器根本不知道往哪些地址转发。recvfrom(65535)里的 65535 是 UDP 数据报的最大理论长度。实际上超过 MTU 的数据报可能在网络上被分片实验环境里聊天消息不会那么大可以把缓冲区调成 2048减少不必要的内存拷贝。但既然recvfrom是阻塞的调大点也不会多收数据recvfrom一次只返回一个完整数据报。4.2 UDP 客户端固定源端口才能稳定收转发消息UDP 客户端最容易踩的坑是只创建 socket然后sendto给服务器却忘了bind。这样的 socket 每次发送时系统会随机分配一个源端口服务器第一次收到后会把这个随机端口存进地址表。等服务器转发回来时如果客户端没有持续监听同一个端口消息就丢了。# udp_chat_client.py import socket import threading import sys SERVER (127.0.0.1, 8000) name input(你的昵称: ).strip()[:12] sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, 0)) # 让系统分配一个端口但客户端必须一直用这个 socket sock.settimeout(0.5) sock.sendto(name.encode(), SERVER) def receive(): while True: try: data, _ sock.recvfrom(65535) print(data.decode(), end) sys.stdout.flush() except socket.timeout: continue except OSError: break threading.Thread(targetreceive, daemonTrue).start() while True: text input() if text.lower() /quit: sock.close() break if text.strip(): sock.sendto(text.encode(), SERVER)sock.bind((0.0.0.0, 0))的意思是端口号由操作系统分配但分配后这个 socket 就固定占用该端口直到关闭。服务器收到的addr里就是这个端口后续转发才能回到同一个 socket。UDP 版还得面对“客户端掉线不可感知”的问题。TCP 关闭时双方都能收到 FINUDP 说断就断服务器地址表里会残留死地址。如果这个地址被系统分配给另一个进程新进程可能收到不属于自己的历史聊天消息。课堂实验可以接受这个缺陷但要在实验报告里主动写出来说明你理解了 UDP 的语义边界。4.3 TCP 版和 UDP 版的实现差异对照对比项TCP 版UDP 版服务器如何感知新用户accept 返回新连接首次收到该地址的数据报在线表内容昵称 - socket对端地址 - 昵称并发处理每连接一个线程单循环顺序处理消息边界字节流需自定义帧每个数据报天然一条消息掉线检测recv 返回空数据或异常无法感知地址表残留广播实现遍历 sockets 逐个 sendall遍历地址表逐个 sendto最大消息长度理论无上限受 UDP 数据报和 MTU 限制表里的关键结论是UDP 代码更短但“谁在线”这个信息不可靠TCP 代码更长但状态清晰。计算机网络实验之所以把两个版本都安排进来就是想让你亲手体会“可靠连接”和“无连接报文”在应用层设计上的差异。5. 调试与避坑手册断线残留、粘包与 UDP 广播的三个坑5.1 现象客户端被 CtrlC 后服务器开始刷 BrokenPipeError原因客户端强制关闭后服务器还在往它的 socket 写数据。TCP 有一个机制对端关闭后继续写会收到 RSTPython 这边就抛出BrokenPipeError。如果这个异常没被处理聊天室整个崩溃。解决广播函数里必须用try/except捕获连接异常并在捕获后从clients字典中移除这个 socket。我在第 3 章的代码里已经写了这个处理。常见误用是把remove放在广播锁内部调一旦remove里又调用broadcast就会形成重入锁的问题。我写的版本是先del self.clients[name]锁外再发广播就没有二次加锁风险。5.2 现象连续发两条消息接收方只看到一条拼接内容原因TCP 是字节流两条sendall的数据可能在同一 TCP 段里到达接收方一次recv全部读出没有按消息边界切开。这就是实验报告里常写的“粘包”。不过严格说这是“消息边界丢失”不是 TCP 本身粘了包。解决客户端发送时保证每条消息以\n结尾接收线程维护一个缓冲区每次recv后按\n切分。示例片段如下。buffer b while True: chunk conn.recv(4096) if not chunk: break buffer chunk lines buffer.split(b\n) buffer lines.pop() # 最后一段是不完整的半条消息留到下次拼 for line in lines: if line.strip(): print(line.decode())这个做法的核心是“粘包由接收方负责拆”而不是要求发送方每次停顿一下。很多同学试图用time.sleep(0.1)避免粘包这是治标不治本一旦网络延迟变化就会翻车。5.3 现象UDP 版在局域网里收不到其他机器的消息原因最常见的是客户端没有bind或者bind的端口被防火墙拦截。局域网里还有一类坑客户端bind到0.0.0.0服务器回包时使用的目的地址是客户端发送时源 IP而不是广播地址理论上能到。但如果服务器地址表里存的是127.0.0.1那只能用于本机测试跨机器聊天就别指望了。解决先确认客户端bind了固定端口再用netstat -anu看 UDP 监听状态最后把服务器和客户端放到同一网段。测试时不要两台机器都跑在虚拟机里因为虚拟机 NAT 网络会把 UDP 包地址转换得乱七八糟。我一般在物理机的两个终端之间先测通再换两台物理机。5.4 现象服务器退出再重启bind 报 address already in use原因TCP 连接关闭后端口会进入 TIME_WAIT 状态持续约 2 分钟。此时立刻重启服务器bind同一个地址会失败。UDP 没有连接状态通常不会这样但 Windows 上有时也会因为 socket 未完全释放而报错。解决在socket.socket创建后、bind之前加一行server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)。这个选项让端口可以被快速重用。实验报告中要写清楚SO_REUSEADDR不等同于允许两个进程同时监听同一端口它只改变了端口重用策略。6. 用 Wireshark 和心跳把实验做到能验收的水平多人聊天室功能跑通后别急着关终端。拿 Wireshark 抓一遍包能把这份实验从“我抄的”变成“我懂的”。抓包步骤很简单打开 Wireshark选回环接口Loopback: lo在过滤栏输入tcp.port 8000。然后重新启动服务器和一个客户端在 Wireshark 里就能看到三次握手的 SYN、SYN-ACK、ACK 三个包这是答辩时最有力的截图。UDP 版抓包就更有说服力了。过滤udp.port 8000你一眼能看到每个数据报的长度和源端口。用一个客户端发消息服务器转发给其他客户端Wireshark 里会出现源地址为服务器 IP、目的地址为多个客户端端口的几个连续 UDP 包这就是“一对多”最直接的证据。抓包之后我还会给 TCP 版加一个心跳线程。因为校园网环境下Wi-Fi 掉线是一种常态TCP 的recv可能长时间不返回你以为连接还活着实际已经半开。常见做法是客户端每 30 秒发送一个\n空行服务器收到后重置一个超时计时器超过 90 秒没有收到客户端的任何数据就主动断开。这个逻辑不用做得太重实验中能演示“拔掉网线后服务器最终能把该客户端踢下线”就够了。重连逻辑也值得写一下。客户端recv异常退出后不要直接弹窗报错而是尝试每隔 3 秒重连服务器import time import socket def connect_with_retry(host, port, retry5): for i in range(retry): try: s socket.create_connection((host, port), timeout5) return s except OSError: time.sleep(3) raise IOError(服务器连接失败)这个函数用到socket.create_connection它比先socket()再connect()多做了 DNS 解析和超时控制也更符合实际项目习惯。我做这个实验时最深的教训是不要一开始就写完整聊天室先跑一个只有一个客户端连接的版本抓包看通不通再扩到两个客户端看广播对不对最后压到十几个线程看会不会崩。现在让你照做一遍我相信你也会发现实验三最大的收获不是那张“已通过”的验收单而是你终于知道网络协议书上那几行概念在sendall和recvfrom之间是怎么活过来的。希望帮到你。本文还有配套的精品资源点击获取