不是所有项目都适合异步
很多团队看到异步编程能带来数倍甚至数十倍的性能提升,就迫不及待地想将现有项目重构。但第一个需要明确的判断是:你的项目瓶颈真的在 I/O 等待上吗?
如果你维护的是一个数据处理后台,核心逻辑是复杂的数值计算或矩阵运算,CPU 占用率长期在 80% 以上,那么引入 asyncio 不仅不会带来性能提升,反而会因为事件循环的调度开销而略微降低效率。异步的甜头,主要尝在那些“大部分时间都在等”的场景里:等网络响应、等数据库查询、等文件读写、等远程 API 返回。
一个典型的误判场景是 Web 后端服务。如果这个服务只是简单地从数据库查一条记录然后返回,那么它确实是 I/O 密集的。但如果服务内部有大量的数据聚合、序列化转换、甚至调用了一些同步的外部 C 库,那么瓶颈就可能转移。我曾见过一个团队,为了追求“现代化”,将整个 Flask 应用用异步方式重写,结果发现整体吞吐量不升反降,排查后发现是 JSON 序列化库和某些同步的数据验证中间件拖慢了整个事件循环。
理解异步的“发动机”:事件循环
从同步思维切换到异步,最大的认知门槛是理解事件循环。它不是魔法,而是一个高效的调度器。
你可以把它想象成一个餐厅里唯一的服务员(单线程)。在同步世界里,这位服务员接到一桌客人点单后,会亲自跑到后厨盯着厨师做菜,菜没好他就一直站在那等,其他桌的客人只能干着急。这就是阻塞。
而在异步世界里,这位服务员聪明多了。他接到 A 桌点单后,把单子交给后厨,然后立刻去 B 桌点单。当后厨喊“A 桌的菜好了”,他再过去上菜。他永远不会“阻塞”在任何一个等待操作上,总是在处理那些“就绪”的任务。这个“喊菜”和“响应”的机制,就是事件循环在底层监听和分派 I/O 事件。
技术上说,当你在协程中写下 await response.read() 时,你其实是在告诉事件循环:“这个网络读操作需要时间,别傻等,先去看看有没有其他协程的 I/O 已经完成了,或者有没有新的任务可以执行。” 控制权就此让出。
核心三要素与常见“翻车”点
围绕事件循环,你需要处理好三个核心元素:协程、任务和可等待对象。很多初期问题都源于对它们的混淆。
- 协程 (Coroutine): 用
async def定义的函数。调用它不会执行代码,而是返回一个协程对象。你必须用await来驱动它,或者把它包装成任务。 - 任务 (Task): 被事件循环调度执行的协程包装器。使用
asyncio.create_task()是并发执行多个协程的关键。 - 可等待对象 (Awaitable): 能放在
await后面的东西,主要包括协程、任务和 Future。
新手最常踩的坑有几个:
第一,忘记写 await。这会导致协程根本没有执行,你的程序看起来什么都没做。现代 IDE 和 Python 3.8+ 的警告能部分缓解这个问题。
第二,在协程里混用阻塞式 I/O。比如用了 requests.get() 或者普通的 time.sleep()。这会阻塞整个事件循环,让所有其他任务“卡住”。正确的做法是使用对应的异步库(如 aiohttp)和 asyncio.sleep()。
# 错误:阻塞事件循环
async def fetch_data():
result = requests.get('https://api.example.com') # 阻塞!
return result.json()
# 正确:使用异步客户端
async def fetch_data():
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com') as response:
return await response.json()
第三,无节制地创建并发任务。以为并发数越高越好,一次性发起一万个网络请求,结果把目标服务器或自己的网络连接池打爆。必须使用信号量 (asyncio.Semaphore) 或类似机制进行限流。
从局部试点到架构演进
不建议一上来就尝试用异步重写整个单体应用。更稳妥的路径是寻找一个边界清晰的、高 I/O 的独立模块进行试点。
试点场景选择:
- 数据抓取或导出任务:需要从大量外部 API 或数据库拉取数据。
- 消息推送服务:需要向成千上万的客户端发送通知。
- 聚合型 API 网关:需要并行调用多个下游服务然后聚合结果。
例如,一个电商后台的“订单报表导出”功能,从同步改造为异步后,耗时从十几分钟缩短到两三分钟,这种立竿见影的效果最能建立团队信心。在试点时,重点关注:
- 依赖的第三方库是否有稳定、高效的异步版本?(如
asyncpg,aiomysql,aiohttp) - 现有代码中与该模块的调用接口是否清晰?能否比较容易地改为
async/await接口? - 团队的调试和监控工具链是否支持异步代码?(例如,跟踪一个请求跨多个 await 点的全链路)
性能调优与资源管理
当你的异步应用跑起来后,下一个阶段是让它跑得稳、跑得快。这涉及到一些精细的控制。
并发度控制:这是最重要的调优参数之一。除了用信号量限制全局并发数,对于数据库、Redis 等连接池,也需要配置合理的最大连接数,避免连接耗尽。
超时与熔断:异步系统因为并发高,一个慢响应会拖住一个工作槽位。必须为每一个外部调用设置超时。
try:
result = await asyncio.wait_for(fetch_external_api(), timeout=5.0)
except asyncio.TimeoutError:
logger.warning("API 请求超时")
# 执行降级逻辑或快速失败
选择合适的事件循环:Python 标准库的 asyncio 事件循环已经足够健壮,但在极端性能场景下,可以考虑使用 uvloop(仅限 Linux/macOS),它能带来显著的性能提升,通常只需一行代码替换:
import asyncio
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
异步与同步世界的共存
在过渡期,你的项目必然是一个同步与异步共存的“混合体”。处理好两者的边界至关重要。
规则是:同步代码可以调用异步代码,但必须通过特殊方式;异步代码应尽量避免调用阻塞的同步代码。
| 场景 | 正确做法 | 注意事项 |
|---|---|---|
| 在同步函数(如 Flask 视图)中调用异步函数 | 使用 asyncio.run() (仅适用于简单脚本)或创建新事件循环运行。更好的架构是使用桥接层(如将异步逻辑封装为独立服务)。 |
注意 asyncio.run() 不能嵌套调用。在 Web 框架中滥用会导致为每个请求创建新循环,开销巨大。 |
| 在异步协程中需要运行一个阻塞的 CPU 或 I/O 函数 | 使用 asyncio.to_thread()(Python 3.9+)将其放到一个单独的线程池中运行,避免阻塞事件循环。 |
线程池大小需要管理,过多的线程会抵消异步的优势。 |
| 老式同步库没有异步版本,但又必须在异步流程中使用 | 将其包装在 to_thread 中,或者考虑是否值得为其封装一个简单的异步 HTTP 服务作为替代。 |
这是技术债,应在项目计划中标识出来,寻求长期替代方案。 |
一个常见的架构模式是“异步核心,同步外壳”。即用 FastAPI 或 Sanic 这类原生支持异步的框架作为 HTTP 入口,内部业务逻辑全部采用异步编写。而对于一些旧的、无法改动的同步管理脚本或定时任务,它们通过消息队列(如 Redis)或 RPC 来调用异步核心的服务,从而避免直接耦合。
给团队的建议
引入 asyncio 不仅是技术升级,也是工作方式的调整。
- 统一学习曲线:组织内部小范围分享,用具体的试点项目代码作为教材,重点讲透事件循环模型和常见错误模式。
- 建立代码规范:明确何时使用
create_task,如何设置默认超时,如何记录异步上下文中的日志(确保请求 ID 能跨 await 传递)。 - 升级工具链:确保团队的 IDE、调试器、APM(应用性能监控)工具都支持异步代码的跟踪和剖析。
- 谨慎评估:对于新项目,如果确定是 I/O 密集型,可以优先选择异步架构。对于存量项目,采用“外围蚕食,核心渐进”的策略,优先在新增的、边界清晰的模块中使用。
归根结底,从同步到异步的转变,目标不是追求最时髦的技术,而是为了解决真实的性能瓶颈和资源利用率问题。当你看到那些原本需要漫长等待的批处理任务在几分钟内完成,当你服务的 QPS 因为更高效地利用 I/O 等待时间而大幅提升时,你就会明白,正确地打开 asyncio 这扇门,背后是一个更高效、更响应迅速的 Python 世界。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/153/