1. 内容整体设计与思路拆解
1.1 学网络编程之前,先搞懂它在整个Python学习路径里的位置
Python教程走到第14课这个位置,前面你已经掌握了变量、数据类型、函数、模块、文件操作这些基础能力,大概率还自己写了一些小的数据处理脚本或者爬虫雏形。这时候再学网络编程,其实就是把前面的所有基础能力盘活的关键一步。
可以这么理解:之前你写Python程序,数据来源基本是本地文件、用户输入、或者自己手动构造的变量。但真实世界里的程序,数据更多是在网络之间流动的——浏览器从服务器拉页面、手机App从后端接口取JSON、爬虫去抓取别人的网站内容、聊天软件收发消息,这些全都依赖网络传输。而Python最强大的应用方向之一,就是作为粘合剂,快速开发出能和网络上的服务打交道的工具。
这个教程我安排在最核心的位置是有原因的:网络编程涉及的是跨机器、跨进程的数据交换,它天然要求你理解数据的编码解码、字节流和文本的差异、阻塞与非阻塞的调度逻辑。这些概念,比单纯写一个文件处理脚本要抽象得多,但一旦跨过去,你会发现之前学到的字符串操作、循环判断、异常处理、多线程这些知识全都派上用场了。
1.2 为什么从socket而不是从某个框架入手
很多Python初学者喜欢直接上手requests库去爬网页,或者用Flask起一个小服务,觉得那才算“能跑起来的东西”。但我的建议很直接:网络编程的起点必须是socket,绕不过去。
socket是操作系统提供的一套网络通信接口,TCP/IP协议栈在最底层干活,而socket就是供程序员操作协议栈的封装层。如果你直接学requests,你看到的是一个干净的高层封装,背后TCP三次握手、数据分包、粘包处理这些事情全被隐藏了;出了问题你根本不知道是服务器没回,还是网络层丢了包,还是你自己代码逻辑写错了。
这就有点像学开车之前,先得知道方向盘打多少、油门刹车怎么配合,而不是一上来就开自动驾驶。socket就是让你亲手体验那套“底层驾驶感”的东西。学完socket之后,你再回去看requests、Flask、aiohttp这些框架,会觉得自己站在了更高的视角,能看懂它们内部做了什么,而不是纯黑盒调用。
1.3 这套教程内容是怎么安排的
这一篇的内容,我按“从概念到实现,从简单到并发”的顺序来。首先说清楚网络编程的两个核心模型:TCP和UDP,以及它们各自适合什么场景。然后手把手写出最简单的TCP服务端和客户端,让数据能在一台电脑的进程之间跑通。接下来进入网络编程最容易踩坑的粘包问题,我会给出一套真正能用的解决思路。
熟悉基础之后,再把并发方案拉进来看:多线程、多进程、协程,各自在网络编程里扮演什么角色,什么场景选什么。最后我整理了一份排查问题的速查表,那些你在网上搜了半天也不一定能搜到靠谱答案的报错,这里直接给你结论。
这一篇的代码,你不需要在一台有公网IP的服务器上执行,本机直接跑就行。只要装好Python 3.8以上版本(3.10、3.11都行,没有特殊依赖),用系统自带的socket库就能完成所有实验。如果你还在纠结Python环境安装的问题,先把这一篇放一边,去把环境配好再回来看。
2. 核心细节解析与实操要点
2.1 TCP和UDP,选哪个不是你拍脑袋决定的
网络编程里最基础也最容易被忽略的选择,就是TCP和UDP。很多教程第一句话就丢给你“TCP是面向连接的、可靠的,UDP是无连接的、不可靠的”,然后就让你背。但实际写代码时,你需要的是判断标准。
TCP好比两个人打电话,先拨通(三次握手),接通后你说一句我回一句,没说清楚的可以重说(确认重传),说完了挂断(四次挥手)。整个过程中,网络两端的字节流是有序、无损地到达对面的。绝大多数应用都是这个模型:网页浏览、文件下载、数据库连接、消息推送。
UDP则更像寄快递不签收,你把包裹扔进快递柜,对方什么时候来取、包裹有没有中途破损,你一概不管,也不会有回执。但它有一个无法替代的优势:快,而且没有连接阻抗。网络视频通话、语音通话、游戏实时对战,这些场景对延迟极其敏感,而且一次丢包影响不大(视频花一帧、语音顿一下),重传来不及,选TCP可能画面卡成幻灯片,UDP反而是工程上的优选。
实操中的选择标准,我总结成一个问题列表:
- 数据必须全部到达吗?必须无错吗?选TCP。
- 对时间敏感,允许偶尔丢一点数据吗?选UDP。
- 业务逻辑简单、单条消息短小、不需要长连接维持状态?UDP可能更适合。
- 传输的是文件、数据库指令、需要严格顺序处理的数据?TCP毫不犹豫。
你在学习阶段,绝大多数练习和面试场景都围绕TCP展开,但UDP的代码也要会写,否则遇到实时音视频或者局域网广播这类需求就抓瞎。
2.2 三次握手和四次挥手,代码层面你看到的是什么
三次握手和四次挥手,常被当成TCP面试题的必背八股。我在这里不详细背这些理论知识,而是告诉你代码运行过程中,它们对应到socket API调用上的哪些节点。
客户端调用socket.connect(target_address)时,触发的是TCP三次握手。第一个SYN包发出,服务器内核协议栈返回SYN+ACK,客户端再回一个ACK,连接就建立了。这整个过程对你的Python代码来说是被操作系统隐式处理的,你在connect成功返回的那一刹那,连接已经可用。
四次挥手则分布在主动关闭的一方调用close()或shutdown()时。主动关闭方发FIN,对方回ACK,对方再发FIN,主动方回ACK,然后连接才彻底关闭。这个过程代码里也看不到,但你会在某些时刻遇到TIME_WAIT状态占着端口,本质就是主动关闭方在等待网络上残留的包消散完,防止新连接收到旧数据。后面排查Address already in use时,这个知识就是关键线索。
熟悉这些底层细节,不是为了背面试题,而是排错时你能从现象倒推原因。比如客户端连着服务器,服务器直接关进程,此时服务器没有走正常的四次挥手,客户端那边的socket会怎样?它可能长时间感觉不到异常,直到你给它设置一个超时时间,才发现连接已经死了。这个现象和底层机制,后面排查部分会完整展开。
2.3 字节,不是字符串:收发数据前必须经历的转换
刚接触socket的人几乎都会踩同一个坑:写代码时习惯性地把要发送的内容当字符串直接传进去,比如client_socket.send("hello"),结果报错TypeError: a bytes-like object is required, not 'str'。这时候你才意识到,网络传输的不是人类可读的文本,而是字节序列。
socket API在设计上就要求传输二进制安全的数据,所以Python里收发的都是bytes类型。你要发送一个字符串,得先用encode()转成UTF-8字节;接收到的字节,要用decode()转为字符串才能正常打印和处理。
这一个转换动作,背后其实牵扯到编码一致性的问题。如果客户端用UTF-8编码,服务器用GBK解码,你得到的就是乱码。跨平台通信时,强烈建议统一使用UTF-8,这是现代互联网的默认共识。遇到老旧的第三方服务可能还要考虑ASCII或Latin-1,那就属于特定场景特殊处理了。
另外还要提醒一个隐秘的坑:字符串长度和字节长度不一定相等。中文字符UTF-8编码后占3个字节,一个包含10个汉字的字符串,在网络上传输就是30个字节。如果你按字符串长度去截取数据,必然出错。后面处理粘包问题时,永远记住按字节长度来切分,这是铁律。
2.4 阻塞IO就是最简单的模型,但别把它的缺点当优点
socket默认工作在阻塞模式。所谓阻塞,就是程序执行到某个socket操作(比如接收数据)时,如果没有数据到达,代码就停在那里等,直到有数据或超时。这是最简单、理解成本最低的模型,特别适合写教学示例和简单的客户端工具。
但阻塞模型有一个致命弱点:一个socket占一条线程,而且线程大部分时间在空等。如果你写了一个服务端要同时处理几百个连接,起几百条线程,系统资源消耗很大,而且线程切换开销不小。所以生产环境下,很少有人用纯阻塞加多线程来做高并发服务器。
明白阻塞之后,你自然会产生两个改进方向:一是让socket变成非阻塞,不等待数据,没数据就抛异常或返回空,配合事件循环来做;二是用异步IO(协程)让单线程内多个连接交错执行。这些后面的并发模块会一个一个展开。
3. 实操过程与核心环节实现
3.1 环境准备:用什么IDE和Python版本没有争议
网络编程依赖的是Python标准库,不需要pip安装任何第三方包。所以你只需要确保电脑上有一个能运行Python的环境,3.8、3.9、3.10、3.11甚至更新的版本都行。
如果你还在用vscode做开发,建议提前确认两件事:一是右下角选择的Python解释器是不是你系统里安装的那个,二是终端里运行python --version能正确输出版本号。如果你在Windows上因为多个解释器冲突导致python was not found之类的报错,去“设置—应用—高级应用设置—应用执行别名”里把“应用程序安装程序”对应的python.exe别名关掉,再重开终端试一次。这是Windows上非常常见的坑,值得单独记一下。
我这一篇的所有示例代码都保存为标准.py文件,用python命令直接运行,不用折腾jupyter notebook。因为网络编程涉及两个甚至多个进程同时运行,jupyter单个内核跑起来不方便模拟客户端和服务器双向通信。
3.2 第一个TCP程序:让客户端自己报一圈端口再关闭
先写服务端。新建文件tcp_server.py:
import socket HOST = "127.0.0.1" PORT = 8888 server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((HOST, PORT)) server_socket.listen(1) print(f"服务器启动,监听 {HOST}:{PORT}") client_socket, client_addr = server_socket.accept() print(f"收到来自 {client_addr} 的连接") data = client_socket.recv(1024) print(f"收到客户端数据: {data.decode('utf-8')}") client_socket.sendall("服务端已收到".encode("utf-8")) client_socket.close() server_socket.close()再写客户端tcp_client.py:
import socket HOST = "127.0.0.1" PORT = 8888 client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect((HOST, PORT)) client_socket.sendall("Hello TCP".encode("utf-8")) response = client_socket.recv(1024) print(f"服务端回复: {response.decode('utf-8')}") client_socket.close()运行顺序:先开一个终端跑python tcp_server.py,再开另一个终端跑python tcp_client.py。你会看到客户端打印出服务端回复的内容,服务端打印出客户端地址和数据。
这里面每个API的作用,我逐个说明:
socket.socket(AF_INET, SOCK_STREAM):AF_INET表示使用IPv4地址族,SOCK_STREAM表示使用面向连接的TCP协议。如果改成SOCK_DGRAM,就是UDP。bind((HOST, PORT)):把socket绑定到本机的一个IP和端口上。127.0.0.1是回环地址,表示只监听本机来连的请求,外部机器访问不到。如果想对外提供服务,改成0.0.0.0。listen(1):参数表示最大挂起的连接排队数。注意这个数字不是最大并发连接数,而是没被accept处理的等待队列长度。accept():阻塞等待客户端的连接请求,返回一个新的socket(专门服务于这个连接的)和客户端地址。这里要特别理解:监听socket只负责“接电话”,真正“聊天”的是accept返回的新socket。recv(1024):接收最多1024字节的数据。这是设置接收缓冲区大小,不意味着一次只能收这么点,而是最多读这么多。实际接收到的字节数可能少于1024。sendall():发送数据。它和send()的区别是,sendall会循环调用send直到所有数据发送完成,遇到网络波动中途失败则抛异常。单次发送小数据两者区别不大,但大文件传输时用sendall更稳妥。
这个示例代码刻意放得极简,连异常处理都没写,是因为第一个程序的核心目标是跑通连接流程,理解socket的生命周期。如果你把调试的耐心全放在处理异常上,反而冲淡了对主线的把握。
3.3 处理粘包问题的完整方案
运行上面的代码时,你只发了一句话,recv一次就收到了,感觉一切都很正常。但真实的网络通信不是这样的:发送端连续发送多条消息时,对端接收到的数据可能粘在一起,也可能被拆开成多块。这就是经典的“粘包/拆包”问题。
为什么TCP会有粘包?因为TCP是流式协议,它不给你分隔消息的边界。操作系统把数据按TCP段发送,接收方按自己的节奏调用recv,你发送的多个消息之间并没有业界统一的“消息分隔符”。所以接收方看到的就是一段连续的字节流,你必须自己定义规则,从里面切出每一条完整的消息。
朴素的做法是在消息后面加特殊字符,比如每条消息以\n结尾,接收方读到换行符就认为一条消息结束。这个方案对纯文本可行,但如果消息本身含有特殊字符,或者传输的是二进制数据,就会失效。
工程上更可靠的做法是“头部记录长度法”。发送时,先用4个字节的固定长度(可以用struct模块打包成网络字节序)记录消息体的字节长度,紧接着发送消息体。接收方先读4个字节,解析出消息体长度,再根据这个长度去读取完全匹配的消息体。
下面给出这个方案的发送和接收核心函数:
import struct def send_message(sock, data: bytes): # 用4字节大端序打包长度 header = struct.pack(">I", len(data)) sock.sendall(header + data) def recv_exact(sock, n: int) -> bytes: # 精准接收n个字节 chunks = [] received = 0 while received < n: chunk = sock.recv(n - received) if not chunk: raise ConnectionError("连接被关闭") chunks.append(chunk) received += len(chunk) return b"".join(chunks) def recv_message(sock) -> bytes: header = recv_exact(sock, 4) (msg_len,) = struct.unpack(">I", header) return recv_exact(sock, msg_len)这套方案有几个细节值得注意:
>符号表示大端字节序。网络传输的字节序约定为大端,也就是高位字节在前。整个互联网协议栈都遵循这个约定,你打包长度字段时也要这样做,否则跨平台通信时会解析错乱。struct.pack(">I", len(data))中的I表示无符号32位整数。这意味着单个消息不能超过4GB,实际上消息体再大也不会用一条socket消息传,大文件会分块发送。recv_exact里面用while循环保证读到指定长度才返回。网络上任何一次recv都可能只返回部分数据,这种“读满为止”的函数是必须要写的。- 如果
recv返回空字节,说明对端已经正常关闭连接。这时候继续读下去只会无限等待,所以必须抛异常让上层处理。
把这段代码插入前面那个简单的TCP示例里,你会发现粘包问题彻底消失了:服务端不管客户端一次发多少条消息,都能准确拆出每一条单独处理。
3.4 UDP版本的实操:写一个局域网内互相认识的“打招呼”工具
UDP相比TCP,代码上最大的区别是少了连接的过程。UDP服务端不需要listen和accept,UDP客户端不需要connect(当然也可以connect,但那只是一种“备忘录”,不会真的握手)。
一个简单的UDP服务端:
import socket HOST = "0.0.0.0" PORT = 9999 udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_socket.bind((HOST, PORT)) print(f"UDP服务端启动,监听 {PORT} 端口") while True: data, client_addr = udp_socket.recvfrom(2048) print(f"来自 {client_addr} 的数据: {data.decode('utf-8')}") udp_socket.sendto(f"收到你的消息".encode("utf-8"), client_addr)一个简单的UDP客户端:
import socket server_addr = ("127.0.0.1", 9999) udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_socket.sendto("你好UDP".encode("utf-8"), server_addr) response, server = udp_socket.recvfrom(2048) print(f"服务器回复: {response.decode('utf-8')}") udp_socket.close()UDP最关键的区别在于recvfrom和sendto。因为UDP是无连接的,每次接收数据时你必须从返回值里拿到对方的地址,回复时也要明确指定发给谁。服务端本身就是通过每一次sendto来记住“当前在跟谁对话”的。
另外一个无法回避的事实:UDP会丢包。你运行上面的客户端时,如果服务器没启动,客户端会卡在recvfrom上一直等不到数据。更棘手的是,UDP没有可靠重传机制,所以生产代码里通常要加上超时控制:
udp_socket.settimeout(3) try: response, server = udp_socket.recvfrom(2048) except socket.timeout: print("等待超时,可能服务器没启动或数据包丢失")这个超时设置在TCP场景下也一样有用。它给你的程序一个“止损机制”,不至于让用户在无响应时干等。
4. 并发模型:从单连接到同时服务几千个连接
4.1 多线程方案:剁碎了改一改就能用
前面TCP服务端的示例有个硬伤:一次只能处理一个客户端连接。当第一个客户端把整个流程走完、连接关闭后,程序才回到accept()继续等待下一个客户端。真实场景不可能这样做,必须支持多个客户端同时在线。
最简单的改进方案是:每来一个客户端,就创建一个新线程去处理,主线程继续循环accept。示例代码如下:
import socket import threading def handle_client(client_sock): try: while True: data = client_sock.recv(1024) if not data: break client_sock.sendall(f"已收到: {data.decode('utf-8')}".encode("utf-8")) except ConnectionError: pass finally: client_sock.close() server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8888)) server.listen(5) print("多线程TCP服务器启动") while True: client_sock, client_addr = server.accept() print(f"新连接: {client_addr}") t = threading.Thread(target=handle_client, args=(client_sock,)) t.start()注意if not data这个判断:客户端正常调用close()后,服务端再recv会收到空字节,这是TCP半关闭状态的信号,此时必须退出循环并关闭连接。很多新手在这里漏掉判断,导致线程空转,内存泄漏和连接堆积就是这么来的。
多线程方案的优点是把并发逻辑降维成一个“每连接一个线程”的模型,代码非常直观。缺点是线程有创建和切换的开销,如果同时有上万个连接在挂机,光线程数就能拖垮系统。而且Python的GIL(全局解释器锁)决定了多线程没法真并行跑CPU密集型任务,好在网络IO密集型的场景里,GIL释放的时间足够长,多线程仍然适用。
4.2 多进程方案:性能更好,但交互要绕一圈
多进程方案能绕过GIL,充分利用多核CPU。每个进程都有自己独立的Python解释器,可以同时跑在多个CPU核心上。对于网络编程,典型做法是让主进程负责accept,然后把拿到的新连接通过os.fork()(Linux)或multiprocessing模块分派给子进程去处理。
但多进程有个麻烦:进程间内存独立,你要共享数据就要用队列、管道或共享内存。用它来做网络并发,有点“杀鸡用牛刀”的感觉,因为大多数网络服务器在等IO时CPU基本空闲,多线程就够用了。多进程更适合的是“每个CPU核都要跑满计算任务,同时还要对外提供网络接口”这类场景。
实际操作中,多进程更常见的用法是配合进程池。进程池里预先创建N个进程,任务来了就分配给空闲进程,避免频繁创建销毁进程带来的开销。这种设计适合请求量平稳、处理时间较长的任务,但连接状态的维护要格外小心,因为同一个客户端的多次请求可能落到不同的子进程,如果有会话状态,就得把状态抽离到独立存储里,而不是放在进程内部。
4.3 协程方案:高并发低开销的现代答案
协程是在单个线程内部实现事件循环,用一个线程管理成千上万个IO任务。每个任务在等待IO操作时主动让出CPU,切换到另一个可以立即执行的任务那里,这样线程的利用率极高,而且没有线程切换的系统调用开销。
Python 3.4引入了asyncio标准库,让协程成为一等公民;但很多人对asyncio的写法感到别扭,因为它要求把每个socket操作都写成await。更贴近原始socket的写法是用asyncio.open_connection来封装连接,而不直接暴露socket细节。
下面是一个用asyncio实现的TCP回显服务器:
import asyncio async def handle_echo(reader, writer): data = await reader.read(100) if data: writer.write(f"已收到: {data.decode('utf-8')}".encode("utf-8")) await writer.drain() writer.close() await writer.wait_closed() async def main(): server = await asyncio.start_server(handle_echo, "127.0.0.1", 8888) async with server: await server.serve_forever() asyncio.run(main())这里面的关键差异:reader.read(100)返回一个可等待对象,执行到这一行时如果数据没到达,事件循环会去处理其他连接的任务,而不是干等。这就是协程高并发的秘密。
实际选型时,我的经验是:
- 项目只需要简单几百个并发连接,多线程足够了,学习成本低、好排查。
- 需要上万的连接在线,或者需要极低的内存开销,asyncio是更优选择。
- 不是非黑即白,很多项目是组合拳:底层用asyncio做IO核心,上层用多进程部署实例来压满CPU。
4.4 事件驱动模型里的select/poll/epoll
如果你在Linux下运维过东西,一定听过epoll。这其实是操作系统内核提供的IO多路复用机制。在Python里,你可以绕过asyncio直接使用selectors模块,它是对select/poll/epoll的统一封装。
简单说,select模型是把一批文件描述符交给内核,让内核告诉你有哪些socket已经可读可写,然后程序再去处理就绪的socket。epoll改进的点在于,连接数再多也不会像select那样每次都要把那批描述符全扫一遍,而只在有活跃事件时通知你。
直接操作selectors的代码比asyncio更啰嗦,但它能帮你理解epoll和事件循环的本质。如果只是为了写业务代码,直接用asyncio即可;如果你想深刻理解高性能服务器是怎么设计的,建议去看一下selectors模块的官方文档,并把一个echo服务器手写出来。
5. 常见问题与排查技巧实录
5.1 Address already in use:被TIME_WAIT卡住的端口
服务端程序在开发调试时,你可能会遇到OSError: [Errno 98] Address already in use,然后在网上搜到各种解释,其中最典型的就是TIME_WAIT。刚才在四次挥手部分提过,主动关闭连接的那一端,socket会进入TIME_WAIT状态,持续约2分钟(具体时长由操作系统决定,通常是4分钟内)。
解决的办法其实很简单:在bind之前设置SO_REUSEADDR。示例代码里我一直写着:
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)这行代码的意思是告诉操作系统:“如果这个端口还处于TIME_WAIT状态,允许我重新绑定它”。加了它之后,绝大多数开发环境下这个报错就不会再出现。生产环境是否要加因场景而异,因为TIME_WAIT本身有防止旧连接数据串到新连接的作用,但那是运维同学需要操的心,学习阶段直接加上没有问题。
另一个坑要注意:如果服务端和客户端都在同一台机器上,其中一个进程没有关闭,还占着端口,那即使加了SO_REUSEADDR也可能报错。这时候用lsof -i :8888(macOS/Linux)或netstat -ano | findstr 8888(Windows)查一下是谁占着端口,把对应进程处理掉就行。
5.2 recv返回空,原因只有一个但很多人记不住
recv返回空字节,只表示一件事:对端关闭了连接。这里有几个常见场景:
- 客户端代码没写
close()就退出了,但进程一结束操作系统会帮你关闭socket,所以服务端依然会收到空字节。 - 服务端在循环里recv时,客户端只发了一次数据就关闭连接,第二次recv就会返回空。
- 对端进程崩溃,操作系统会收敛它持有的所有socket,发送FIN,服务端同样会收到空字节。
这个判断规则简单到让人忽略,但它实际上是TCP连接生命周期里最可靠的语言。在写服务端处理循环时,永远要写:
data = client_sock.recv(1024) if not data: break不要用if data == b"",光写if not data就够了,因为空字节的布尔值是False。
5.3 粘包问题在什么场景下最明显
粘包问题在“连续且频繁发送小消息”时最明显。比如客户端在一个循环里循环了50次发送,每次发一个很短的JSON,服务端一次recv可能同时收到好几条拼接在一起的数据。如果你没有消息边界的方案,直接对数据做json.loads,就会因多出后半截内容而解码失败。
解决的办法已经在前面给过:加头部长度。这里我再补充一个更细的坑:整个消息头部固定4字节,绝对不要用可变长度、也不要只用1字节去存长度。1字节最多存255,一旦消息体超过255字节就溢出。另外,如果你在消息里既要传文本又要传二进制,头部长度一定要按字节计算,不能按字符计算。
排查粘包问题时,一个快速的手段是在服务端收数据后先打印原始bytes的repr,不要转str:
print(repr(raw_data))repr会显示b'msg1msg2msg3'这样的原始样子,方便你判断是不是发生了拼接,以及拼接的边界在哪里。
5.4 断线重连怎么设计才不慌
客户端和服务器的网络连接,受各种因素影响,随时可能断开。你的程序应该默认“连接会断”,而不是“连接应该永远正常”。重连的设计有几种常见方案:
第一种是最简单的:发现连接断了,创建新socket重新connect,失败就sleep几秒再试。这种模式适合心跳类应用,逻辑简单,但缺点是如果服务器一直没恢复,客户端会陷入无限重试。
第二种是带退避策略的重连。第一次失败等1秒,第二次等2秒,第三次等4秒,到最大间隔(比如30秒)后保持这个频率。这样既不会在服务器恢复之前狂打请求,也能在恢复后尽快重连上。
第三种是心跳检测加重连的组合。客户端定时发送心跳包,如果连续几个周期没收到服务端回复,就判定连接失效,主动断开并重连。这样即使网络层没有报错,应用层也能感知到连接已经名存实亡。
我项目里的套路一般是:心跳包间隔30秒,连续3次没回就判定失效;重连用指数退避,最多退避到60秒;重连期间保留业务数据的发送队列,等连接恢复后补发,避免数据丢失。
5.5 DNS解析慢怎么办:预解析IP然后缓存
网络编程还有一个经常被忽略的细节:socket.getaddrinfo这个调用,在做域名解析时可能阻塞,严重时每次连接都要等几百毫秒。对于连接频繁的程序,这个开销相当可观。
解决办法是把域名解析结果缓存起来,定期刷新。你可以先用一次解析拿到IP,后续直接用IP建连。当然,直接使用IP会丧失DNS的负载均衡能力,但对内部服务、自建网关这种场景,预解析加长连接是性价比非常高的方案。
如果必须每次都用域名,那也要把socket.getaddrinfo放到线程池里执行,避免阻塞住主流程或者事件循环。
6. 从回显服务器到真实项目:网络编程的进阶路线
6.1 给服务器加上协议解析层
前面的回显服务器,客户端发什么返回什么,这只是验证流程的玩具。真实项目中,服务器必须在字节流上解析出“这次请求的含义”。HTTP协议就是干这个的:它规定了请求行、请求头、空行、请求体,服务端按这个规则解析,才知道客户端在请求哪个URL,要传什么参数。
自己做协议解析时,我建议你按这个顺序设计:
第一,定义消息格式。最省事的是前面用的“4字节长度+消息体”,消息体内部用JSON序列化业务数据。JSON的好处是调试时肉眼可读,而且Python的json库处理起来非常顺滑。
第二,定义消息类型。给每个消息加一个type字段,比如{"type": "login", "data": {...}},服务端根据type分发给对应的业务处理函数。这样做的好处是网络层和业务层彻底解耦,后面加新功能只需要扩展类型分发,不用改动底层收发逻辑。
第三,处理半包。半包就是一次recv只收到了消息的一部分,可能是半个JSON。如果你用了标准的头部长度方案,recv_exact已经处理了这个问题。但如果你用的是“按换行切分”的方案,就一定要在缓冲区里累积数据直到找到完整的一行,不能只处理一次recv的返回值。
6.2 看到并发新写法先别慌:asyncio其实不难
很多人第一次接触asyncio的时候,最大的障碍是思维方式要变——不再是“调用函数等待结果”,而是“创建一个任务,等待它的事件循环来唤醒自己”。
我用一个最简单的类比说明:你在饭店点餐,多线程的模式是你每道菜都亲自站在厨房等,asyncio的模式是点完菜回到座位上,菜好了服务员来叫你。前者每个菜需要一个等菜的人,后者一个人可以同时点很多道菜。
写asyncio代码,记住三个关键字就够了:
async def:定义协程函数。await:等待一个异步操作完成,此时让出控制权给事件循环。event_loop:整个异步世界的调度器,管理所有协程的切换。
asyncio.run(main())是主入口,它会自动创建事件循环,运行main协程,直到main结束再关闭循环。不要自己去管loop的创建和关闭,除非你在做框架级的工作。
6.3 网络编程的调试工具三板斧
写网络程序不可能一次就通,我日常调试时固定用这三件工具:
第一是Wireshark,抓包工具。它能看到网络上每一个TCP包、UDP包的细节。当程序行为诡异,代码又看不出问题时,抓包能直接告诉你是客户端压根没发出请求,还是服务端收到了但处理错了。
第二是netstat,查看端口和连接状态。netstat -an | grep 8888能看某个端口是否有程序在监听、有哪些连接处于ESTABLISHED或TIME_WAIT状态。排查端口占用问题时它是第一反应工具。
第三是tcpdump,命令行抓包。服务器上没有图形界面的Wireshark时,它就是标配。学会一条最简单的tcpdump -i any port 8888 -nn -X就能抓到指定端口的数据包内容。
还有一个往往被忽略的调试方法是:把recv到的原始数据打印出来。很多问题只要看到原始bytes,就立刻明白了。别急着转成str输出,字符串转换会把不可见字符、长度信息都隐藏掉。
说实话,网络编程最折磨人的地方不是语法,而是“代码看着对,但线上表现不对”。你无法通过单步调试轻易复现网络时序问题,只能靠对协议底层机制的理解去推断。所以在学习阶段,多花时间搞懂TCP的状态流转、socket的阻塞行为、数据的字节流特性,比多背几个API有价值得多。踩过几次连接卡死、粘包错乱、状态残留的坑之后,你再看网络编程的很多代码,会有一种“原来如此”的通透感。