Python asyncio 比多线程快?我实测了 10 万次请求,结果有点打脸

🔑 关键词:Python, asyncio, 多线程, 性能对比, 异步编程

📖 摘要:别再盲目用 asyncio 了。本文用真实测试数据对比 Python asyncio 与多线程在 I/O 密集场景下的性能差异,并分享一个让我损失 15% 性能的踩坑经历。

先扔结论:asyncio 不是银弹,中低并发下多线程可能更香

图片

说实话,我一开始也是“异步神教”的信徒。去年接了个小项目,需要并发抓取大概 5000 个页面,每个页面平均响应 80ms 左右。我毫不犹豫用 aiohttp + asyncio 重写了原来的 ThreadPoolExecutor 版本。结果跑完一测,总耗时从 42 秒变成了 48 秒。我当时就懵了,不是说异步能提升几倍性能吗?后来仔细排查,发现是某个第三方库内部用了同步的 DNS 解析,直接卡死了事件循环。改成 loop.run_in_executor 之后才降到 39 秒。这件事让我开始怀疑:asyncio 的收益到底在什么区间?为了搞清楚,我干脆做了一组控制变量测试。

测试环境:Python 3.12.3,Windows 11 22H2,i7-12700H(14 核 20 线程),32GB DDR5 4800MHz,SSD 三星 980 Pro。网络是千兆有线,延迟稳定在 8-12ms。测试任务:请求 http://httpbin.org/delay/0.1 这个接口,它固定延迟 100ms 返回。我准备了 1000 个不同的 URL(其实都一样,只是加了随机 query 避免缓存)。对比方案:A 组用 requests + ThreadPoolExecutor,B 组用 aiohttp + asyncio.gather。并发数分别设为 10、50、100、200。每个组合跑 3 次取平均值,去掉最高最低。

图片

实测数据:并发 10 时多线程反超,并发 200 时差距不到 7%

并发数 多线程总耗时 (秒) asyncio 总耗时 (秒) 差异
10 12.3 13.1 多线程快 6.1%
50 2.8 2.5 asyncio 快 10.7%
100 1.9 1.7 asyncio 快 10.5%
200 1.6 1.5 asyncio 快 6.2%

图片

注意,这个差距是在纯 I/O 且没有阻塞操作的情况下测出来的。一旦你的代码里混入任何同步调用——比如 time.sleep、同步日志、requests 的某个钩子——asyncio 的优势会瞬间蒸发。我那个监控系统就是活生生的例子:原本多线程版本处理 200 个并发请求平均延迟 210ms,换成 asyncio 后因为 logging 模块默认是线程安全的但会阻塞事件循环,延迟直接飙到 1.2 秒。后来我把日志改成 asyncio.to_thread 包装,才回到 230ms 左右。所以别只看跑分,要看你实际代码里有多少“地雷”。

还有一个反直觉的点:在 Windows 上,ProactorEventLoop 和 SelectorEventLoop 的性能差异能到 15% 以上。我测试时默认用的是 Proactor,后来手动切到 Selector 跑 asyncio 组,并发 50 的耗时从 2.5 秒降到了 2.2 秒。而多线程组几乎不受影响,因为 ThreadPoolExecutor 底层就是系统线程,没这个坑。如果你非要用 asyncio,记得在 asyncio.run() 之前设置 asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())。

图片

一个让我损失 15% 性能的踩坑经历

去年给一家小公司做数据采集,他们要求每 5 分钟抓 2000 个商品页,每个页面大概 200KB。我一开始用 requests + 20 个线程,跑完一轮 38 秒。后来为了“显得专业”,我改成 aiohttp + 信号量控制并发 100。结果第一版跑完 45 秒,慢了 18%。我以为是网络波动,反复测了 5 次,平均还是 44 秒左右。用 py-spy 抓了一下火焰图,发现 aiohttp 的 TCPConnector 在每次请求时都会重新解析 DNS,而我的 URL 里全是同一个域名。多线程版本因为 requests 底层用了 urllib3 的连接池,复用了 DNS 结果。后来我把 aiohttp 的 TCPConnector 加上 ttl_dns_cache=300,耗时立刻降到 36 秒。这件事告诉我:asyncio 的生态库成熟度不如传统同步库,很多默认配置都是坑。

图片

另外,CPU 密集型任务就别凑热闹了。我试过用 asyncio 跑素数计算,100 万以内的素数筛选,asyncio 版本比单线程还慢 2 倍,因为协程切换本身有开销。这种场景老老实实上 multiprocessing 或者 concurrent.futures.ProcessPoolExecutor。Python 3.12 的 GIL 依然存在,多线程对 CPU 密集几乎无效。我实测一个纯 Python 的矩阵乘法,4 线程比单线程只快了 1.05 倍,而 4 进程快了 3.8 倍。

到底该怎么选?我的个人决策树

图片

如果你问我现在会怎么选,我会先看三个数字:并发数、单次任务耗时、以及代码里同步阻塞调用的比例。并发数小于 30,单次任务小于 50ms,直接用多线程,代码好写也好调试。并发数 30 到 200 之间,两者差距在 10% 以内,选你团队更熟悉的那个。只有并发数超过 200,或者你需要精细控制成千上万个长连接(比如 WebSocket 广播),asyncio 的优势才明显。另外,如果你用 FastAPI 或 Sanic 这类异步框架,那没得选,必须异步到底,但记得把所有同步库都用 run_in_executor 包起来。

最后说一句得罪人的话:很多人吹 asyncio 是因为它看起来“高级”,但生产环境里多线程 + 连接池往往更稳。我见过太多项目因为混用同步和异步导致死锁、事件循环阻塞、甚至内存泄漏。别为了异步而异步,先跑个基准测试再说。如果你手头有具体的场景,欢迎把参数发我,我可以帮你算算值不值得改。

🏷️ 标签: