简介:一份面向具备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表示有序、可靠、基于字节流的传输(对应 TCP),SOCK_DGRAM表示保留消息边界、不可靠的报文传输(对应 UDP)。很多人在这里会混淆“TCP/IP”与“Socket”两个概念:Socket 是 API,TCP/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(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK") while True: conn, addr = server.accept() print(f"客户端已连接:{addr}") t = threading.Thread(target=handle_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(b"hello 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(b"ack", addr) udp_sock.setblocking(False) sel.register(udp_sock, selectors.EVENT_READ, read_handler) while True: events = sel.select(timeout=1.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 实现中的差异收敛到可决策的维度上。
| 维度 | TCP | UDP |
|---|---|---|
| Socket 类型 | SOCK_STREAM | SOCK_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(b"pong") 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, interval=30, timeout=10): 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,), daemon=True).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), timeout=2) as c: c.sendall(b"ping") assert c.recv(1024) == b"ping"这样测试通过getaddrinfo绑定到随机端口,做到零冲突,并且连接、读写、关闭都覆盖到了。平时调优backlog、调整缓冲区大小、开关 Nagle 算法(TCP_NODELAY)后,跑一遍这套测试能立刻发现问题——比对着 netstat 的数去猜测哪有洞要快得多。
本文还有配套的精品资源,点击获取