从 JavaScript 转向 Python 感觉就像搬到了一个路标相同但交通规则不同的城市。语法看起来亲切且熟悉。你看到 async 和 await 就摆在语法里,于是你以为思维模型可以无缝迁移。事实并非如此。JavaScript 中的一个习惯性模式会悄无声息地拖垮你的 Python 性能,既不会导致程序崩溃,也不会记录错误,甚至在快速的代码审查中也难以察觉。
JavaScript 如何教会你“启动并忘记”
在 JavaScript 中,异步函数调用是“急切的”(eager)。一旦你调用它,引擎就会创建一个 Promise,工作立即开始。事件循环已经全速运转起来了。这就是为什么 JavaScript 开发者会自然而然地写出这样的代码:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
在触及任何一个 await 之前,两个网络请求都已经处于进行中状态。第一个 await 会挂起当前函数,直到 fetchUser 解析完成,但 fetchOrders 从上一行开始就已经在后台运行了。当你需要 orders 变量时,第二个请求可能已经完成了。这种模式在 JavaScript 中感觉如此自然,以至于许多开发者甚至不认为这是一种并发技巧。这只是 async 的工作方式。
Python 的惊喜:一个“冰冷”的协程
Python 使用的是不同的契约。在 Python 中调用 async def 函数时,你并没有开始任何工作。你只是接收到了一个协程对象。把它想象成一张写在纸上的食谱。配料已列好,步骤很清晰,但烤箱里什么都没有。除非有东西显式地驱动该协程通过事件循环,否则它将保持静止状态。
陷阱就在这里。一名需要获取用户及其订单的 JavaScript 工程师可能会在 Python 中这样写:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
它看起来是并发的,闻起来也像并发的,但它实际上是完全串行的。
第一行将一个休眠的协程赋值给 user_coro。第二行将另一个休眠的协程赋值给 orders_coro。当执行到达 await user_coro 时,Python 才会终于开始第一个任务并运行到结束。只有在 fetch_user 完成后,解释器才会到达 await orders_coro 并开始第二个任务。你的总执行时间是两个 I/O 操作的总和,而不是其中最长的一个。你并没有并行运行它们,而是带着额外的步骤一个接一个地运行。
为什么这个 Bug 是隐形的
这种性能退化往往会持续数月之久。代码是合法的 Python。它能通过类型检查。它能返回正确的结果。它只是运行速度减半,甚至更慢。因为没有堆栈追踪,也没有警告,工程团队通常会先从其他地方寻找原因。他们会添加 Redis 缓存、升级数据库层级,或者更换托管区域。真正的罪魁祸首是对 await 实际作用的预期存在微妙的偏差。
让 Python 真正并发运行的三种方法
要解决这个问题,你必须告诉 Python 的事件循环立即调度工作。你需要比原始协程更“主动”的东西。你需要一个 Task(任务)。
1. asyncio.create_task
JavaScript 模式最直接的对应方式是将你的协程封装在 Task 中。Task 一经创建就会被调度到事件循环上。它是 Python 中最接近“正在运行中的 JavaScript Promise”的概念。
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
现在,在第一个 await 之前,fetch_user 和 fetch_orders 都已经处于进行中状态。当你到达 await user_task 时,你只会暂停直到该特定 Task 完成,而另一个 Task 会继续运行。如果 fetch_orders 先完成,它的结果会一直保存在 orders_task 中,直到你请求它。
不过要小心。如果你创建了一个 Task 但从未 await 它,Python 会抛出一个关于“已销毁的待处理任务”(destroyed pending task)的错误。你仍然必须收集你的结果。
2. asyncio.gather
如果你有多个协程都需要在继续下一步之前完成,asyncio.gather 可以为你处理这些样板代码。它会在内部将每个协程调度为 Task,并同时等待它们。
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
这种方式简洁且易读。当操作是相互独立的,且你想要用一行代码表达“运行所有这些,然后把每个结果都给我”时,它表现得非常出色。即使底层任务完成的顺序不同,它也会保留返回列表或元组中参数的顺序。
3. asyncio.TaskGroup
Python 3.11 引入了 TaskGroup,为标准库带来了结构化并发(structured concurrency)。你不再需要手动创建任务,而是使用一个上下文管理器,它能确保每一个派生的任务都能正常结束。如果其中一个任务抛出异常,其他任务会自动被取消。
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
这种模式非常适合复杂的业务工作流。它消除了任务变成“孤儿任务”的风险,并将相关操作的生命周期统一管理在一个逻辑范畴之下。如果你的代码库运行在 Python 3.11 或更高版本上,这通常是实现扇出并发(fan-out concurrency)最简洁的架构。
思维模型:await 的意思是“立即运行此项”
核心教训在于语言表达上的差异。在 JavaScript 中,你可以将 await 理解为“与此同时”。你启动一项工作,去做其他事情,只有在需要该值时才暂停。在 Python 中,await 的意思是“驱动此协程运行到下一个挂起点或完成”。如果协程尚未被调度,await 就是执行调度操作的行为。这就是为什么你不能先启动两个原始协程,然后再稍后对它们进行 await。因为在此期间,你并没有给事件循环(event loop)分配任何任务。
可以将 Python 协程类比为生成器函数(generator functions)。调用生成器并不会对其进行迭代。你需要对其进行循环、调用 next(),或者将其传递给消费者。异步编程的工作原理也是如此。asyncio.create_task 就是那个说“现在就把这个放到事件循环中”的消费者。随后的 await 仅仅是在等待完成信号。
一个有帮助的具体习惯是:每当你将一个异步函数调用赋值给变量而没有使用 await 时,问问自己是否已经对其进行了调度。如果等号右侧没有被 create_task、gather 或 TaskGroup 包裹,它就没有在运行。它仅仅是一份放在柜台上的“食谱”。
总结
Python 的异步运行时非常强大,但它要求明确的意图。仅仅因为你调用了一个函数,语言并不会自动开始后台工作。如果你来自 JavaScript 背景,请检查每一个将协程存储在变量中并稍后进行 await 的地方。除非你先将其提升(promote)为 Task,否则你写的只是披着异步外衣的顺序代码。先通过 Task 启动工作,然后再等待结果。这才是将 Python 异步从一个隐形的瓶颈转变为真正的并发工具的方法。
