下面对比 Python asyncio 的 await 和 Node.js 中 await(基于 Promise)的异同,帮助你建立更精确的心智模型。
总览一句话
两边语法都写成 await expr,但:
- Node.js
await等的是一个 Promise(或能被转成 Promise 的 thenable)。 - Python
await等的是一个“可等待对象”(awaitable),核心是协程对象/Task/Future;调度由asyncio事件循环驱动。 语法相似,底层抽象与生命周期管理却不完全一样。
核心差异速览
| 维度 | Python asyncio | Node.js |
|---|---|---|
| await 可等待的东西 | 协程对象、asyncio.Task、Future、实现 __await__ 的自定义对象 | Promise 或 thenable |
| 启动方式 | 创建协程对象后“惰性”不执行,直到第一次被 await/封装进 Task | 调用 async function 立即返回已开始执行的 Promise |
| 并发单元 | 协程 + Task(包装可独立调度) | Promise(执行体在函数调用时已开始) |
| 并发组合 | asyncio.gather, TaskGroup, wait | Promise.all, Promise.race, allSettled, any |
| 取消 | task.cancel() 发送 CancelledError 进协程;需要协程在可中断点让出 | promiseAbortController / 人为包装;Promise 本身没有原生取消(AbortController/第三方模式) |
| 调度粒度 | 基于显式 await/loop.call_soon;没有“微任务队列”概念 | libuv + 宏任务阶段 + 微任务(Promise jobs、process.nextTick()) |
| 异常传播 | 协程里抛出异常,await 时重新抛出;未消费 Task 异常可触发日志 | Promise reject,在 await 处抛出;未处理的 rejection 触发 unhandledRejection 事件 |
| 重复等待 | 同一个协程对象不能被多次并行 await;需先封成 Task | 同一个 Promise 可以被多次 await/then |
| 创建时机差异 | 协程体尚未执行(lazy),直到调度 | async 函数一调用立即执行到第一个 await |
| 顶层 await | 交互式/3.11+ REPL 或特殊模式;脚本里用 asyncio.run() 包装 | ESM 模块原生支持顶层 await |
| 自定义 await 行为 | 可实现 __await__ 协议 | 通过 thenable(实现 then 方法) |
| 优雅关闭 | 事件循环可收尾所有 Task (TaskGroup) | 进程退出前 pending Promise 需手动管理 |
| 并发隔离 | TaskGroup 提供结构化并发,异常会“汇聚” | 原生缺少结构化并发(可用库 “promise-pool” 等) |
| 性能调度侧重点 | Python 侧等待点少、上下文切换开销更低于线程 | JS 事件循环内置 I/O 模型,配合微任务保证立即清算 Promise 回调 |
1. “启动时机” 是最容易混淆的点
Python:
async def coro():
print("step1")
await asyncio.sleep(0)
print("step2")
c = coro() # 此时不会打印,函数体尚未开始
await c # 这里才开始执行Node.js:
async function fn() {
console.log('step1');
await Promise.resolve();
console.log('step2');
}
const p = fn(); // 这里立即打印 step1;开始执行到第一个 await
await p; // 只是等待其完成2. 多次等待行为差异
Python 不允许并发“二次 await”同一个协程对象(因为协程是一次性状态机):
c = coro()
await c
await c # 第二次会抛 RuntimeError: cannot reuse already awaited coroutine要多处等待,用 task = asyncio.create_task(coro()),然后多个地方 await 同一个 Task(结果共享)。
Node.js:
const p = fn();
await p;
await p; // OK:Promise 可多次等待;状态已缓存3. 并发模式对比
Python(结构化并发):
async def a(): ...
async def b(): ...
results = await asyncio.gather(a(), b(), return_exceptions=False)Node.js:
const [ra, rb] = await Promise.all([a(), b()]);差异点:Python 3.11+ TaskGroup 会在一个任务失败后取消其它未完成任务,更偏向结构化并发安全;Node.js Promise.all 在其中一个 reject 时立即 reject,其它仍继续执行(无法自动取消,除非手动接入 AbortController/自定义逻辑)。
4. 取消机制
Python:
task = asyncio.create_task(long_job())
task.cancel()
try:
await task
except asyncio.CancelledError:
passNode.js(没有内建取消)常见写法:
const ac = new AbortController();
longJob({ signal: ac.signal });
ac.abort(); // 由 longJob 内部监听 signal 退出或使用第三方库(e.g. p-cancelable),本质是模式约定。
5. 微任务 / job 阶段差异
Node.js 有“微任务队列”(Promise 回调、queueMicrotask、process.nextTick),保证在一次事件循环 tick 内尽快清空。
Python asyncio 没这个显式概念:协程切换只发生在:
await一个真正挂起(Future 未完成)的对象- 显式
await asyncio.sleep(0)/await asyncio.yield_now()(3.11+) - 循环调度下一批 ready 回调
因此 Node.js 在 .then() 链里的 “同步 then” 更即时;Python 中“回到调度点”需要一次 loop 迭代。
6. 自定义可等待 vs thenable
Python:
class MyAwaitable:
def __await__(self):
yield from asyncio.sleep(1).__await__()
return 42
result = await MyAwaitable()Node.js:
const thenable = {
then(resolve) {
setTimeout(() => resolve(42), 1000);
}
};
const v = await thenable;两者都能被 await;协议不同:Python 用 __await__ 返回迭代器;Node 用 then 接口。
7. 错误传播与未处理错误
Python:Task 未被 awaited 且抛异常,会出现 “Task exception was never retrieved” 日志。
Node.js:Promise 未设置 .catch() 会触发 unhandledRejection(未来可能被视为致命错误,配置可变)。
8. 顶层 await
Node.js(ESM):
// 在模块最顶层
const data = await fetch(url);Python 普通脚本不支持直接写顶层 await,常用:
import asyncio
asyncio.run(main())交互模式(REPL / IPython / 3.12+ 某些上下文)可以顶层 await。
9. 性能 & 设计哲学
- Node.js 从底层 I/O(libuv)到语言原生 Promise,成体系;I/O 几乎都 Promise 化。
- Python 原生库里很多 I/O 仍是阻塞式;
asyncio+ 第三方(aiohttp、asyncpg、Bleak)补齐生态;同步/异步世界并存,需要注意不要在协程里做长时间阻塞 CPU 的操作(否则要run_in_executor)。
10. 在你的 BLE 示例中的实际意义
| 需求 | Python asyncio 做法 | Node.js 类比 |
|---|---|---|
| 扫描 + 并行等待超时 | asyncio.wait_for(scanner(), timeout=...) | Promise.race([scan(), timeoutPromise]) |
| 注册通知回调 | await client.start_notify(...) + handler | 事件监听 characteristic.on('data', cb) / Promise 包装 |
| 多任务(心跳、监听、命令) | TaskGroup / gather | 多个 async 函数 + Promise.all,但取消需手动广播 |
11. 容易踩的坑对照
| 场景 | Python 注意 | Node 注意 |
|---|---|---|
| 忘了 await | 协程对象未执行,静默 | Promise 已启动但未处理异常 |
| 重复 await | 协程报错 | OK |
| 阻塞 CPU | 阻塞整个 loop(需 executor) | 同理阻塞事件循环 |
| 取消清理 | 用 try/finally 释放资源 | 监听 abort / finally 关闭句柄 |
| 竞争条件 | 需要 asyncio.Lock | 用 Mutex/自写锁/原子引用 |
12. 心智模型对齐
Python:
协程 = 暂停/恢复的可迭代状态机;await = 把当前协程挂起,把控制权交还事件循环;事件完成后恢复。
Node.js:
Promise = 最终值或错误的占位符;await = 暂停当前 async 函数,安排后续逻辑作为微任务回调;Promise settled 后恢复。
13. 一个“同构”示例对照
Python:
async def fetch_resolution(client):
data = await client.read_gatt_char(CHAR_UUID)
return parse(data)
async def main():
# 并发两个操作
r1, r2 = await asyncio.gather(fetch_resolution(c1), fetch_resolution(c2))Node.js:
async function fetchResolution(client) {
const data = await client.readChar(CHAR_UUID);
return parse(data);
}
async function main() {
const [r1, r2] = await Promise.all([
fetchResolution(c1),
fetchResolution(c2)
]);
}表面极其相似,内部执行起点 & 取消控制不同。
14. 什么时候不必深究差异?
如果你只是“写一个顺序 await 流程”,两边思维几乎一致。 当你开始需要:
- 可控取消
- 结构化并发(异常传播策略)
- 自定义 awaitable/thenable
- 调度公平性 / yield 点 这些差异就变得重要。
15. 总结要点(记忆版)
- Python 协程是 lazy,Node async 调用即刻执行。
- Python 不能二次并发 await 同一协程;Node Promise 可以多次等待。
- Python 有结构化并发(TaskGroup);Node 需要手动模式。
- 取消:Python 原生,Node 需 AbortController 模式。
- 微任务:Node 有明显“Promise job”阶段;Python 没同级概念。
- 自定义:Python
__await__,Node thenable。