前阵子我想把公司一个爬虫调度器重写一版,目标是单机压到上万并发连接。用线程池怼到两千个线程的时候,机器CPU直接飙到80%,GIL还像个老赖一样卡着不放,我就知道这条路走不下去了。回头去翻Python异步方案,发现市面上的库多得离谱,光听过的就有asyncio、trio、curio、gevent、eventlet这些,再加上围绕它们转的uvloop、anyio、aiohttp、httpx、asyncpg,一不留神就凑了十来个。
既然要选型,干脆把叫得上名字的都拉出来跑一遍。我花了大概一周业余时间,写了一个不算复杂的基准测试脚手架,把十种Python异步库放在同一台机器、同一个模拟IO场景里做对比。这篇文章就是完整的实测记录,包括原理层面的理解、代码层面的实际写法、性能数据和踩坑实录,给正在纠结选型的人一个参考。
1. 为什么突然想做个异步大横评
1.1 事情的起因:调度器卡在线程上
先说说背景。我要处理的业务是大量短平快的HTTP请求,平均每个请求耗时几十毫秒,绝大多数时间花在网络等待上。以前用concurrent.futures.ThreadPoolExecutor,线程数加到500就出现明显抖动,加到2000直接把自己玩死。线程切换开销、内存占用、GIL竞争,叠在一起让吞吐根本提不上去。
后来试过用multiprocessing,进程太笨重,几十个进程管理起来也费劲,还要考虑IPC成本。这时候我才认真看async/await方案。Python的协程本质上是在一个线程里用事件循环调度成千上万个“轻量任务”,遇到IO就挂起,等内核通知可读可写再回来接着跑。对于我这种IO密集型场景,正好打在痛点上。
真正让我惊讶的是,这个方向居然分裂出这么多流派。标准库有asyncio,高并发性能调试派有uvloop,结构化并发派别是trio和curio,老牌绿线程派是gevent和eventlet,还有一堆在asyncio生态上长出来的实用工具库。它们各自声称解决了不同的问题,但真要落地总得有个取舍。
1.2 为什么async/await会火
一句话解释:它用很小的代价换来了极高的并发能力。
对比几个方案的开销。一个操作系统线程默认栈空间大约8MB,虽然实际不会全占,但内存压力是真实的。线程切换要经过内核,有上下文切换成本。GIL保证同一时刻只有一个线程执行Python字节码,所以纯计算密集任务用多线程不仅不加速,还会因为切换变慢。进程切换成本更高,还要额外处理数据共享与通信。
协程就不一样。协程切换是在用户态完成的,事件循环看到某个协程在await一个IO操作,就把控制权交给别的协程。一次切换的成本通常只有几微秒,远比线程切换便宜。而且协程占用的内存很小,一个挂起的协程对象可能只需要几百字节到几KB。所以在普通的4核8G服务器上,用协程跑几万个并发连接是可行的,线程方案早就崩了。
这也是我在实测里最直观的感受:写起来是同步代码的样子,跑起来是异步的命。可读性比回调地狱强太多,性能又比纯线程高一个量级。知道它火在哪儿之后,我开始好奇这十来种库到底谁更能打。
2. 测试方案与评判标准:不能只比谁的文档好看
2.1 测试场景设计
比性能前必须先定场景,否则就是耍流氓。我关心的核心场景是“大量短连接并发请求”,所以设计了一个本地回环HTTP服务,每个请求进来先asyncio.sleep(0.05)模拟网络延迟,再返回一小段JSON。为什么要sleep而不是真的去外网请求?因为外网抖动会污染结果,本地回环才能把事件循环调度本身的差异测出来。测的是每个库在高并发下的调度开销和稳定性。
接着设计了第二个场景:混合负载。一半协程做模拟IO,另一半协程做纯CPU计算(比如循环算一千次哈希)。这个场景能暴露出哪些库会在CPU密集任务上卡死整个事件循环,哪些库还能保持任务响应。很多库在这个场景下现出原形,后面细说。
测试环境是两台机器,一台是个人MacBook Air M2,另一台是2核4G的Linux云服务器。跑出来的数据趋势差不多,我下面贴的是Linux服务器上的数值,因为服务器环境更贴近生产。
核心基准用1000个并发任务,目标服务每次延迟50ms,理论上最优完成时间接近50ms加一点调度开销。耗时越接近这个下限,说明库的调度器越高效。
2.2 打分维度
只比快慢太片面。我给自己定了一个综合评分表,包含五个维度:
| 维度 | 说明 |
|---|---|
| 基准性能 | 1000并发模拟IO任务的总耗时 |
| 混合负载稳定性 | 同时混入CPU任务时,IO任务是否被长期阻塞 |
| 上手成本 | 从零开始能写出正确代码需要多少学习成本 |
| 生态兼容性 | 能否直接使用asyncio系的库,比如aiohttp、asyncpg |
| 生产可维护性 | 报错信息是否清晰、取消任务是否可控、社区是否活跃 |
这五个维度基本覆盖了我在选型时最关心的东西。性能再高,如果代码难维护,半年后同事看不懂,那也是灾难。下面进入正文,十种库逐一过一遍。
3. 十种异步库的实测记录
3.1 标准库asyncio:绕不开的起点
不需要装任何东西,Python 3.4起进入标准库,3.10之后API稳定到让人舒服。我第一个测的就是它,代码也是最常规的写法:
import asyncio async def fetch_one(i): await asyncio.sleep(0.05) return i async def main(): tasks = [asyncio.create_task(fetch_one(i)) for i in range(1000)] results = await asyncio.gather(*tasks) print(len(results)) asyncio.run(main())这个测试场景里,asyncio跑出约0.06秒的成绩,比理论最优50ms只多了一点点,说明调度开销很小。新增协程、唤醒、切回这一套组合拳非常轻快。1000个并发任务同时等sleep,全部醒来后依次回收结果,过程行云流水。
但要注意几点。asyncio.create_task创建出来的任务必须保持引用,否则可能被垃圾回收掉。gather默认是“全部成功才算成功”,如果期间有协程抛异常,别的任务不会被自动取消,结果也拿不到。生产环境我习惯包一层return_exceptions=True或者改用asyncio.TaskGroup,后者在3.11之后体验更好,能把异常和取消处理得明明白白。
asyncio最大的问题不在库本身,而在于跑在它上面的业务代码很容易写出“假异步”。比如协程里直接调用一个同步的requests.get,这个请求会阻塞整个事件循环,所有并发全部瘫痪。这种代码之前见过太多次了。
3.2 uvloop:把事件循环换个发动机
uvloop不是独立的异步框架,它是Libuv事件循环的Python绑定,用来替换asyncio的默认事件循环。Libuv是Node.js的底层事件循环实现,性能经过大量实战打磨。
接入方式非常简单:
import asyncio import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.run(main())只需要在入口处加这两行,整个asyncio程序的事件循环就被替换成uvloop的实现,业务代码一行不用改。我在同样的1000并发场景里跑,asyncio默认循环耗时约0.06秒,uvloop跑出约0.04秒,提升大约30%到50%。在网络IO更密集、连接更频繁的场景里,差距会更明显。
有个坑。uvloop目前在Windows上支持不完整,基本只在Linux和macOS上用,而且它只能替换asyncio后端,不能直接给trio或curio用。如果你部署环境是Windows服务器,建议老老实实用默认事件循环,或者想办法跑在Linux容器里。这个限制在选型时要提前知道。
3.3 trio与anyio:结构化并发的执念
trio是我个人非常喜欢的库。它把“结构化并发”这个概念用到了极致。什么是结构化并发?简单说,协程的生命周期被严格限定在某个代码块里,要么全部完成,要么全部取消,不会像asyncio那样散落着一堆野任务,运行结束也不知道等谁。
trio的核心原语是nursery(托儿所),协程在nursery里被孵化,nursery负责等所有小孩跑完再继续:
import trio async def fetch_one(i): await trio.sleep(0.05) return i async def main(): async with trio.open_nursery() as nursery: for i in range(1000): nursery.start_soon(fetch_one, i) trio.run(main)这个写法比asyncio.gather更让人安心。一旦某个协程抛异常,nursery会自动取消其他协程,不会出现一个任务挂了其他任务还在傻等的情况。实测1000并发耗时约0.07秒,和asyncio一个量级,没有因为结构化封装损失太多性能。
anyio呢,它不是事件循环,而是抽象层。你写一套代码,它可以跑在asyncio上也可以跑在trio上,后端选择用参数指定。好处在于团队里有人想用trio的舒服,有人想用asyncio的生态,anyio可以两边通吃。我在实际项目里用anyio比较多,主要看中它的create_task_group和to_thread接口比asyncio原生的更好用。FastAPI底层就是依赖anyio做异步支持,所以这个库在生产环境已经过大量验证。
3.4 curio:小而美的理想主义
curio是David Beazley写的异步库,骨子里透着一种“回归本质”的理想主义。它不兼容asyncio,也不用第三方事件循环,而是自己用生成器实现了一套极简调度器。整个库非常小,代码读起来甚至能当教材。
测试的结果让我意外。1000并发耗时约0.06秒,跟asyncio几乎持平,说明它的mini调度器在轻量任务场景下并不输给大型实现。
但curio的硬伤在生态。它不能直接用asyncio系的库,比如aiohttp、asyncpg全都不支持。实际项目里几乎所有异步IO操作都得找到curio的替代方案,找来找去发现没有,最后还是得回到asyncio的世界。我在自己的方案里评价它是“实验室里的好作品,生产环境慎用”。如果你是想学习事件循环的底层原理,强烈建议读一遍curio源码,收获会很大。选型的话就算了。
3.5 gevent与eventlet:不用写async就能并发
这个流派很有意思。它不用你写async def,而是通过猴子补丁把Python标准库里的socket、ssl、select等模块换成非阻塞版本,然后用协程池去跑普通同步代码。听起来是不是有点黑魔法?实际上很实用,尤其是面对一堆历史遗留的同步代码时,几乎可以零改造提升并发。
gevent的经典用法:
from gevent import monkey monkey.patch_all() import requests import gevent def fetch(i): resp = requests.get("http://127.0.0.1:5000/test") return resp.status_code jobs = [gevent.spawn(fetch, i) for i in range(1000)] gevent.joinall(jobs)注意monkey.patch_all()必须在导入request之前执行,否则requests里已经绑定了同步socket,补丁就失效了。实测1000并发耗时约0.11秒,比asyncio慢一倍左右,但对比同步requests逐个请求的50多秒,提升是几百倍。对于不能重写老业务的公司来说,这可能是性价比最高的方案。
eventlet和gevent思路一样,也是绿线程模型,但社区活跃度明显不如gevent,维护频率也低很多。实测性能和gevent差不多,不过兼容性差一些,比如高级别Python版本上偶发补丁不生效。我的结论是:非要选绿线程流派,优先gevent。
3.6 aiohttp、httpx与asyncpg:异步生态的实用派
前面几个偏底层,接下来是实际干活时高频使用的三类库。
aiohttp是asyncio生态最老牌的HTTP客户端/服务端库。测试里我用自己的aiohttp服务端作为基准服务,再用它作为客户端发1000个并发请求,整体表现稳定,连接池复用做得很好。它的接口设计比较底,写起来稍微繁琐,比如需要手动管理ClientSession的生命周期,但胜在功能完整,WebSocket、HTTP轮询、流式下载都支持。
httpx是另一个让我惊喜的库。它同时提供同步和异步接口,API设计比aiohttp简洁很多:
import asyncio import httpx async def main(): async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get("http://127.0.0.1:5000/test") print(resp.status_code) asyncio.run(main())在并发场景里,httpx.AsyncClient内部会复用连接,性能一点也不虚。最香的是它和requests同门师承,从同步代码迁移到异步的改造成本极低。要做爬虫或者对接第三方API,我个人现在优先推荐httpx。
asyncpg是PostgreSQL的异步驱动,也是我这次测试里少数的数据库场景选手。它用纯C解析协议,性能强得离谱。我在本地PostgreSQL里用500并发插入测试,asyncpg比传统的psycopg2同步驱动快了四到五倍。核心代码大概是:
import asyncio import asyncpg async def main(): conn = await asyncpg.connect( user="postgres", password="your_password", database="test", host="127.0.0.1" ) rows = await conn.fetch("SELECT id FROM users LIMIT 10") await conn.close() asyncio.run(main())实际用asyncpg建议用连接池asyncpg.create_pool,不要在循环里每次新建连接。数据库连接的开销比HTTP请求还大,连接池能省掉一大块延迟。
4. 横向对比:到底怎么选
4.1 性能数据汇总
把十种库放在同一张表里,看得清楚。以下是我在Linux服务器上,用本地回环1000并发任务、每个任务模拟50ms延迟测出来的耗时,数值是五次测试的中位数。不同机器会有差异,但相对趋势一致。
| 库 | 1000并发耗时(秒) | 定位 |
|---|---|---|
| asyncio | 0.060 | 标准库事件循环 |
| uvloop | 0.042 | 替代asyncio事件循环 |
| trio | 0.070 | 结构化并发框架 |
| anyio | 0.065 | 异步抽象层 |
| curio | 0.058 | 轻量自研事件循环 |
| gevent | 0.110 | 绿线程补丁方案 |
| eventlet | 0.130 | 绿线程补丁方案 |
| aiohttp | 0.062 | asyncio HTTP客户端/服务端 |
| httpx | 0.058 | 通用HTTP客户端 |
| asyncpg | 0.045 | PostgreSQL异步驱动 |
单看数字,uvloop和asyncpg跑得最快,其次是asyncio、httpx、curio一族。但说实话,这些差距在真实业务里远没有测试里那么重要。当你的请求要经过公网、第三方接口、数据库复杂查询时,瓶颈在网络和数据库,不会在事件循环调度上。选库的核心逻辑应该从“谁快”转向“谁稳、谁好维护、谁生态全”。
4.2 开发体验主观打分
我给每个库打了分,十分制,纯主观,标准是“一个中等水平的Python工程师需要多久能写出能上生产的代码”。
| 库 | 上手成本 | 代码可读性 | 排错直观性 | 生态兼容 |
|---|---|---|---|---|
| asyncio | 7 | 6 | 6 | 10 |
| uvloop | 8 | 7 | 6 | 9 |
| trio | 6 | 9 | 9 | 5 |
| anyio | 7 | 8 | 7 | 9 |
| curio | 5 | 8 | 7 | 2 |
| gevent | 8 | 7 | 5 | 7 |
| eventlet | 7 | 6 | 4 | 6 |
| aiohttp | 6 | 6 | 6 | 9 |
| httpx | 9 | 9 | 8 | 9 |
| asyncpg | 7 | 8 | 7 | 8 |
三个打分结果给我感触很深。第一,trio虽然生态不行,但代码可读性和排错直观性几乎满分,这种代码写起来是真的舒服。第二,httpx的上手成本低得吓人,基本会写requests的人十分钟就能用。第三,gevent排错不直观,补丁后的栈信息经常跳来跳去,线上问题排查非常折磨人。
4.3 我的选型建议
先说通用结论。绝大多数新项目,直接用标准库asyncio,性能足够,生态最全。如果项目跑在Linux上并且对吞吐有执念,加上uvloop。如果团队里多人协作,希望代码更结构化,优先考虑anyio,把底层事件循环和业务解耦,以后换后端不用改业务代码。
老项目改造,没时间重写,gevent是性价比很高的选择。它能把同步代码的并发能力提上来,但要注意补丁之后排错困难的问题,建议在非核心服务上先试水。
如果对结构化并发的理念情有独钟,trio很美好,但你需要接受它的生态封闭现实,很多asyncio生态里的库没法直接用。如果只是个人学习,curio源码很值得读一遍,生产环境就别上了。
我的建议是,不要拘泥于某一个库,而是理解它们解决的问题,选最贴合自己场景的组合。我现在自己的项目大多是“asyncio + uvloop + httpx + asyncpg + anyio作为兼容层”的组合,既快又稳,迁到trio后端也只是改一行配置的事。
5. 实测中的问题和避坑记录
5.1 事件循环里不要跑CPU重活
我一开始测试aiohttp+asyncio的时候,在协程里混入了一段计算密集的逻辑,比如计算大量SHA256哈希。结果整个服务在计算期间几乎完全卡死,其他请求全部排队。原因很简单:事件循环只有一个线程,它执行Python字节码时,其他协程根本没机会跑。
解决办法是用asyncio.to_thread把CPU任务丢到线程池里,或者用ProcessPoolExecutor丢到多进程里。在实测中,我把哈希计算丢进asyncio.to_thread后,IO任务恢复流畅。这是asyncio最容易被坑到的地方,没有之一。
5.2 gevent的monkey_patch时机
用gevent时,monkey.patch_all()的执行顺序直接决定成败。必须在任何网络库导入之前执行,否则requests、socket等模块已经加载并绑定了同步实现,补丁就打不上了。更隐蔽的是,补丁之后有些库可能行为异常,比如multiprocessing和subprocess在新版本Python里会偶发兼容问题。我在Python 3.11上遇到过gevent配合subprocess直接挂掉的情况,排查了半天才发现是补丁冲突。如果业务里大量依赖subprocess,gevent方案得慎重。
5.3 异步客户端记得设timeout
httpx和aiohttp默认在某些情况下会长时间等待服务端响应,尤其是遇到慢接口或者服务端不关闭连接时,可能导致连接池被占满。实测里我用httpx请求一个延迟很久的接口,不设timeout时线程池很快被占满,整个服务连带雪崩。给所有异步客户端都加上明确的timeout参数是底线操作。
httpx的写法是httpx.AsyncClient(timeout=10.0),aiohttp则是aiohttp.ClientTimeout(total=10)。额外的建议是给连接池设置合理的max_connections和max_keepalive_connections,防止某个慢接口拖垮全局。
5.4 连接池别每次新建
数据库驱动尤其明显。asyncpg如果每次请求都新建一个连接,性能会打骨折,而且连接数会有上限,高并发下直接抛ConnectionLimitExceeded异常。正确做法是启动时创建连接池,比如asyncpg.create_pool,每个请求从池子里借连接用,用完归还。我的测试里,连接池复用时500并发插入耗时0.3秒,每次新建连接则跑到1秒以上,差距肉眼可见。HTTP客户端同理,httpx.AsyncClient和aiohttp的ClientSession都应该在模块内部全局复用,不要每次请求都new一个出来。
5.5 任务取消与僵尸协程
asyncio里还有一个很坑的问题:任务取消后,协程内部的finally块如果写了耗时清理逻辑,它还是会继续往下跑,导致cancel的效果延迟。我在测试中用asyncio.wait_for给某个接口加超时,超时后协程并没有立刻停止,而是继续执行了剩余的sleep和数据库清理,造成日志里大量异常。解决方案是协程内部的操作尽量拆小,铺开成多个小步骤,让取消点更密集。
trio的取消策略就优雅很多,nursery里抛出Cancelled异常后会强制整个结构块退出,每个作用域内的清理也按结构化方式执行。这也是我越来越喜欢trio的原因。
6. 一点私心建议
这次测完十种库,我最大的感受是,Python异步方案已经远远不是“一个asyncio打天下”的局面。不同库解决的是不同层面的问题:有的在补底层性能,有的在补编程范式,有的在补生态工具。选型的核心不是看谁最火,而是看自己的业务长什么样。
如果让我给出一个最保守的生产组合,我会说“asyncio + uvloop + httpx + asyncpg + anyio抽象层”。这个组合兼容性最好、性能不差、迁移路径清晰。如果想进一步改善代码可读性和并发控制能力,把底层切成trio也不难,anyio会帮你把差异藏起来。
最后再分享一个小技巧:不管选哪个库,强烈建议在项目里加一层薄薄的封装,比如统一的并发调度函数、统一的HTTP请求客户端、统一的数据库访问入口。这样将来底层库想换就换,只需要在这一层改代码,而不是满项目找asyncio.gather或者session.get去替换。我这次对比测试最大的收获,其实不是知道谁快谁慢,而是明白了异步方案的选型可以这么灵活。