
看到python进阶三 网络编程这个标题我估计你多半已经会写Python脚本能调库、能处理数据但一到自己写网络服务就发懵。这个系列的第三篇我打算把网络编程这条线彻底讲透不是堆API文档而是从你拿到一个需求开始到设计协议、选并发模型、处理线上故障整个链路应该怎么思考。先声明这篇东西不会让你三天精通C网络库但能让你用Python写出能扛住真实流量、遇到问题知道怎么查的TCP服务。很多初学者觉得网络编程就是调socketconnect、send、recv三步走。真这么简单就不会有那么多线上事故了。你写一个requests.get()的时候背后有连接池、超时重试、TLS握手、HTTP解析这些全是网络编程的活。进阶的标志不是会用某个库而是当库不满足需求时你能自己把底层那层补上。1. 先搞清楚网络编程进阶到底进阶在哪1.1 会写requests不等于会网络编程我见过太多同学用requests写爬虫、调接口都特别溜但是让他自己写一个服务端接收POST数据他第一反应是去装Flask。用框架当然没错但一旦遇到长连接、自定义协议、或者需要跟硬件设备通信框架反而成了障碍。你需要的不是会调用某个函数而是理解一次网络通信从客户端到服务端中间经历了什么。网络编程的本质是在不可靠的信道上实现可靠的数据交换。TCP帮你解决了丢包重传、顺序保证、流量控制这些底层问题但一条消息从哪里开始、到哪里结束这个边界问题TCP不管。HTTP有\r\n\r\n和Content-Length来划边界WebSocket有帧长度MQTT有固定头这些都是构建在TCP之上的规矩。你进阶的第一步就是学会自己定规矩。1.2 这个阶段应该掌握的知识边界既然是进阶三默认你已经会Python基础语法、函数、类、装饰器也了解迭代器和生成器。在此基础上我建议你按下面这个清单自查缺哪块补哪块socket编程的基本流程TCP的connect、bind、listen、acceptUDP的sendto、recvfrom字节流与报文的区别粘包和半包产生的原因进程、线程、协程在并发网络模型里的适用场景GIL到底卡在哪常见应用层协议的手工解析至少能解析HTTP/1.1超时、重试、心跳、断线重连这四大护法会用抓包工具验证自己的判断而不是靠猜如果你发现上面某一条完全没概念那这篇就是给你准备的。如果你都懂也可以看看后面的代码细节尤其是帧缓冲和半关闭那两段很多老手也会在这里翻车。2. 从socket开始TCP连接的骨架比你想的更严谨2.1 一个最少可用的TCP服务端骨架直接看代码这是最朴素的单线程TCP服务端import socket server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((0.0.0.0, 9000)) server_socket.listen(128) while True: conn, addr server_socket.accept() print(f收到来自 {addr} 的连接) data conn.recv(1024) print(f收到数据: {data!r}) conn.sendall(bpong) conn.close()这段代码虽然简单但每一个调用都值得细品。bind传的地址是0.0.0.0而不是127.0.0.1区别在于前者监听所有网卡接口本机、局域网、外网都能连上后者只允许环回地址访问。开发调试用127.0.0.1更安全部署时才用0.0.0.0。SO_REUSEADDR不是可有可无的。如果不设置它服务端程序结束之后立刻重启大概率会报Address already in use。因为TCP连接断开后端口会进入TIME_WAIT状态默认要等2到4分钟才能完全释放。设置了这个选项内核就允许你在TIME_WAIT期间重新bind同一个端口。很多人上线服务时遇到端口被占其实就是这行没写。2.2 客户端和服务端的生命周期状态listen(128)里的128是backlog表示内核为这个监听socket排队的最大连接数。当客户端connect的时候三次握手其实在内核里已经完成了服务端还没来得及调用accept连接就会先进入这个队列。如果队列满了新来的连接会被直接拒绝。这个数字不是越大越好还得结合后续的并发模型否则压了一堆连接处理不过来反而浪费时间。accept()返回一个新的socket对象这个新socket才是真正跟客户端通信的通道。原来的监听socket只负责接收新连接不发送数据。很多新手在这里犯迷糊以为是同一个socket。记住一个socket连接对应一个四元组源IP、源端口、目标IP、目标端口服务端的监听socket没有固定的远端信息所以它没法收发具体业务数据。客户端这边connect会阻塞直到连接建立或超时。连接建立成功不代表对端应用层已经准备好接收数据只代表内核协议栈完成了握手。这就像电话拨通了但对方还没拿起话筒。2.3 为什么recv返回空字符串就是连接关闭了TCP是字节流没有消息边界。conn.recv(1024)返回的可能是半条消息也可能是一整条再加半条。真正的重点在于如果recv返回的是b说明对端正常关闭了连接发送了FIN。如果是负数或者抛出异常那才是异常断开。很多初学者把返回空字符串当成收到空数据这是个致命误解。UDP就不一样它是数据报协议recvfrom一次返回一个完整的数据报天然有边界。但UDP不保证不丢包、不保证顺序所以使用场景完全不同DNS查询、日志上报、音视频流这类能容忍丢包或者自带纠错机制的业务才适合UDP。后续我们讲协议解析主要针对TCP。3. 困扰90%新手的粘包问题不解决它接口都是白写3.1 粘包和半包是怎么出现的粘包这个词其实不太准确TCP是一个没有边界的字节流不存在包里多包了的概念。所谓的粘包是因为应用层连续发送了两条消息内核可能把它们合并成一个TCP段传给对端半包则是一条消息被拆成多个TCP段接收方一次recv只拿到了其中一部分。你自己写代码肯定会遇到服务端先发送消息A再发送消息B客户端一次recv居然同时收到了A和B。或者更糟收到的数据只到A的一半一半A加完整B纠缠在一起。如果采取每次recv当一条消息的幼稚解析方式程序就乱套了。解决方案只有一条设计消息边界协议。常见的有四种固定长度每条消息都一样长不足补零简单但浪费带宽分隔符比如HTTP头用\r\n\r\n但消息内容里不能出现该分隔符长度头包头固定几个字节表示正文长度这是最通用的做法自描述格式像Protocol Buffers、MessagePack这类序列化格式自带长度信息3.2 设计一个带长度头的帧协议我强烈推荐在自定义TCP协议里使用4字节长度头 载荷的格式。长度头使用无符号整数网络字节序大端这样跨平台解析不会有歧义。定义一个发送函数import struct def send_frame(sock, payload: bytes): header struct.pack(!I, len(payload)) sock.sendall(header payload)接收端不能用一次recv解决需要维护一个缓冲区不断累积数据然后尝试从缓冲区中解析出完整的帧class FrameBuffer: def __init__(self): self.buffer b def feed(self, data: bytes): self.buffer data def pop_frame(self): if len(self.buffer) 4: return None length struct.unpack(!I, self.buffer[:4])[0] if len(self.buffer) 4 length: return None frame self.buffer[4:4 length] self.buffer self.buffer[4 length:] return frame要点在于每次recv到新数据先塞进缓冲区再循环调用pop_frame()直到返回None表示当前缓冲区里的数据还不够组成一个完整帧。这里我用了内存切片如果数据量很大反复切片会有性能问题可以用bytearray配合索引偏移来做原理一样但生产环境要注意。struct.pack(!I, ...)里的!表示网络字节序即大端。如果你的设备全是x86小端用也能跑但一旦跟嵌入式设备、Java服务互通字节序不一致就会解析出天文数字。所以协议设计时统一用!是基本素养。3.3 边缘情况超大帧和恶意数据协议里还有个隐藏问题如果客户端声明长度头是4GB你的缓冲区就会尝试分配4GB内存直接被打死。所以实际开发中必须给单帧长度设上限比如最大1MB或10MB超过直接断开连接。同时为了防止慢速攻击应该从连接建立开始就监控缓冲区的总大小超过阈值立即拒绝。另外长度头里的值只代表帧负载的长度不代表这条消息的业务含义。你要不要在帧里再套一层业务类型字段比如第一个字节表示消息类型后面再跟数据。这个设计取决于业务复杂度。我的习惯是长度头只解决边界消息内部再放一个消息类型字段这样扩展起来不用改帧协议。4. 并发模型单线程阻塞、多线程、还是事件循环4.1 最简单也最容易踩坑的多线程方案上面的单线程服务端一次只能处理一个连接在处理recv时其他连接全部排队。最简单的改进就是每accept一个连接就开一个线程处理from concurrent.futures import ThreadPoolExecutor def handle(conn): with conn: while True: data conn.recv(1024) if not data: break conn.sendall(data) executor ThreadPoolExecutor(max_workers16) while True: conn, addr server_socket.accept() executor.submit(handle, conn)线程池能限制并发数避免无限开线程导致系统崩溃。在Python里多线程受GIL限制CPU密集型的代码很难并行但网络IO是阻塞型的线程在等待recv时会释放GIL所以多线程对IO密集型任务依然有效。这里有几个致命细节handle函数必须负责把连接关闭最好用with conn。如果忘记关闭连接会一直挂着客户端那边感知不到最终文件描述符耗尽。另一个问题是线程池的线程数是死的如果业务处理包含大量计算和慢查询线程会被占满新连接只能排队。4.2 多进程和线程的本质差异多进程能绕开GIL每个进程有独立的Python解释器真正的并行计算。但进程间通信比线程复杂你需要用multiprocessing.Process配合队列或管道传递连接。有一种经典做法是fork之后让子进程处理所有连接但进程数量管理、连接在进程间的负载均衡都需要自己操心。如果你只需要实现多核CPU上的高并发TCP转发器多进程是合理的。但如果你主要还是IO等待进程切换开销比线程大收益并不明显。我见过有人开着32个进程每个进程再开32个线程结果上下文切换开销巨大性能反而不如单进程加异步。4.3 事件循环asyncio人少也能干大事asyncio是Python官方的异步IO库核心思想是用一个线程通过事件循环调度多个协程。每个协程遇到IO操作就挂起让出控制权事件循环去处理其他就绪的协程。这样避免了线程切换的开销也能支撑大量长连接。写一个带asyncio的TCP服务器比想象中简单import asyncio async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): addr writer.get_extra_info(peername) print(f连接来自: {addr}) while True: data await reader.read(1024) if not data: break writer.write(data) await writer.drain() writer.close() async def main(): server await asyncio.start_server(handle, 0.0.0.0, 9000) async with server: await server.serve_forever() asyncio.run(main())看起来跟多线程版本一样是同步风格但await会挂起当前协程。比如两个客户端同时连接第一个客户端不发数据协程就挂在await reader.read()这里事件循环可以切换到第二个客户端完全不会阻塞。但asyncio不是银弹。如果你的业务代码里有一个耗时的CPU计算或者一个同步阻塞的time.sleep、requests.get整个事件循环都会被卡住。解决办法是把这类操作扔给asyncio.to_thread或loop.run_in_executor。记住在协程里写阻塞调用等于自杀。4.4 选型对照表我在实际项目里做技术选型通常按下面的逻辑判断场景推荐模型原因连接数很少100每个连接需要大量CPU计算多进程充分利用多核避开GIL连接数中等100-1000主要是IO等待多线程简单直观阻塞IO让出GIL长连接高并发1000单次IO较频繁asyncio线程开销太大协程更轻量需要处理混合任务网络CPU密集asyncio 进程池网络协程CPU任务扔进程池这个表不是绝对标准。操作系统支持的线程数量也不是无线扩展的一个进程默认能开的线程几千到几万不等但线程栈会消耗大量内存。asyncio的优势在于内存占用低单机撑几万连接很轻松。5. 把协议做实实现一个HTTP客户端和简单的WebSocket握手5.1 HTTP请求行、头部和消息体的组装网络编程最终都落到具体协议上。HTTP协议是你避不开的。用原生socket实现一个HTTP GET请求能帮你彻底弄懂requests在干什么。import socket def http_get(host, port, path): s socket.create_connection((host, port), timeout5) request (fGET {path} HTTP/1.1\r\n fHost: {host}\r\n fConnection: close\r\n f\r\n) s.sendall(request.encode(utf-8)) response b while True: chunk s.recv(4096) if not chunk: break response chunk s.close() return response这里Connection: close告诉服务器处理完请求后主动关闭连接这样我们读到EOF就知道响应结束了。但真实场景中为了性能都会用Keep-Alive这时候就需要解析响应头里的Content-Length来判断响应的结束位置。跟前面自定义帧协议一样HTTP的Content-Length就是帧长度头。5.2 解析HTTP响应时别忽略分块传输编码如果响应头里有Transfer-Encoding: chunked那Content-Length就不存在了。分块编码的格式是每块先给一个十六进制数字表示块大小后面跟CRLF然后跟块内容再跟CRLF直到一个大小为0的块结束。解析时要专门处理这个格式否则你会在Connection: close和chunked之间迷失。真实客户端库比如requests已经处理了这些但你调试抓包时如果不理解chunked会看到响应里多了很多十六进制数以为是乱码。5.3 WebSocket握手从HTTP Upgrade到掩码处理WebSocket是另一个典型协议它的建立过程以一个HTTP Upgrade请求开始。客户端发送一个带Sec-WebSocket-Key的GET请求服务端计算出Sec-WebSocket-Accept并返回101状态码之后连接升级为WebSocket帧。关键计算方式import base64 import hashlib GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 def compute_accept(key): digest hashlib.sha1((key GUID).encode(utf-8)).digest() return base64.b64encode(digest).decode(utf-8)之后的WebSocket帧有固定的头部格式第一个字节的高四位是opcode第一个字节的FIN位表示是否最后一帧第二个字节的MASK位客户端必须置1后面还有扩展长度和掩码密钥。从客户端发到服务端的帧必须掩码服务端到客户端不必掩码。用Python手动解析这些位运算是对二进制协议理解的一个很好锻炼。这部分代码要点在于移位和按位与写过一次之后再看其他二进制协议都会觉得亲切。6. 生产环境绕不开的坑超时、重连、半关闭6.1 为什么你的客户端一断网就卡死没有超时控制的网络程序在极端场景下等于死锁。比如服务端已经崩溃但客户端还阻塞在recv上因为客户端不知道对方已经挂了它会一直等。TCP有保活机制默认要等2小时才探测所以客户端至少要设置settimeout或者用select/poll监测。socket的settimeout是一把双刃剑。设短了正常的高延迟网络会被误判为超时设长了故障恢复时间变长。我习惯的连接建立超时设为3秒读取超时根据业务场景设30到60秒。注意settimeout作用于所有后续的阻塞操作如果一次recv超时抛出socket.timeout连接并不一定坏掉你可以继续重试读取但需要根据业务谨慎决定是否关闭连接。6.2 断线重连的退避策略重连不是等一秒再connect这么简单。如果服务端还没恢复你每秒重试一次会让服务端遭受连接风暴同时你的客户端日志会被刷屏。标准做法是指数退避加抖动。第一次等待1秒第二次2秒第三次4秒以此类推最多到30秒或60秒封顶。抖动是加一个随机值防止多个客户端同时重连导致服务端瞬间收到大量请求。import random import time def backoff(attempt): base min(2 ** attempt, 30) return random.uniform(0.5 * base, 1.5 * base)在循环里每次重连失败就time.sleep(backoff(attempt))连接成功后把attempt归零。这个策略写起来很简单但能帮你挡掉很多线上故障。6.3 半关闭shutdown和close不是一回事当你不再发送数据但还想接收数据时需要调用shutdown(socket.SHUT_WR)它会发送FIN给对端表示我说完了但套接字仍然可以读。有些协议比如HTTP/1.0就是靠半关闭来表示请求结束的。close()则是完全释放套接字底层引用计数减一。注意一个坑如果同一个socket被多个文件描述符引用比如fork之后在一个进程里close并不立即关闭底层连接要等引用计数为0。所以在多进程服务里父进程如果不小心持有子进程的socket会导致连接不释放。正确做法是每个进程只保留自己需要的那一份避免跨进程共享socket。另外TCP还有一个SO_LINGER选项可以控制close时的行为如果希望发送完缓冲区数据再超时关闭需要设置l_onoff和l_linger。不过日常开发里我很少动它除非遇到端口占用或脏数据这类特殊问题。6.4 心跳设计应用层心跳是必要的。TCP自带keepalive默认太慢不能及时发现死连接。最简单的心跳是客户端每N秒发一个Ping帧服务端如果连续M秒没收到任何数据就主动关闭该连接。心跳和重连配合使用才能保证连接池里的连接是真正可用的。7. 调试网络程序的实用手段与心得7.1 能复现问题的最小脚本网络问题最怕偶现。如果你怀疑是发送数据的问题先写一个10行以内的最小脚本只发送一条固定消息如果你怀疑是解析问题把收到的原始数据存成hex dump肉眼观察。我在排查问题时几乎不会直接改业务代码而是在源码里临时加一个原始数据记录点把它转成十六进制打印出来。比如收到b\x00\x05hello长度头0x05后面的hello正好5字节一目了然。很多粘包、半包问题一看hex就真相大白。7.2 学会用抓包工具验证结论不要靠猜抓包工具是你的第二双眼睛。用tcpdump抓本机端口数据sudo tcpdump -i lo0 port 9000 -X-X会同时打印十六进制和ASCII能清晰看到TCP有效载荷。你不用抓完整个连接通常抓前几个包就够了。配合Wireshark图形界面可以看三次握手、序列号、标志位。当你的代码判断连接超时时先抓包确认是SYN没有回应还是SYN-ACK丢了还是应用层没回数据排查方向完全不同。7.3 几个容易误判的场景这里分享几个我踩过的坑第一localhost和实际IP地址的差异。在Linux上连接localhost可能走IPv6的::1如果你只监听了IPv4的0.0.0.0就会连接被拒。所以调试时最好明确用127.0.0.1。第二缓冲区溢出导致的数据截断。recv(1024)返回的并不一定是1024字节它可能少到只有几个字节。千万不要假设一次recv就能收到一条完整消息必须以解析结果为准。第三网络时序问题。客户端先send再立刻recv如果对端处理很快响应可能已经到达如果慢recv会阻塞。所以测试时要故意制造延迟比如在对端sleep(1)验证你的客户端是否会出现假死。第四不要用print大法调试网络协议。print在数据量大时会疯狂输出而且print本身有缓冲容易改变程序的执行时序。我习惯用logging模块配合DEBUG级别输出连接建立、关闭、异常堆栈。这篇内容比较长但我说的每一条几乎都是网络上能搜到但很少有人串起来讲的实战经验。如果你正卡在网络编程某个瓶颈建议你照着第2节写一个最小服务端第3节写一个带长度头的帧缓冲第4节分别用线程和asyncio实现一版然后自己抓包看数据。动手折腾一遍比看十遍文章都管用。等你有足够的socket经验自然会理解为什么很多人说网络编程的尽头是排查问题——这句话我年轻时不信后来被线上告警教育过几次彻底信了。