简介:面向已具备一定Python编程基础与基本网络知识的学习者和程序员,这份PDF实验文档围绕Pycharm环境下的传输层协议实验展开,完整讲解TCP与UDP的Socket编程实现。内容从Pycharm安装与项目创建入手,逐步演示UDP套接字的数据收发、超时设置、Ping程序模拟丢包及RTT统计,以及TCP客户端与服务端的套接字创建、连接、数据编解码与关闭流程,并通过对比帮助读者理解TCP与UDP的可靠性与适用场景。文档包含实验目的、环境准备、步骤说明与代码示例,并给出了Ping服务器完整代码及客户端编写要求,适合课堂实验、自学实践或考前复习使用。全部资源仅含1个PDF文件,压缩包大小为735KB,无需额外配套代码即可随文档逐步操作练习。目前已有170人学习/浏览,对入门网络Socket编程具有较高的参考价值。
1. TCP与UDP的Socket编程,Python为什么是上手最快的落地方式
计算机网络中TCP与UDP Socket编程的Python实现,核心要解决的是进程之间跨机器通信的问题:你既要写服务端监听端口,又要写客户端发起连接,还要在“可靠有序”和“低延迟低开销”之间做选择。TCP三次握手的可靠性和UDP协议栈的轻量特性,让很多新人在选型时直接卡住。我见过不少项目:内部数据采集用TCP,结果被粘包折腾一周;帧同步用UDP,上线后发现丢包率远超预期。Python的socket模块把底层API包得足够薄,既能看清协议细节,又能快速落到生产脚本,是同时验证这两种通信思路最顺手的工具。这篇笔记适合刚进网络编程、以及准备把通信模块写进自动化脚本的从业者。
2. 先选型再动手:TCP与UDP的差异、socket API与Python骨架
2.1 三次握手与四次挥手:TCP的可靠性到底来自哪里
TCP是面向连接的协议,通信前先通过三次握手确认双方收发能力:客户端发SYN,服务端回SYN+ACK,客户端再回ACK,此后进入数据收发阶段。通信结束时走四次挥手,确保双方都没有数据要发了,才真正断开。这个机制带来的直接结果是数据有序、不丢失、不重复,代价是每次建连和断连都有额外开销,而且一旦网络抖动,重传机制会让延迟明显放大。
UDP则完全不同,它不握手、不保序、不重传,应用层把数据交给sendto就直接丢进网络。首部只有8字节,TCP首部是20字节起,数据量小、频率高、拓扑是广播或组播的场景,UDP优势非常明显;但“发出去不保证到达”这一条,必须写进你的架构决策里。
选型时我一般这样判断:接收方必须拿到完整有序的数据,比如文件传输、命令下发、数据库同步,用TCP;数据本身有时效性,丢一拍可以接受,比如实时位置上报、游戏状态同步、音视频流,用UDP。这个选择没有绝对的对错,但一定要想清楚“如果丢了一个包,后果是什么”再决定。TCP/IP协议族里的这个分工延续了几十年,你没有必要在应用层逆着它的设计去硬写。
2.2 Python socket API:服务端与客户端的最小骨架
Python的socket模块底层调用的就是操作系统提供的socket接口,所以你在Linux写的代码,拿回Windows改改地址类型就能跑。创建socket时要指定地址族和套接字类型:AF_INET代表IPv4,SOCK_STREAM代表TCP的流式套接字,SOCK_DGRAM代表UDP的数据报套接字。代码里最常见的错误是把类型和协议搞混:TCP必须配SOCK_STREAM,UDP必须配SOCK_DGRAM,反过来你会在bind或connect时收到意想不到的异常。
| API | 作用 | 关键参数 |
|---|---|---|
| socket.socket(family, type) | 创建套接字 | AF_INET, SOCK_STREAM / SOCK_DGRAM |
| bind(address) | 绑定地址和端口 | (ip, port) 元组 |
| listen(backlog) | 进入监听模式,仅TCP | 等待队列长度 |
| accept() | 接受一个客户端连接,仅TCP | 返回(conn, addr) |
| connect(address) | 主动连接服务端 | 目标(ip, port) |
| send(data) / recv(bufsize) | TCP收发字节串 | bufsize是本次最多读多少字节 |
| sendto(data, addr) / recvfrom(bufsize) | UDP收发数据报 | addr是目标地址元组 |
这里要特别强调send和recv操作的是字节串,不是字符串。Python 3里直接send("hello")会报TypeError,必须先编码成utf-8或gbk再发。另外recv(1024)里的1024是“一次最多读多少字节”,不是“必须读满1024才返回”,这两个概念的差别是后面理解粘包问题的基础。
2.3 用一段TCP回显程序跑通端口绑定、监听与accept循环
直接写一个最小可跑的TCP回显服务端和客户端,这是所有TCP业务的地基。服务端监听本机9000端口,把客户端发来的内容原样回回去。
import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 方便重启,避免端口被TIME_WAIT占住 server.bind(("0.0.0.0", 9000)) # 0.0.0.0 表示监听所有网卡 server.listen(5) # backlog=5,最多排队5个连接 print("server listening on :9000") while True: conn, addr = server.accept() # 阻塞等待新客户端 print("connected from", addr) while True: data = conn.recv(1024) # 一次最多收1024字节 if not data: break # 收到空串说明客户端关闭 conn.sendall(data) # sendall比send更稳妥,能保证尽量发完 conn.close()import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect(("127.0.0.1", 9000)) # 连本地回环地址 client.sendall(b"hello tcp") # b开头的是字节串 resp = client.recv(1024) print("received:", resp.decode("utf-8")) client.close()服务端里我做了三件事:先bind到9000端口,bind的ip用"0.0.0.0"而不是"127.0.0.1",因为前者允许局域网其他机器连进来,后者只允许本地访问;listen的backlog参数控制的是内核维护的等待队列长度,并发量不大时5够用;内层while循环持续读取直到收到空数据,recv返回b""表示对端调用了close,这时候必须退出循环否则会死循环。客户端的sendall和send的区别在于:send可能只发出部分字节并返回本次发出去的长度,sendall则负责把数据全部写入,应用层写代码优先用sendall。每次接受一个连接就串行处理,这个服务端只适合验证流程,真正多客户端并发在第5章展开。
3. UDP的Socket编程:从最小可跑代码到可靠性补偿
3.1 UDP服务端与客户端:没有listen,也没有accept
UDP服务端的写法比TCP还简单,因为无连接的特性,不需要listen和accept,只要bind住端口就可以用recvfrom接收任意来源的数据报。对应地,客户端也不需要connect,直接用sendto把数据报丢出去。这里没有“连接建立失败”的概念,你往一个没人监听的端口发数据,UDP协议栈本身不会告诉你。
import socket udp_server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_server.bind(("0.0.0.0", 9001)) # 绑定UDP端口9001 print("udp server listening on :9001") while True: data, addr = udp_server.recvfrom(2048) # 返回数据和来源地址 print(f"packet from {addr}: {data.decode('utf-8')}") udp_server.sendto(data, addr) # 原样回给同一个地址import socket udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_client.sendto(b"hello udp", ("127.0.0.1", 9001)) resp, server_addr = udp_client.recvfrom(2048) print("received:", resp.decode("utf-8"))这段代码里有三个容易被忽略的点。第一,recvfrom的返回值是两个值,数据本身和来源地址元组,你回包时必须要用这个addr,否则发错地方对方收不到。第二,UDP的bind端口和TCP端口是独立的,9000跑TCP、9001跑UDP完全没有冲突,反过来同一个端口两种协议同时存在也合法。第三,UDP客户端不connect也能收发,但如果你调用connect之后再send,内核会把对端地址固定下来,后续只能用send发送,系统也会在你connect时做一次路由检查,提前发现“目标不可达”这类错误;这种用法在UDP端口测试时经常遇到,能帮你快速筛掉一部分网络配置问题。
3.2 为什么UDP会丢包:缓冲区、MTU与端口探测
UDP丢包不是玄学,原因通常能在三层里找到。第一层是内核缓冲区满:收包速度超过应用层recvfrom消费速度,内核接收队列溢出,新到的包直接丢弃,这种情况在服务器上最常见,UDP洪水一到应用层CPU没来得及处理,丢包率就会飙升。第二层是链路MTU:以太网默认MTU是1500字节,IP首部20字节加UDP首部8字节后,UDP payload超过1472字节就可能触发分片,分片包在传输中丢失任何一个,整个数据报都废了。第三层是目标端口没人监听,包到了但没人收,这不算网络层的丢包,但应用层表现同样是数据消失了。
想做UDP端口测试,Linux下最简单的方式是nc -u,Windows可以用Python写一个三行的探测脚本:创建UDP socket、settimeout(2秒)、sendto一个探测包,然后尝试recvfrom,收到说明服务在线,超时说明端口无响应或防火墙拦截。实践中我建议把每个UDP包控制在1400字节以内,既规避了MTU分片,也降低了单包被路由器丢弃的概率。要真正确认丢包率,拿数据说话,后面5.3节会给iperf3打流的具体做法。
3.3 给UDP补可靠性的常用做法:序列号与超时重传
很多自研协议基于UDP实现,因为它不想背TCP的队头阻塞和建连开销,但可靠性还得自己补。最朴素的方案是给每个数据报加一个递增的序列号,接收方检查序列号是否连续,发现断了就发一个NACK通知重传;发送方给每个包启动一个超时定时器,超过阈值没收到确认就重发。这套机制做出来就是精简版的TCP,浅尝辄止可以,想做到生产级需要处理乱序、重复包、拥塞控制,工程量并不小。
import socket, time udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_client.settimeout(0.5) # 500ms未收到ACK就重传 seq = 0 while seq < 3: msg = f"seq={seq}, payload=hello".encode("utf-8") for _ in range(3): # 最多重传3次 udp_client.sendto(msg, ("127.0.0.1", 9002)) try: ack, _ = udp_client.recvfrom(256) print("ack:", ack.decode("utf-8")) break except socket.timeout: print("timeout, retransmit seq", seq) continue seq += 1这段代码演示的是发送端视角的“超时重传”:同一个序列号最多发3次,收到ACK才跳下一个。真实项目里接收端只认新序列号,重复收到的包直接丢弃,ACK里要带上收到的序列号让发送端精确定位。你的业务如果只是局域网内偶尔丢一两个包,这种补偿足够了;但如果要跨公网、跨运营商,先评估一下你的团队有没有能力处理乱序和拥塞,如果没有,直接用TCP是更务实的选择。别为“快”去造协议,协议栈不是你的核心竞争力的地方就交给TCP。
4. 避坑:TCP与UDP Socket编程里最常见的4个翻车现场
4.1 Windows下报“每个套接字地址只允许使用一次”的10048错误
现象:服务端程序重启后偶尔能启动,但频繁重启没多久就抛OSError,Windows系统下常见的是错误码10048。原因:主动关闭方会进入TIME_WAIT状态,默认等待约240秒才释放端口,你立刻重启服务端去bind同一个端口,内核就把你拒了,Linux下表现则是不设置SO_REUSEADDR时bind失败。解决:创建socket后立即调用setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1),这行代码的含义是允许端口在TIME_WAIT阶段被重新绑定,调试期加这一行能少踩不少坑。生产环境里如果你用systemd托管服务,还可以通过systemd的ReusePort=True配合SO_REUSEPORT做多进程监听,但那是另一个话题了。
4.2 TCP recv永远收不全一条消息:粘包与半包
现象:客户端一次send发送了一整条业务数据,服务端recv回来发现数据和其他包粘在一起,或者一条消息被截成两半,按JSON解析怎么都报错。原因:TCP是字节流协议,不保留应用层消息边界,发送方多次写、接收方多次读,数据在缓冲区里重新组合后你根本分不清“一条”的界限在哪里。解决:自定消息格式,最通用的是“长度前缀+内容”的做法。前面4字节用struct模块打包成无符号整数表示正文长度,接收方先读满4字节拿到长度,再按这个长度循环读取正文,直到收齐为止。这不是抢占式处理,也不是什么黑匣子,它就是TCP应用层必须自己维护的协议边界。
import struct def recv_exact(sock, n): chunks = [] remains = n while remains > 0: chunk = sock.recv(remains) if not chunk: raise ConnectionError("connection closed") chunks.append(chunk) remains -= len(chunk) return b"".join(chunks) def recv_msg(sock): header = recv_exact(sock, 4) length = struct.unpack("!I", header)[0] return recv_exact(sock, length)这段代码把recv封装成recv_exact,核心思想是“要多少读多少”,一次recv没读够就继续读,直到凑满所需的字节数。recv_exact里每次recv(remains)的下限是剩余字节数,是为了防止一次性多读后面消息的数据。至于粘包分界符的做法,比如用\n或空行切分,只适合业务字段里不可能出现该字符的场景,通用性远不如长度前缀。
4.3 UDP客户端在没人回复时卡住不动
现象:用UDP发了一个请求,服务端没起来或防火墙把端口拦了,客户端recvfrom一直阻塞,程序像死掉了一样。原因:UDP socket默认是阻塞模式,没有任何机制能通知你“对端不存在”,recvfrom会一直挂在那里等。解决:给socket加超时,udp_client.settimeout(3)之后,recvfrom在3秒内收不到数据会抛socket.timeout,你在异常分支里做重发或降级处理。最稳妥的做法是直接在代码里创建socket后先调settimeout,再进收发循环;不要指望操作系统的UDP协议栈给你反馈,它在这件事上就是个哑巴。
4.4 负载一大UDP丢包率飙升,先怀疑单包太大
现象:局域网里传小文件没问题,一传大的结构化数据,UDP丢包率直接到30%以上,调试时包越小越稳。原因:你传的业务数据超过MTU后会被IP层分片,任何一个分片在路由器、交换机或接收端被丢弃,整个数据报都算丢;高负载下路由器还可能主动丢弃尾部超长包,俗称尾丢弃策略。解决:控制单包大小,UDP payload保持1400字节以下,对应MTU 1500减去IP和UDP首部的28字节;大业务数据自己拆成多个小包,接收端按序列号重组。配合4.3的超时重传,这两个技巧能解决局域网UDP链路80%的通信问题。
4.5 accept循环里做耗时处理,第二个客户端永远连不上
现象:第一个客户端连上服务端,两边数据交互正常;第二个客户端connect却一直pending,直到第一个断开才被accept。原因:单线程的accept循环里,每个连接的数据通信都是串行处理的,第一个连接占住了CPU,后续连接只能在内核的backlog队列里排队。解决:把每个连接的处理丢给线程,accept循环只负责接收新连接。这个做法在第5章会展开,但这里先记住结论:生产级TCP服务端必须并发处理连接,单线程死循环只会让你在第一个压力测试时就翻车。
5. 把Socket放到真实场景:多连接、非阻塞、粘包与性能验证
5.1 多客户端并发:用threading撑起一个能同时服务的TCP服务端
第2章的串行服务端只能做教学验证,真实项目里一台采集设备要同时上报几百个点位,必须并发处理。最直接的做法是每来一个客户端,就创建一个线程专门处理它。
import socket, threading def handle_conn(conn, addr): with conn: while True: data = conn.recv(1024) if not data: break conn.sendall(data) print("connection closed", addr) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9003)) server.listen(50) # 并发上来后,backlog要调大 while True: conn, addr = server.accept() threading.Thread(target=handle_conn, args=(conn, addr), daemon=True).start()这里把每个客户端连接的生命周期和业务处理都收进handle_conn函数,线程设为daemon是为了主进程退出时不会因为某个长连接线程阻塞在recv而挂住。listen的backlog调到50,因为线程调度需要时间,如果队列太短,很多连接会被内核直接RST拒绝。这个方案能应对数百个连接,但是每个线程的内核栈空间占用不小,高并发上到几千就需要换selector或asyncio,Python的GIL会让纯线程方案在多核机器上有些吃亏。
5.2 非阻塞与select:不想开线程时的另一种调度方式
线程不是唯一出路,你还可以把socket设为非阻塞,配合select做事件驱动。服务端把监听socket和所有客户端socket放进一个read列表,select一旦发现某个socket可读,再决定去accept还是去recv。这种方式的优势是单线程里能管理大量连接,没有线程切换开销,代码却复杂不少。
import socket, select server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setblocking(False) # 非阻塞模式是select的前提 server.bind(("0.0.0.0", 9004)) server.listen(10) clients = [] while True: readable, _, _ = select.select([server] + clients, [], [], 1.0) for sock in readable: if sock is server: conn, addr = server.accept() conn.setblocking(False) clients.append(conn) else: data = sock.recv(1024) if not data: clients.remove(sock) sock.close() else: sock.sendall(data)select的核心参数是第一个列表,里面放所有你想读的socket;返回的readable只是“可能有数据”的子集,你还是要逐个recv。之所以要setblocking(False),是因为在高并发下,某个socket可能刚被select标记为可读,下一个瞬间数据就被另一个线程抢走,非阻塞recv在没数据时直接抛BlockingIOError而不是挂住。这段代码适合连接数多但每个连接流量不大的场景,比如长时间维持的监控通道;如果你是命令交互,需要同时读写,还得分出write和except两个集合,复杂度继续上升。
5.3 iperf3 UDP打流:用数据而不是感觉判断链路质量
写好了通信程序,不能只看“偶尔能通”就上线。验证网络性能和丢包率,我用iperf3打流,这是网络从业人员都认的工具。先在一台机器上启动服务端,另一台机器上用UDP模式向它打流量。
# 服务端,监听默认端口5201 iperf3 -s # 客户端,UDP模式打流100Mbps,持续10秒,每包1400字节 iperf3 -u -c 192.168.1.100 -b 100M -t 10 -l 1400参数含义:-u启用UDP模式,-c指定服务端地址,-b设置目标带宽,-t指定打流时长,-l设置负载包大小。跑完看服务端输出里的Lost/Total Datagrams和jitter两列,前者是丢包率,后者是延时抖动。如果你看到丢包率超过1%,先检查你的包大小是不是超过MTU,再检查链路是千兆还是百兆,最后看看服务端UDP接收缓冲区是否有溢出。这套打流结果可以直接作为你UDP业务“能否上线”的判定数据,比你自己写脚本ping几百次靠谱得多。TCP同理可以用iperf3不加-u来测最大吞吐,主要用于确认协议栈和链路瓶颈。
6. 缓冲区与TCP_NODELAY:调好这两个参数,延迟和吞吐立刻不一样
到了这个阶段,连接能建、数据能跑,但你还会遇到两个看着很玄学的现象:小数据包的延迟突然飙升,或者UDP在大流量下还是偶尔丢几包。前者十有八九是Nagle算法在捣乱,后者多半是接收缓冲区太小,这两个调参就是最后的临门一脚。
Nagle算法会把多个小包攒成一个发送,减少网络上的小报文数量,这在传输大量数据时是好事;但你的业务是高频小报文交互,比如每几秒发一次坐标或状态,Nagle就会把数据扣在本地等ACK,延迟能叠加到几十毫秒。交互式场景下的标准解法是关闭这个算法,代码里一行搞定:tcp_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)。这里踩过的坑是只对服务端设置不管客户端,客户端发送小报文一样会触发Nagle,两边都要设。
UDP接收缓冲区则是另一个容易被忽视的点。Linux默认的net.core.rmem_max可能只有两百多KB,业务突发流量稍微大一点就会触发内核丢包,缓解办法是把接收缓冲调大:udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024),再配合sysctl里net.core.rmem_max和net.core.wmem_max的系统级上限一起调。之前做一个监控项目,延迟问题排查了半天,最后发现就是服务端没关Nagle,教训就是性能问题不要只盯着网络拓扑,协议栈参数也是个黑匣子。这个方向你值得花半小时在实验环境里把参数穷举一遍,记录不同配置下的延迟和吞吐,后续遇到相似业务能直接套用模板。希望帮到你。
本文还有配套的精品资源,点击获取