ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python高并发实战:从GIL到多线程、多进程与asyncio

Python高并发实战:从GIL到多线程、多进程与asyncio 先别急着背概念我直接说一个很反直觉的结论你写的 Python 并发/异步代码跑不快八成不是 Python 的锅而是你压根没搞清楚任务类型和运行模型之间的匹配关系。我自己接手过不少线上项目也压过几百 QPS 的内部系统身边朋友一提到 GIL 就说“Python 多线程是废的”转头又开始纠结要不要换语言。实际上把 GIL、多进程、asyncio 这三板斧吃透你完全可以用 Python 写出能扛住高并发压力的 Web 应用而且不需要把项目推倒重来。这篇文章就把我这些年踩过的坑、实测过的数据、还有可以直接抄走的方案一次说清。这玩意儿的内容很简单一篇从 GIL 底层限制讲起到并发工具选型最后落到高性能 Web 应用压测与调优的实战笔记。适合刚接触并发的 Python 新手也适合已经会用threading但总觉得哪里不对劲的老手。我尽量不讲教科书套话只说怎么用、为什么这么用、还有哪些坑是文档里不会告诉你的。1. GIL 破解之前先认清并发与并行的分野1.1 你被 GIL 卡住的是哪一类任务GIL 全称 Global Interpreter Lock中文叫全局解释器锁是 CPython 解释器里一个非常出名的设计。它保证同一时刻只有一个线程能执行 Python 字节码。很多人一听就炸那 Python 多线程不就没用了吗这个说法只答对了一半。关键要分清两个概念并发和并行。并发是多个任务在同一个时间段内交替执行你感觉它们同时在跑并行是多个任务真正在同一时刻同时执行需要多核 CPU 支撑。GIL 卡的其实是“并行执行 Python 字节码”这条路也就是说 Python 多线程在 CPU 密集任务上无法利用多核优势。但在 IO 密集任务上线程一旦遇到网络请求、文件读写、数据库查询会主动释放 GIL这时候多线程照样能大幅提升吞吐量。所以你首先要回答一个问题我的任务到底是吃 CPU 还是等 IO吃 CPU 的任务比如图像处理、数据压缩、复杂计算多线程会被 GIL 死死按住等 IO 的任务比如爬虫、API 调用、文件传输多线程依旧真香。1.2 实测一下 GIL 到底干了什么没有数据没有发言权我们先跑个简单的实验。一个纯计算函数分别用单线程和两个线程去执行import time from threading import Thread def cpu_task(n30000000): while n 0: n - 1 start time.perf_counter() cpu_task(30000000) cpu_task(30000000) print(f单线程耗时: {time.perf_counter() - start:.2f}s) start time.perf_counter() t1 Thread(targetcpu_task, args(30000000,)) t2 Thread(targetcpu_task, args(30000000,)) t1.start() t2.start() t1.join() t2.join() print(f双线程耗时: {time.perf_counter() - start:.2f}s)我在一台 8 核机器上跑单线程大约 2.1 秒双线程反而要 2.5 秒左右。没错不仅没有加速还因为 GIL 的切换开销变得更慢了。这就很直观线程在 CPU 密集场景下帮的是倒忙。但同样的逻辑如果每个任务里加一次sleep(0.1)模拟网络等待双线程和单线程的差距就会非常明显。原因不复杂线程等待的时候GIL 被释放另一个线程就能拿到执行权。GIL 并不是完全锁死线程只是限制了同一时刻的字节码执行。顺便提一句Python 3.13 开始官方在实验性支持不带 GIL 的 free-threading 模式但那是另一个话题。眼下现实中跑生产环境的 CPython你还是要认真对待 GIL 的存在。2. 多线程没废IO 密集型场景依然真香2.1 ThreadPoolExecutor 的正确用法日常开发里我不会直接操作threading.Thread而是用concurrent.futures.ThreadPoolExecutor。它帮你管理线程生命周期代码写起来也干净。最典型的场景是一大批 URL 要抓内容from concurrent.futures import ThreadPoolExecutor, as_completed import requests urls [ https://example.com/api/users/1, https://example.com/api/users/2, # 假设这里有几百个 URL ] def fetch(url): resp requests.get(url, timeout10) return url, resp.status_code, len(resp.content) with ThreadPoolExecutor(max_workers16) as pool: futures [pool.submit(fetch, u) for u in urls] for future in as_completed(futures): url, status, size future.result() print(url, status, size)这里有三个容易忽略的点。第一as_completed不是必须的但很有用。如果直接遍历原始urls列表某个慢请求会卡住整个遍历as_completed是哪个任务先完成就先返回哪个整体耗时接近最慢的那个请求而不是所有请求耗时之和。第二max_workers不是越大越好。线程多了操作系统上下文切换开销会抵消收益而且每个线程都有栈空间内存占用也会上来。经验值通常是min(32, os.cpu_count() 4)附近但真实项目要根据任务平均等待时间、目标并发数来压测确定。第三future.result()会重新抛出任务里的异常。很多人写完pool.map没调result()异常被吞掉排查问题的时候一脸懵。务必保证拿到结果或捕获异常。2.2 锁与竞态别让 Thread-safe 变成自欺欺人多线程最阴间的坑不是 GIL而是数据竞争。即便 GIL 保证了字节码级别的互斥也不代表一段 Python 代码是原子操作。比如一个非常经典的计数器问题import threading counter 0 def add(): global counter for _ in range(1000000): counter 1 threads [threading.Thread(targetadd) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() print(counter) # 期望值是 8000000实际可能少很多counter 1在底层是“读取值、加一、写回”三步线程可能在写回前被打断另一个线程读到了旧值最后结果就丢了。解决办法也很简单用锁lock threading.Lock() def add(): global counter for _ in range(1000000): with lock: counter 1或者直接用queue.Queue让线程之间通过队列传递消息避免直接共享可变状态。这也是我比较推荐的做法能不用锁就不用锁消息传递比锁安全得多。2.3 我从爬虫和高并发脚本里总结的线程踩坑不要在ThreadPoolExecutor里复用同一个数据库连接。大多数数据库驱动不是线程安全的每个线程要么创建独立连接要么使用专门的连接池比如 SQLAlchemy 的QueuePool。线程池里的任务如果抛异常程序不会立刻崩溃异常只挂在Future对象上。一定要future.result()或者用add_done_callback处理。优雅退出很重要。ThreadPoolExecutor的with块退出时会等待所有任务完成但如果你用的是手写线程别忘记join()。服务重启时不处理线程直接os._exit可能导致数据库连接没释放。共享全局requests.Session在多线程里是安全的吗requests.Session官方说大部分操作线程安全但我实践中还是会为每个线程单独实例化一个 Session避免连接池和 cookie 状态打架。3. 绕开 GIL 的硬核解法多进程与 CPU 密集型并行3.1 ProcessPoolExecutor 到底怎么运作如果任务是 CPU 密集型的最简单的绕开 GIL 方案就是改用多进程。每个 Python 进程都有自己独立的解释器和 GIL进程之间天然并行。ProcessPoolExecutor的用法和线程池几乎一样from concurrent.futures import ProcessPoolExecutor import hashlib def sha256_loop(n): data bx * 1024 for _ in range(n): hashlib.sha256(data).digest() return done if __name__ __main__: with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(sha256_loop, [200000, 200000, 200000, 200000])) print(results)这段代码在四核机器上四个任务几乎是同时完成的再也不会出现线程那种“越跑越慢”的情况。但有一个很多人第一次用就会翻车的点if __name__ __main__保护。Windows 上启动子进程会重新导入主模块如果没有这个保护子进程会无限递归创建新进程直接把你机器搞崩。Linux 上虽然默认用 fork 方式不一定触发这个问题但养成写保护的习惯没坏处。3.2 进程间通信的成本算计多进程解决了并行问题但带来了另一个隐形成本进程间通信。任务参数和返回值需要序列化和反序列化这个过程用的是 pickle。如果你的任务要传递一个 200MB 的 DataFrame序列化耗时可能比计算本身还长。我的建议很简单把大对象留在进程内部处理完只回传小结果。比如一个任务需要处理大量文本那就让子进程读取文件路径、处理完返回统计信息而不是把整个文件内容塞进任务参数。如果确实需要大批量数据交换可以用multiprocessing.Queue、Pipe或者共享内存。但说实话在大多数业务场景里ProcessPoolExecutor配合“大进小出”原则就够了。再复杂的分布式并行建议直接上 Ray 或 Dask别自己在 multiprocessing 上造轮子维护成本太高。3.3 混合并行模型主进程调度 工作进程异步真实业务往往不是纯 CPU 或者纯 IO而是混合型。比如一个 Web 请求进来既要做耗时的签名计算又要调用外部 HTTP 接口。这时候单用线程池或者单用进程池都不太够。我的做法是把阻塞的 CPU 计算丢给进程池把 IO 等待交给异步事件循环两者用run_in_executor串起来import asyncio from concurrent.futures import ProcessPoolExecutor def heavy_cpu(x): # 模拟一个 CPU 密集型计算 result 0 for i in range(x): result i ** 2 return result async def handle_request(x): loop asyncio.get_running_loop() # CPU 任务放到进程池避免阻塞事件循环 result await loop.run_in_executor(process_pool, heavy_cpu, x) return result process_pool ProcessPoolExecutor(max_workers2) async def main(): tasks [handle_request(i) for i in range(10)] results await asyncio.gather(*tasks) print(results) asyncio.run(main())注意这里的process_pool最好定义成全局复用别放在请求处理函数里反复创建。频繁创建进程池的开销非常大而且容易把文件描述符打满。这种“进程池 协程”的组合在高性能 Web 服务里非常常见后面实战部分还会再提到。4. asyncio 异步编程单线程也能撑起高并发4.1 事件循环、协程与 await 的本质理解很多初学者把 asyncio 想得很玄乎其实它就是一个在单线程内实现的协作式调度器。事件循环负责管理任务队列当某个协程遇到await一个 IO 操作时它会把控制权交还给事件循环让其他协程继续跑。等 IO 完成了再回来继续执行刚才挂起的地方。打个比方一个咖啡师同时接待多个客人点单后先去做咖啡等待磨豆机转的时候不是干站着而是先去给下一个客人打奶泡。等磨豆完成了再回来继续操作。这就是异步的核心思想等待时不占用人手。一段最简单的代码import asyncio async def say_hello(): print(hello) await asyncio.sleep(1) print(world) async def main(): await asyncio.gather(say_hello(), say_hello()) asyncio.run(main())两个say_hello()会交错执行总耗时约 1 秒而不是 2 秒。注意asyncio.sleep只是模拟 IO 等待真实场景里等待的是网络响应、数据库查询、文件读取这类阻塞操作。4.2 什么时候用 async/await什么时候别硬上网上有个常见的误解async 一定比线程快。这完全不对。asyncio 的价值在于避免大量线程带来的上下文切换和内存开销同时用更可控的方式管理并发任务。如果你的依赖库全是同步实现的比如requests、psycopg2硬塞进 async 代码里会把事件循环整个阻塞住反而比线程池还慢。对号入座适合 asyncio大量 IO 密集型、依赖有异步版本如aiohttp、httpx、asyncpg、redis.asyncio。不适合 asyncioCPU 密集型计算、重度使用同步库、需要利用多核的纯计算任务。如果项目里已经有一堆同步代码不建议立刻迁移。可以用anyio的to_thread或者loop.run_in_executor把同步阻塞操作丢到线程池这样渐进式改造风险小很多。4.3 实战用 asyncio 重写一个 IO 密集型任务把前面 2.1 的爬虫例子改成 asyncio 版本import asyncio import aiohttp urls [ https://example.com/api/users/1, https://example.com/api/users/2, ] semaphore asyncio.Semaphore(20) # 限制并发数 async def fetch(session, url): async with semaphore: async with session.get(url, timeout10) as resp: content await resp.read() return url, resp.status, len(content) async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] results await asyncio.gather(*tasks) for url, status, size in results: print(url, status, size) asyncio.run(main())这里有几个细节是新手最容易踩的。第一asyncio.Semaphore(20)非常重要。如果不做限流几千个 URL 同时发起连接很可能把目标服务器打挂也可能触发本机端口耗尽。信号量就是用来限制同时运行的协程数量。第二aiohttp.ClientSession应该复用不要每个请求都创建新的 Session。Session 内部维护了连接池复用能大幅降低握手开销。第三asyncio.gather默认是任务一旦有异常就直接抛出来其他任务可能被取消。如果希望异常不中断整体可以传return_exceptionsTrue然后在结果里逐个判断isinstance(result, Exception)。实测同样的 1000 个 URLrequests单线程跑完可能 80 秒线程池 16 个 worker 大约 15 秒aiohttp 配合 50 并发大约 8 秒。差距非常明显。但也别一上来就说 asyncio 无敌这个场景本质上是 IO 密集换成线程池也一样能扛只是资源占用和调优空间不同。5. 高性能 Web 应用实战把并发三板斧拧进一个服务5.1 服务端并发模型架构选型FastAPI Gunicorn Uvicorn开发高性能 Web 应用我最常用的组合是 FastAPI Gunicorn Uvicorn。FastAPI 负责路由、参数校验、OpenAPI 文档Uvicorn 是 ASGI 服务器内部跑事件循环Gunicorn 作为进程管理器启动多个 Uvicorn worker 进程来利用多核 CPU。为什么不是单跑 Uvicorn因为单个 Uvicorn 进程默认是单事件循环只能跑在一个 CPU 核上。即使你这台机器有 32 核它最多也就吃满一个核。所以生产环境还要再加一层进程管理gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app这里的-w 4指 4 个 worker 进程。worker 数量不是越多越好太多进程会争抢 CPU、增加内存开销。经验上IO 密集的 API 服务可以设为2 * CPU核心数 1纯 CPU 密集则建议等于或略小于核心数。最靠谱的做法还是压测下面第 6 节会说。每个 worker 进程内部是一个 or 多个事件循环接口函数如果是async defFastAPI 会把它放进事件循环调度如果定义成普通defFastAPI 会自动丢到线程池执行。这里有个很实用的判断逻辑接口里只有异步 IO 操作写成async def接口里是同步阻塞逻辑就老实写成普通def靠线程池兜底千万别用async def包裹同步阻塞调用。5.2 异步数据库访问与连接池配置Web 服务的高并发瓶颈往往不在应用层而在数据库。每个 worker 进程如果自己管理数据库连接连接数会随着 worker 数翻倍极容易把数据库连接池打满。如果使用 SQLAlchemy 2.0 的异步能力连接 PostgreSQLfrom sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker DATABASE_URL postgresqlasyncpg://user:passlocalhost/dbname engine create_async_engine( DATABASE_URL, pool_size10, max_overflow20, pool_pre_pingTrue, ) SessionLocal async_sessionmaker(engine, expire_on_commitFalse)关键参数就三个pool_size是连接池保持的最小连接数max_overflow是高峰时最多额外创建的连接数pool_pre_ping会在取连接前先探活避免拿到坏连接。连接池大小怎么定一个粗略公式是((core_count * 2) effective_spindle_count)但实践中我习惯先给一个保守值再根据压测结果调。连接池设太大了数据库先扛不住设太小了高并发时接口排队严重P99 延迟飙升。库存扣减这类高并发写场景一定不要用“先查库存再 update”这种两步逻辑哪怕加了事务也会出现超卖。推荐用原子 UPDATE 或者乐观锁UPDATE products SET stock stock - 1 WHERE id :id AND stock 1;让数据库保证原子性应用层再根据 rowcount 判断是否扣减成功。这个方案简单、可靠能应付大多数秒杀和库存场景。5.3 限流、背压与监控高性能不等于无限并发没有限流和背压的高并发系统就像踩油门不踩刹车的车迟早出事。应用层限流我常用slowapi或者在接口入口放一个asyncio.Semaphore。注意Semaphore是进程内的不是全局的。如果你开了 4 个 worker每个 worker 里 200 并发系统整体其实是 800 并发。要想做全局限流必须依赖 Redis 或者 API 网关比如 Nginx 的limit_req、Kong 的 rate limiting。背压指的是当下游处理不过来时上游不要继续无脑发任务。实现思路是在队列满了之后直接返回 503让调用方退避重试而不是无限堆积在内存队列里把服务拖垮。监控方面我强烈建议在 FastAPI 里挂一个 Prometheus 客户端from prometheus_fastapi_instrumentator import Instrumentator Instrumentator().instrument(app).expose(app, endpoint/metrics)这样每个请求的耗时、状态码、QPS 都能观测到。高并发项目最忌讳“凭感觉调优”拿到监控面板之后你才会知道瓶颈到底在 CPU、数据库连接池还是外部第三方接口。6. 压测与调优用数据说话而不是“我觉得快”6.1 压测工具怎么选调优之前先要能用数据量化现状。我常用的压测工具有三个工具适用场景优点缺点wrk简单 HTTP 接口压测轻量单机可压出很高 QPS支持 Lua 脚本复杂业务场景难编写Locust模拟真实用户行为Python 写场景分布式压测简单压测机资源占用较高JMeter复杂断言、录制回放社区成熟支持各种协议图形界面偏重新手难驾驭日常自测我先用 wrk比如起一个接口后用wrk -t8 -c200 -d30s http://localhost:8000/api/products意思是 8 个线程、200 个并发连接、持续 30 秒。结果会给出 QPS、平均延迟、P50、P99 等指标。6.2 一个具体压测案例从吞吐量到延迟分布的解读我压过一个内部的商品列表接口8 核机器FastAPI 4 个 worker压测结果大概是这样的Thread Stats Avg Stdev Max /- Stdev Latency 12.28ms 10.15ms 220.23ms 89.20% Req/Sec 1.25k 142.67 2.13k 74.32% 286678 requests in 30.00s, 45.12MB read Requests/sec: 9555.46 Transfer/sec: 1.50MBQPS 接近 1 万P50 延迟 12ms看起来不错。但别高兴太早P99 如果能到 80ms 以内才算比较健康。如果 P99 远高于 P50说明有少数慢请求拖后腿可能是数据库连接池不够、GC 暂停、或者外部服务抖动。压测时遇到瓶颈我是这么排查的看 CPU 使用率。如果多个 worker 进程 CPU 打满说明计算或序列化是瓶颈需要增强硬件或优化代码。看数据库连接数和慢查询。连接数满或慢查询多问题在下游。看内存和 GC。Python 对象频繁创建销毁GC 会造成毛刺延迟考虑用__slots__、减少临时对象分配。6.3 配置参数与内核参数的常见调优项压测完了通常会做一波参数调整。我比较常用的调优项列在这里Gunicorn worker 数从2 * CPU核心数 1开始压测后逐步增删找到拐点。Uvicorn 的 limit_concurrency控制 worker 内同时处理的请求数防止排队过多。内核参数修改文件描述符上限ulimit -n 1048576开启 TCP 快速回收net.ipv4.tcp_tw_reuse1减少高并发短连接带来的 TIME_WAIT 堆积。数据库连接池根据压测曲线把pool_size、max_overflow调到合理范围。uvloop在 Uvicorn 里开启 uvloop 的事件循环策略部分场景能降低事件循环开销。响应体体积能用 JSON 压缩就开压缩GZipMiddleware能显著降低带宽消耗。调优没有银弹所有参数都要结合监控数据去调。比如你以为max_workers调大就能提升性能实际可能因为数据库连接数打满导致 P99 直接翻倍。每一步都要用压测数据验证而不是拍脑袋。7. 最后分享几个我一直在用的并发调试小技巧写了这么多年并发代码我最大的体会是并发 bug 之所以难查是因为它不走寻常路。分享三个能帮你少熬夜的小技巧。第一在本地复现问题时把并发数调小到 2 到 4加上详细日志比在百并发下抓现场高效得多。大多数竞态问题在小并发下也能复现而且日志不会乱成一片。第二用faulthandler注册超时转储在服务启动时加faulthandler.dump_traceback_later(30, exitTrue)一旦出现死锁或卡死能自动打印所有线程的堆栈定位哪一行卡住了。第三务必重视超时设置。HTTP 请求要设 timeout数据库连接要设 connect_timeout队列要设 put 超时。没有超时的并发系统就是一个无限等待的漩涡一旦一个下游服务抖动整个链路都会跟着雪崩。并发编程不难难的是尊重资源的边界。这篇文章写的每个方案都是我踩过坑之后沉淀下来的做法。你可以照着抄也可以在此基础上根据自己的业务场景继续打磨。希望你能少踩几个我踩过的坑把时间花在真正有价值的功能上。
返回列表