1. 为什么高并发IO场景下,线程池第一个投降
先讲个我自己踩过的场景。几年前做爬虫,目标就几千个页面,用requests串行跑,一个页面平均 300 毫秒,算下来要十几分钟,实在太慢了。第一反应是上线程池,ThreadPoolExecutor(max_workers=32),把 URL 列表分片丢进去,表面上确实快了。但线程数一旦提到 64、128,问题就来了——吞吐量不升反降,CPU 占用忽高忽低,偶尔还出现莫名其妙的ConnectionError。最后用cProfile一分析,发现大量时间耗在线程切换和锁等待上。
这件事让我彻底想明白一个结论:线程池解决的是"并发"问题,不是"等待"问题。网络请求、文件读写、数据库查询,这些操作的本质是"发出去,等结果",CPU 本身没有干活,大部分时间都在睡大觉。而线程一旦多了,操作系统要频繁做内核态和用户态的切换,这个开销是被白白浪费掉的。
Python 协程解决的就是这个"等"字。它不需要操作系统级别的线程调度,而是在进程内部、由事件循环统一管理任务的切换。协程的切换成本极小,小到什么程度?线程切换通常要消耗几微秒到几十微秒的 CPU 时间,还要经过内核,协程切换只需要在用户态保存和恢复几个寄存器和栈帧,基本是纳秒级。所以同样一台机器,用线程池能扛住几百个并发连接,用协程能扛住几万个。
打个比方:线程像是你临时聘请了一百个员工处理业务,每个员工都要工位、工作证、交接班记录;协程则是一个员工手里拿了一百张待办清单,虽然在同一个工位上干活,但每张清单上的任务进展都清清楚楚,换清单的时候只需要从口袋里掏出来看一眼就行。
这篇文章主要聊 Python 协程的核心机制、asyncio 的实际用法、协程和线程/进程的选型边界,以及我在生产环境里踩过的那些坑。适合刚接触 async/await 的入门者,也适合已经写过一些 asyncio 代码但总感觉"哪里不对劲"的同学。理解协程不是为了赶时髦,而是当你的程序真的被 IO 卡住脖子的时候,手里有另一个解法。
2. 从生成器到async/await:Python协程的底层机制拆解
2.1 生成器怎么就成了协程的前身
要说 Python 协程,绕不开生成器。早期 Python 里实现用户态切换,最经典的方案就是yield。看这段老代码,它其实已经具备了一个最小协程的雏形:
def task_a(): print("A 开始") yield print("A 继续") yield print("A 结束") def task_b(): print("B 开始") yield print("B 继续") gen_a = task_a() gen_b = task_b() next(gen_a) # A 开始 next(gen_b) # B 开始 next(gen_a) # A 继续 next(gen_b) # B 继续 next(gen_a) # A 结束控制流在两个函数之间来回跳,程序可以在一个线程内实现"你一下我一下"的交错执行。绿协程(greenlet)走的也是类似路线,只是把yield封装得更顺手。Python 3.4 之后引入了asyncio,3.5 引入了async/await关键字,本质上是把"通过 yield 实现暂停恢复"的模式标准化、语义化。
理解协程的关键在于:协程是惰性的,不驱动它,它就不执行。普通函数一调用就从头跑到尾,而协程函数被调用后只返回一个协程对象,必须等事件循环或 await 去驱动它才会真正跑起来。很多初学者写async def foo()之后忘了await,程序没有任何反应,就是这个原因。
2.2 事件循环到底在循环什么
事件循环是协程的核心调度器。你可以把它理解成一个不断问"现在谁可以继续跑"的调度员:
- 从任务队列里取出一个协程,开始执行;
- 协程遇到 IO 等待(比如
await asyncio.sleep(1)),就主动让出控制权,把自己挂起; - 事件循环把这个协程的"唤醒时间"记下来,然后去执行下一个可运行的任务;
- 当协程的等待条件满足(比如定时器到了、网络数据到了),事件循环把它重新放回可运行队列;
- 循环往复,直到所有任务完成。
关键点在第二步——协程遇到 await 时主动让出,而不是被操作系统抢占。这是和线程最大的区别。所以只要代码里不写阻塞调用,事件循环就不会被卡住,调度效率极高。
import asyncio async def task(name, delay): for i in range(3): print(f"{name} 第 {i + 1} 轮开始") await asyncio.sleep(delay) print(f"{name} 第 {i + 1} 轮结束") async def main(): await asyncio.gather( task("A", 0.1), task("B", 0.15) ) asyncio.run(main())这段代码里,A 和 B 是在同一个线程内交替执行的。asyncio.sleep让出控制权,事件循环立刻切换去跑 B。从输出时间戳上你能看到,两个任务的总耗时大约是最大单个任务耗时,而不是两者之和。这就是并发带来的收益。
2.3 await 到底在等什么
await后面可以跟两类东西:一是另一个协程对象,二是实现了__await__方法的对象。本质上,await 是在表达"我需要等待这个操作完成,期间我不占着 CPU,其他协程可以跑"。
用生活化的例子:你点了一杯奶茶,正常做法是站在柜台前等,什么都不干;协程的做法是拿个叫号器,先去旁边做别的事,奶茶好了叫号器响了再回来取。await就是把叫号器递出去的动作。
需要注意的是,await 不是等待完成之后阻塞在这里,而是把自己挂起了。挂起和阻塞是两个完全不同的概念。阻塞是"我在等,CPU 被我占着",挂起是"我在等,CPU 让给别人了"。协程性能的全部来源,就是尽可能多地把"阻塞等待"变成"挂起让位"。
3. asyncio实战:任务创建、并发控制与超时处理
3.1 会用asyncio.gather就够了吗
很多教程喜欢直接展示asyncio.gather,但它不是万能的。我推荐先理解create_task,因为它是理解 asyncio 任务模型的基石:
import asyncio async def fetch_one(url): await asyncio.sleep(0.1) # 模拟网络请求 return f"结果: {url}" async def main(): task = asyncio.create_task(fetch_one("https://example.com")) result = await task print(result) asyncio.run(main())create_task会把协程包装成一个 Task,立刻提交给事件循环调度。注意这个"立刻"——就算你还没 await 它,它也可能已经开始跑了。这个特性在需要并发启动多个任务时非常有用:
tasks = [asyncio.create_task(fetch_one(f"url-{i}")) for i in range(10)] results = await asyncio.gather(*tasks)3.2 wait、gather、TaskGroup 到底怎么选
asyncio 里有三套并发 API,选择标准其实很清晰:
asyncio.gather:所有任务都必须成功,任何一个抛异常,gather 默认会把异常抛给你。适合"要么全成功,要么算失败"的批处理场景。asyncio.wait:可以控制返回值,FIRST_COMPLETED、FIRST_EXCEPTION、ALL_COMPLETED。适合"我只要最先完成的那个结果"的场景,比如多个服务接口,谁先返回用谁的。TaskGroup(Python 3.11+):新一代写法,自带结构化并发,其中一个任务失败会取消其他任务。写新代码优先选它,语义最清晰。
async def main(): async with asyncio.TaskGroup() as tg: task1 = tg.create_task(fetch_one("a")) task2 = tg.create_task(fetch_one("b")) # 离开 with 块时,所有任务都完成了3.3 限流:Semaphore 是你必须掌握的工具
碰上几十万个并发任务,直接create_task一梭子打满,瞬间就能让目标服务返回 429 或者把你的本地文件描述符耗尽。正确做法是给任务加信号量限流。
import asyncio async def worker(sem, task_id): async with sem: # 模拟 IO await asyncio.sleep(0.5) print(f"任务 {task_id} 完成") return task_id async def main(): sem = asyncio.Semaphore(100) # 最多 100 个并发 tasks = [asyncio.create_task(worker(sem, i)) for i in range(5000)] await asyncio.gather(*tasks) asyncio.run(main())这个模式在爬虫、API 调用、批量数据同步中基本是标配。信号量的设计哲学是"宁可排队,不要打爆"——控制下游压力永远比事后处理 502 简单。
3.4 超时处理:asyncio.wait_for 的隐藏坑
协程的性能优势恰恰是它的风险点——一个任务如果内部没有设置超时,可能会卡住事件循环很久。asyncio.wait_for用来兜底:
try: result = await asyncio.wait_for(fetch_one("slow"), timeout=3.0) except asyncio.TimeoutError: print("请求超时,进入降级逻辑")这里有个坑:wait_for超时后会 cancel 任务,但 cancel 是"请求取消",协程内部如果不响应取消(比如卡在一个不受控的 C 扩展调用里),超时就不生效。这是我排查线上问题时真正遇到过的情况。所以更稳的做法是:在协程内部的关键 IO 点手动配合asyncio.timeout或事件循环的call_later,把超时控制做得更细。
4. 协程 vs 线程 vs 进程:选型逻辑与实测对比
4.1 一张表看懂三者边界
| 维度 | 协程 (asyncio) | 线程 (threading) | 进程 (multiprocessing) |
|---|---|---|---|
| 调度方式 | 用户态事件循环调度 | 操作系统内核抢占式调度 | 操作系统内核调度 |
| 切换开销 | 极小(纳秒级) | 较大(微秒级,涉及内核态切换) | 最大(需要复制/切换地址空间) |
| 内存共享 | 同线程内共享,无锁风险 | 共享,需要锁 | 需 IPC,成本高 |
| CPU 密集任务 | 不推荐 | 受 GIL 限制,几乎无效 | 推荐,可多核并行 |
| IO 密集高并发 | 最优选 | 可用,但并发上千就会吃力 | 不划算 |
| 代码复杂度 | 需理解 async/await,不能随意写阻塞代码 | 相对直观 | 逻辑简单但通信麻烦 |
4.2 三千个并发请求:三个方案的实测差距
我之前做过一个压力测试,任务内容就是发 3000 个 HTTP 请求,目标是一个本地测试服务,每个请求延迟约 200 毫秒。结果如下:
- 串行:耗时约 600 秒,CPU 几乎全程在等待;
- 线程池(64 线程):耗时约 12 到 15 秒,期间 CPU 上下文切换频繁,系统负载偏高;
- asyncio 并发(信号量限流 200):耗时约 3 到 4 秒,CPU 占用极低,所有时间基本都在等待网络响应。
另一个测对比发现,线程数超过 CPU 核心数的 4 到 5 倍后,性能收益就开始衰减,而协程几乎没有这种瓶颈。原因前面说过,线程切换绕不开内核,协程切换只是一个函数调用。
4.3 什么情况下坚决别用协程
协程不是银弹。如果我的代码是做图像处理、复杂数学计算、大量正则匹配这类 CPU 密集型任务,协程帮不上忙。因为根本没有 IO 等待可以利用,事件循环反而多了一层调度损耗。这时候正确选择是multiprocessing或ProcessPoolExecutor,利用多核并行。
再比如代码里用了requests、标准open()文件读取、time.sleep()、logging落到磁盘这些同步阻塞调用,在协程里写它们会阻塞整个事件循环,把并发降级成串行。这是我们最容易犯的错误——协程看起来写了async def,但内部塞了一堆阻塞代码,性能还不如普通线程池。
碰到这种情况,要么把阻塞操作放到线程池里跑,用asyncio.to_thread(func, ...)或loop.run_in_executor,要么换成真正的异步库(比如aiohttp替代requests,asyncpg替代psycopg2)。这是协程领域的核心生存法则:零阻塞原则。
5. 协程项目里我踩过的经典坑与完整排查链路
5.1 阻塞调用卡死事件循环:最隐蔽的性能杀手
症状是这样的:一个协程服务,并发上来后吞吐量急剧下降,但 CPU 占用率不高,看起来像网络问题。我查了半天,最后发现代码里有人在异步函数里直接用了requests.post()同步发了一个外部 Webhook。
根因很好理解:requests是同步阻塞的,它一执行,整个事件循环就被卡住,所有其他协程都只能排队等待。请求一多,这个"卡顿"不断累积,服务吞吐量就塌了。
排查链路可以这样复现:
import asyncio import requests async def bad_task(i): requests.post("http://localhost:9000/api", json={"n": i}) return i async def good_task(i): await asyncio.sleep(0.01) return i async def main(): # bad_task 会阻塞整个事件循环,good_task 也会被拖慢 await asyncio.gather(bad_task(1), good_task(2))验证方法很简单:在 good_task 里打印时间戳,如果 bad_task 那一刻其它任务的时间戳停住了,基本就是阻塞调用实锤。修复方式是用asyncio.to_thread把它扔出事件循环,或者换成aiohttp。这个坑按我的经验,是协程项目里出现频率最高的问题,没有之一。
5.2 创建的 Task 被 GC 吞掉
另一个经典坑:在某个函数内部asyncio.create_task()创建了任务,但函数的返回值很快执行完,Task 对象引用计数归零,被垃圾回收了。事件循环都不知道这个任务消失,请求最终没有执行,没有任何报错,日志干净得像什么都没发生过。
排查链路是:功能偶发不生效,代码逻辑反复检查都没问题,最后加了sys.getrefcount才发现是引用丢了。
修复非常简单——保存好 Task 引用:
background_tasks = set() async def handle_request(): task = asyncio.create_task(do_something()) background_tasks.add(task) task.add_done_callback(background_tasks.discard)这在 FastAPI 或你自己的服务端代码里特别常见。官方文档特别提过这个坑,但太隐蔽了,很多人没意识到。
5.3 忘记 await,协程根本没执行
如果你是新手,最常犯的其实是这个:定义了一个async def,然后像普通函数一样fetch_data()调用,没写await。程序不会报错(最多有个 RuntimeWarning),但你的函数压根没有跑。
排查链路:找"为什么这个异步函数没执行"这类问题时,先检查所有调用点是否都加了 await。一个协程对象就是一个"还没开始的计算",直到 await 它才真正启动。我把这个规则总结成一句口诀:async def 是配方,await 才是开火。
5.4 定时任务与并发任务相互踩踏
协程服务里经常要做"定时拉取配置 + 处理用户请求"这种混合场景。我之前直接用while True: await asyncio.sleep(10)跑定时任务,结果配置更新时正在处理请求,共享变量被改到一半,出现脏读。
根因是协程虽然是协作式调度,但在事件循环内部,单个协程内部的代码块是原子的吗?不是。如果你在一个协程里写了 10 行代码,执行到第 3 行时遇到 await,事件循环就可能切换去跑别的协程,你的共享状态就被干扰了。
解决办法有两个:
- 更新配置时,先在本地赋值给临时变量,最后一次性替换引用;
- 涉及多步读改写,就用
asyncio.Lock()保护临界区。
lock = asyncio.Lock() async def update_config(new_cfg): async with lock: cfg = new_cfg注意这里用的是 asyncio 专用锁,不是threading.Lock。两者作用域完全不同,asyncio.Lock 只对同一个事件循环里的协程生效。
5.5 嵌套事件循环与多线程的冲突
还有一次,我们想在一个 Flask 接口里跑 asyncio 代码,直接asyncio.run(coro)报错RuntimeError: asyncio.run() cannot be called from a running event loop。后来发现是有个旧模块创建了全局事件循环,导致同一线程里嵌套了。
解决方案通常是用asyncio.run_coroutine_threadsafe把协程丢到另一个线程的事件循环里执行,或者把代码统一改成在线程里创建独立事件循环。如果你用的是 FastAPI,就不用担心这个问题,Starlette/Uvicorn 已经帮我们管理好事件循环,你只需要写正常的 async 函数即可。
6. 协程的正确打开方式:爬虫、量化执行器与微服务网关
6.1 爬虫:协程的天然主战场
爬虫几乎就是 I/O 密集型程序的天花板。一个页面从发出请求到拿到响应,90% 的时间在等网络,协程让这些等待全部重叠起来。我的爬虫架构通常分三层:
- 调度层:用列表或
asyncio.Queue管理 URL 任务队列; - 并发层:用
asyncio.Queue+ 一组 worker 协程,每个 worker 循环从队列取 URL; - 限速层:
asyncio.Semaphore控制最大并发数,必要时加asyncio.sleep做访问间隔。
async def worker(q, sem): while not q.empty(): url = await q.get() async with sem: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: html = await resp.text() # 处理 HTML q.task_done()注意这里整个链路用的都是异步库,没有一处阻塞调用。爬虫并发从几十提升到几百、上千,资源占用却能保持在很低水平,就是协程的价值。
6.2 量化策略执行器:用协程串起多个IO等待
量化的场景里,行情接收、策略计算、订单回报、风控检查,每一路都涉及 IO 等待。把协程用好,可以在一个进程内优雅地编排整套流程。事件循环里挂一个行情订阅协程,一个策略计算协程,一个交易执行协程,通过asyncio.Queue解耦数据流。
行情来了,入队等待计算;计算完,发送信号入队等待执行;执行返回,写日志、更新状态。三路之间的等待全部重叠,系统吞吐量比同步串行高出很多,而且天然避免了多线程里"持仓状态被两个线程同时改"的问题——因为协程切换点是你所有 await 的地方,只要在这些点做好一致性设计,状态管理会清晰得多。
6.3 微服务网关:并发转发与熔断
在微服务架构里,Python 常用来做 API 网关、聚合层或者数据采集服务。这类服务的特点是:一个请求进来,需要并发调后端多个服务,谁先回来都能局部更新页面;有服务延迟了,要有超时熔断;下游要限流。
asyncio 恰好把这些工具都配齐了。用asyncio.gather(return_exceptions=True)并发调多个后端接口,部分失败不影响整体;用asyncio.wait_for做单请求超时;用asyncio.Semaphore控制对下游 QPS。相比每个请求开一个线程,协程方式能支撑的并发量高出一个数量级。
我个人的体会是,从线程模型切换到协程模型,最需要改的不是代码,而是思维。你要把"并发是操作系统帮我抢时间片"变成"并发是我自己安排好什么时候等待、什么时候切换"。await不是补丁,它是程序的上帝视角——每一条 await 都意味着"我知道这里要等,我已经安排好了等待期间别人先跑"。
如果正在学习 or 重构协程项目,建议先拿一个 IO 密集的小功能做试点(比如批量下载、并发查询接口),把事件循环、任务、信号量这几个概念跑熟,再逐渐扩大到完整系统。协程不是万金油,但在 IO 密集型的高并发场景里,它确实是我目前见过的最优雅、最省资源的解法。