
简介一份面向具备Python编程基础与计算机网络基本知识的学习者和程序员的PDF实验文档聚焦传输层TCP与UDP的Socket编程实践。文档以Pycharm为开发环境先讲解环境安装、项目创建与Python文件运行方法随后通过UDP Ping实验带出UDP套接字发送/接收、随机丢包模拟、客户端超时设置、报文格式设计与RTT往返时间统计等关键知识点再以TCP客户端/服务端实现为主线展示套接字创建、绑定、监听、接受连接、数据发送接收、关闭连接等完整流程并说明GBK编码处理等实操细节。全文配有步骤说明、代码示例与结果分析适合教学、自学或课内实验参考便于读者对比理解TCP的面向连接可靠传输与UDP的无连接不可靠传输特性。压缩包内仅含1个PDF文件大小约735KB目前已有168人学习可作为计算机网络课程实验或Socket编程入门的实用参考资料。1. 为什么网络实验都拿 Python 讲 Socket如果去翻《计算机网络》教材TCP 三次握手和 UDP 无连接语义能画一整页时序图但关上书很多人还是写不出一个能跑通的最小通信程序。原因在于教材讲的是协议状态机而工程需要的是端点Endpoint的创建、绑定、监听与读写。Socket 编程正是连接这两者的那一层抽象而 Python 的socket模块把这个抽象压缩到了可以直接落地的粒度这也是“计算机网络中 TCP 与 UDP Socket 编程的 Python 实现”这个标题几乎成了期末复习、面试手撕和入门课设的共同起点的原因。用 Python 做这件事有个反直觉的优势它慢但它把网络边界和系统调用暴露得足够干净。socket.socket()对应内核的socket(2)bind()、listen()、accept()与send()/recv()几乎一一映射到 Berkeley Socket API没有框架替你把复杂性藏起来。正因如此你可以用几十行代码亲手触达三次握手、连接队列、半关闭和缓冲区水位这些问题——这些问题在 Java Netty 或 Go net 包里往往已经被抹平了。下文按“公共 API → TCP 实现 → UDP 实现 → 两者的工程取舍 → 排错与验证”推进每一段都会给出可以直接抄走的代码和参数依据。学完你会得到两个结论TCP 的写法是“面向连接状态机”的UDP 的写法是“面向报文边界”的而这两者在select事件循环里最终会走向同一种结构。2. 先吃透 socket 模块的公共 API 与地址族语义2.1 Socket 是文件描述符的“网络变体”Unix哲学里有一句话一切皆文件。Socket 就是这句话在网络空间的延伸——它也是一个文件描述符所以它天然支持read()/write()的语义只是数据不再来自磁盘而是来自内核协议栈的接收队列。Python 的socket模块将这套能力封装为对象方法但你要记住创建 socket 时指定的**地址族Address Family**决定你用什么格式描述“对端是谁”。指定的**套接字类型Type**决定数据在这个描述符上的传输方式字节流还是报文。import socket # IPv4 流式套接字TCP tcp_sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) # IPv4 数据报套接字UDP udp_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM)AF_INET表示使用 IPv4 地址SOCK_STREAM表示有序、可靠、基于字节流的传输对应 TCPSOCK_DGRAM表示保留消息边界、不可靠的报文传输对应 UDP。很多人在这里会混淆“TCP/IP”与“Socket”两个概念Socket 是 APITCP/IP 是协议栈通过SOCK_STREAM这个参数你告诉内核“请为我选择 TCP 作为传输层协议”。2.2 bind 地址是一个二元组端口 0 有特殊含义在 Python 中地址统一表达为“IP 地址 端口号”的二元组。bind()的含义是“把内核分配给该 socket 的端口固定下来”这是服务端必须做的操作因为客户端需要知道一个确定的连入点客户端通常不需要显式 bind因为内核会在首次connect()或send()时自动分配一个临时端口ephemeral port。# 服务端监听本机所有网卡的 9999 端口 server_address (0.0.0.0, 9999) tcp_sock.bind(server_address)0.0.0.0与localhost即 127.0.0.1有本质区别。前者表示“任何本地 IPv4 地址”意味着局域网内的其他机器也能通过你的网卡 IP 访问后者只允许本机回环访问。线上环境如果 bind 到127.0.0.1外部流量会被内核直接丢弃。注意当端口传入0时内核会随机挑一个空闲端口供本 socket 使用之后通过getsockname()可以拿到这个实际端口。这在写测试代码和临时 RPC 服务时非常有用可以避免端口冲突。常见做法是把 bind 的端口设成 0 来启动服务然后从打印日志里读出真实端口交给客户端连接。2.3 不要把 setblocking 和超时混为一谈socket默认是阻塞模式即recv()在没有数据时会一直停在那里直到数据到达或对端关闭。Python 提供了两种控制方式setblocking(False)非阻塞模式读写时如果没有数据立刻抛出BlockingIOError。settimeout(value)阻塞模式下设置超时超时后抛出socket.timeout。这两者的错误处理路径完全不同。非阻塞模式通常配合select/poll/epoll使用在事件循环中判断“何时可读、何时可写”超时模式则适合简单请求-响应模型防止对端异常后线程永远卡死。# 用 settimeout 做客户端连接超时控制 client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(3.0) # 3 秒内没有连接成功则放弃 try: client.connect((192.168.1.10, 8080)) except socket.timeout: print(连接超时服务器可能未启动或防火墙拦截了端口)在写生产级代码时settimeout只做兜底主流程的事件驱动仍然建议放在select上原因后面讲事件循环时会展开。现在先持有这个判断blocking模式是初学最容易理解的状态但不是工程上最常用的状态。3. TCP Socket 编程三次握手在代码里的真实落点3.1 用 listen 维护半连接队列与全连接队列当 socket 从CLOSED进入LISTEN状态靠的是listen()。这个调用传入的backlog参数在很多教材里被简单解释为“最大等待连接数”但内核实际维护的是两个队列半连接队列SYN Queue和全连接队列Accept Queue。server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8080)) server.listen(128) # backlog 建议设为 128内核还受 somaxconn 上限约束 print(TCP 服务已启动监听端口 8080)backlog在不同操作系统上的解释有差异。Linux 上从 4.3 开始它表示全连接队列的最大长度如果你的服务调用accept()不够快新的连接会堆积在这个队列里超过长度后新的握手请求会被丢弃。这就是热词里出现的listen tcp 127.0.0.1:11434: bind: only one usage of each socket address之外的另一种“队列溢出”型故障但报错信息往往不是“队列满”而是客户端普遍反映连接卡顿或拒绝。提示修改内核参数net.core.somaxconn可以放宽全连接队列上限但应用层的 backlog 也需要同步调大否则内核限制不会自动抬高。3.2 accept 循环里每个连接都是一个新线程/协程的起点accept()从全连接队列中取出一个已完成握手的连接返回一个新的 socket 对象。这个新 socket 才是真正和客户端通信的通道而原来的监听 socket 依然只负责接收新连接。你需要在一个循环里不断地accept()否则全连接队列很快会被占满。import threading def handle_client(conn, addr): 处理单条 TCP 连接这里演示短连接一次请求一次响应 with conn: # 用 with 确保退出时连接被关闭 data conn.recv(1024) if not data: return print(f收到来自 {addr} 的数据{data.decode()}) conn.sendall(bHTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK) while True: conn, addr server.accept() print(f客户端已连接{addr}) t threading.Thread(targethandle_client, args(conn, addr)) t.start()recv(1024)里的 1024 表示最多读取 1024 字节但 TCP 是字节流协议收到 300 字节时它可能返回 300也可能返回 200另外 100 字节还在路上。sendall()与send()的区别就在这里send()一次可能只发出部分数据必须检查返回值并循环发送剩余部分sendall()封装了循环发送直到全部数据写入内核发送缓冲区为止。3.3 长连接、粘包与优雅关闭三次握手发生在connect()内部完成四次挥手则分布在close()和shutdown()两个操作里。教材里的四次挥手会画成“客户端 FIN → 服务端 ACK → 服务端 FIN → 客户端 ACK”但代码里如果你直接close()内核会自动把还没发送完的数据发送完然后发出 FIN。短连接指“每次请求都新建连接响应后就断开”实现简单但每次都叠加握手 RTT长连接则是建立后保持多次请求共用同一条 TCP 连接代价是你必须处理粘包——TCP 没有消息边界多次send()的数据可能合并成一次recv()返回。def recv_exact(sock, n): 读取恰好 n 字节解决 recv 一次读不满的问题 chunks [] remains n while remains 0: chunk sock.recv(remains) if not chunk: raise ConnectionError(连接被对端关闭) chunks.append(chunk) remains - len(chunk) return b.join(chunks)粘包没有完美的透明解法工程上三种常用方案固定长度消息、消息头里写负载长度、或者用特殊分隔符。recv_exact这种“读满指定字节数”的函数配合“4 字节长度头 消息体”格式是应用最广的做法。优雅关闭的细节close()会让 socket 的引用计数减一只有减到 0 时才真正关闭shutdown(SHUT_WR)表示“我不会再发送数据了但我还愿意接收数据”这对应 TCP 半关闭状态。需要告诉对端“数据已发送完”但又想继续接收对端剩余数据时应该用shutdown。4. UDP Socket 编程无连接模式下的收发与多播4.1 recvfrom 与 sendto 天然携带对端地址UDP 每次发送都是一份独立的报文内核不会拆包和合包所以recvfrom()返回的字节数就是远端发送的全部内容多出的数据会被丢弃。与 TCP 编程最大的差异在于没有 accept 和 connect 阶段客户端直接sendto()服务端直接recvfrom()。udp_server socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_server.bind((0.0.0.0, 5353)) while True: data, client_addr udp_server.recvfrom(2048) # 2048 是单报文最大接收量 print(f收到来自 {client_addr} 的报文{data.decode()}) resp f你好{client_addr[0]}:{client_addr[1]}.encode() udp_server.sendto(resp, client_addr) # 必须带上对端地址服务端才能回包需要特别强调的是recvfrom(2048)的最大接收量如果远端发来的报文超过这个值超出的部分会被内核直接丢弃而且调用方不会收到任何错误提示。在设计 UDP 应用时约定单报文大小上限比约定端口更重要。大多数局域网环境下建议把报文控制在 1472 字节以内1500 MTU 减去 20 字节 IP 头与 8 字节 UDP 头以避开 IP 分片。4.2 用 connect 把 UDP socket 固定到单一对端UDP 也支持connect()但其语义是“锁定默认对端”而非建立连接。udp_sock.connect((1.2.3.4, 8888))之后你可以直接send()和recv()省略每次的地址参数同时内核会将“来自其他地址的报文”直接过滤掉。这个技巧在“服务端需要和指定客户端通信”的场景里非常实用可以减少每次收发都传地址的开销也能提高一定的安全性。# 连接的 UDP 客户端固定对端后send/recv 与 TCP 写起来类似 udp_client socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_client.connect((127.0.0.1, 5353)) udp_client.send(bhello over connected udp) resp udp_client.recv(1024) print(resp)但请注意UDP 的connect()不会触发三次握手这个调用本身只是“地址绑定到 socket 上”因此读超时settimeout仍然需要自己设置否则recv()会一直阻塞。上述过滤特性也意味着如果你的服务端需要同时服务大量远端地址就不要在监听 socket 上使用connect()而应继续使用recvfrom/sendto的原生形式。4.3 基于 select 实现 UDP 的“伪并发”收发UDP 通常被用来做事件驱动的消息服务但这里有一个高频误区单线程 UDP 只能处理“收到请求立刻回响应”这种一问一答模式吗不是——用select.select可以把收发两条路径放在同一个事件循环里单线程也能应对多来源流量。import selectors sel selectors.DefaultSelector() def read_handler(sock, mask): data, addr sock.recvfrom(4096) print(f[接收] {addr} 发来报文{len(data)} 字节) # 这里可以决定是否回包、回给谁、如何合并 sock.sendto(back, addr) udp_sock.setblocking(False) sel.register(udp_sock, selectors.EVENT_READ, read_handler) while True: events sel.select(timeout1.0) for key, mask in events: key.data(key.fileobj, mask)这个事件循环的精髓在于DefaultSelector()在不同操作系统上会自动选用epoll、kqueue或poll这意味着你可以从细节中抽离出来集中精力设计报文状态机。用同样的方式去写 TCP 服务端时accept事件与read事件也可以注册到同一个 selector 上这就是后续章节会对比的“UNIX 网络编程统一模型”。4.4 多播与广播性能测试里绕不开的 UDP 特性热词里出现的“eventgroup udp 测试”和“iperf3使用udp打流”都指向 UDP 的多播/组播场景。多播允许将数据包发送到一组目标地址224.0.0.0/4 网段发送端只需一个sendto主机的网卡会根据 IGMP 组管理协议决定是否把数据递交给上层。若要加入多播组import struct MCAST_GRP 239.0.0.10 MCAST_PORT 5007 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) # 加入多播组表示本机对该组地址的数据感兴趣 mreq struct.pack(4sl, socket.inet_aton(MCAST_GRP), socket.INADDR_ANY) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) sock.bind((MCAST_GRP, MCAST_PORT))多播报文发送通常需要设置IP_MULTICAST_TTL它控制报文在网络中的跳数上限默认值为 1只在本网段内传播。而 iperf3 的 UDP 打流测试本质就是不停sendto填满带宽然后通过接收端统计到的“报文序号缺口”来估算丢包率——这个概念对理解 UDP 的不可靠性非常直观。5. TCP 与 UDP 的工程选择从代码差异到架构取舍5.1 关键维度对比连接性、开销、边界与背压很多人背得出“TCP 面向连接、UDP 面向报文”但放到代码层面这句话意味着具体工程行为的差异。下面的表格把两者在 Python 实现中的差异收敛到可决策的维度上。维度TCPUDPSocket 类型SOCK_STREAMSOCK_DGRAM连接建立三次握手后可用无连接sendto即可消息边界无边界应用层处理粘包每个报文天然有边界数据可靠性可靠、有序、重传尽力而为可能丢包流量控制内核滑动窗口与拥塞控制无内建机制Python 读取量recv(n)可能读不满recvfrom(n)即一个完整报文写阻塞发送缓冲区满时阻塞在发送方数据报直接丢弃发送方感知较弱真实的工程选择很少是“绝对用 TCP 或绝对用 UDP”而是按需求分层文件传输、数据库协议、HTTP 必须 TCP实时音视频、游戏位置同步、服务发现与监控探针更多落到 UDP 上因为这些场景里的数据产出速度远大于消费速度重传旧数据没有意义。5.2 从 BSD Socket 到 Python asyncio 的演进路径标题说“Python 实现”但从业者的代码形态在过去十年里发生了不少变化。早期的典型写法是threading 阻塞 socket每个连接一个线程。这种模式在连接数小于 500 时很好用再往上就会遇到 GIL 对 CPU 密集任务的影响与线程切换开销。现在的主流方案是asyncio事件循环。它并没有发明新的协议而是用协程把 socket 事件重新组织了一遍import asyncio async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): data await reader.read(1024) # 阻塞等待数据但协程让出事件循环 addr writer.get_extra_info(peername) print(f来自 {addr} 的消息{data.decode()}) writer.write(bpong) await writer.drain() writer.close() async def main(): server await asyncio.start_server(handle, 0.0.0.0, 8080) await server.serve_forever() asyncio.run(main())同一个handle协程里没有显式创建线程但可以并发服务成千上万个连接。核心机制是await reader.read让出控制权操作系统通过 epoll 通知 loop “数据已就绪”loop 再恢复对应的协程。这个模型与第 4 章里selectors的 UDP 事件循环是同一个内核抽象区别仅在于表达层。UDP 的 asyncio 封装是loop.create_datagram_endpoint回调式的事件处理。所以一个有意思的结论是把 TCP 和 UDP 都看透之后语言层面的异步化改造并不会改变协议语义它只是把“等待 I/O”这件事的代价压缩到了趋近于零。5.3 长连接心跳与断线重连的 Python 写法工程里常见的“curl: (35) tcp connection reset by peer”和“10061 目标计算机积极拒绝”本质都是连接建立阶段出了问题前者是中间网络设备发 RST后者是端口没有进程在监听。长连接建立后的保活则要更细一些TCP 的SO_KEEPALIVE默认需要 2 小时的空闲才启动探测调优参数分布在/proc/sys/net/ipv4/tcp_keepalive_time等节点属于内核级配置。应用层通常在空闲 30 秒左右发送一个业务 JSON 心跳包接收方连续 N 次未收到合法心跳就主动断开连接拉起重连逻辑。import time, socket def heartbeat_loop(sock, interval30, timeout10): sock.settimeout(timeout) while True: try: sock.send(b{type:ping}) resp sock.recv(128) if not resp: raise ConnectionError(空响应判定连接已关闭) except (socket.timeout, ConnectionError) as e: print(f心跳失败{e}准备重连) return False time.sleep(interval)没有哪种心跳间隔适合所有业务它是“服务端最大可容忍静默期”与“网络开销”之间的折中。30 秒心跳 3 次失败重连是一次比较典型的配置。值得注意的是心跳与业务数据是两条逻辑路径收到业务响应时不应额外重置心跳计时以外的状态防止误判。6. 排错脚本与验证清单用半个下午换掉一整晚抓包6.1 三板斧排查端口监听、进程状态、socket 选项运维时排错不一定需要 Wireshark。先做三层基础检查能在 10 分钟内定位过半问题。第一板用ss -tlnp确认端口处于LISTEN状态否则问题出在 bind 地址或进程没起来第二板用lsof -i :端口检查进程是否真的持有该 socket防止“进程在跑但绑定失败”第三板检查防火墙规则CentOS 里firewall-cmd --list-ports可以确认端口是否放行修改端口后既要点--reload也要确认规则是tcp还是udp分开设的。上面这些排查手段能解决热词里那两个经典报错error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address本质是端口被占用bind: only one usage of each socket address (protocol/network address/port)则是在告诉你要么换端口要么打开SO_REUSEADDR。对 TIME_WAIT 状态导致的端口占用后者往往就足够了server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 必须在 bind 之前设置否则不生效6.2 报文统计UDP 打流丢包率的简易验证公式模拟 iperf3 的 UDP 打流时验证的关键指标有两个吞吐量与丢包率。发送端为每个报文编号用一个自增整数填入报文头部的前 4 字节接收端记录当前期望序号与实际接收序号之间的差值累计差值 / 总发送数就是丢包率的近似值。注意 UDP 可能乱序到达接收端要做“滑动窗口”判断其次数据报长度不宜超过 1472 字节否则分片后一旦任一 fragment 丢失整份报文都会被丢弃。这一技巧和“TCP 一定比 UDP 高级”的直觉形成互补TCP 在链路质量差时通过重传降低有效吞吐而 UDP 在同等条件下只丢包、不降速。在你做音视频传输方案选型时用这个验证脚本实测两种协议在弱网下的曲线比较官方的粗粒度对比更有说服力。6.3 自动化验证纳管到 pytest最后的建议是把“最小可复现示例”沉淀成自动化测试否则每次换机器都要重新人工对着窗口敲命令。可以用pytest写一份通用的 echo 测试服务端启动在线程里客户端连上去发出去的字节串与收回来的一一比对对 UDP 则验证“发送后能收到正确回包”即可。测试断言落在“超时用select等待事件”上避免把 CI 的执行时间浪费在 recv 阻塞上。# test_echo.py import socket, threading, pytest def start_server(): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((127.0.0.1, 0)) port s.getsockname()[1] threading.Thread(target_run, args(s,), daemonTrue).start() return port def _run(s): conn, _ s.accept() with conn: data conn.recv(1024) conn.sendall(data) s.close() def test_tcp_echo(): port start_server() with socket.create_connection((127.0.0.1, port), timeout2) as c: c.sendall(bping) assert c.recv(1024) bping这样测试通过getaddrinfo绑定到随机端口做到零冲突并且连接、读写、关闭都覆盖到了。平时调优backlog、调整缓冲区大小、开关 Nagle 算法TCP_NODELAY后跑一遍这套测试能立刻发现问题——比对着 netstat 的数去猜测哪有洞要快得多。本文还有配套的精品资源点击获取