0.7 异步与并发基础

概述

你已经非常熟悉 JS 的异步模型:单线程事件循环、Promiseasync/awaitPromise.all。Python 的 asyncio 提供了几乎同构的 async/awaitTaskgather,底层也是单线程事件循环。最大的概念差异是 GIL(全局解释器锁)和 Python 多线程的"假并行"。这一章帮你把 Python 异步的心智模型接上。

JS/TS ↔ Python 对照表

概念

JavaScript / Node.js

Python (asyncio)

说明

异步函数

async function

async def

语法几乎一致

等待

await promise

await coroutine

语法一致

异步对象

Promise

Coroutine / Task / Future

Python 分得更细

创建任务

Promise.resolve() / 隐式

asyncio.create_task()

需显式创建 task

并发等待全部

Promise.all([...])

asyncio.gather(*tasks)

对应关系

并发等待首个

Promise.race([...])

asyncio.wait(..., return_when=FIRST_COMPLETED)

更啰嗦

等待列表

for await (const x of it)

async for x in it:

异步迭代

事件循环

自动(Node 内置)

asyncio.run() 启动

Python 要手动开跑

顶层 await

支持(ESM)

无(需在 async 函数内)

见下文

定时器

setTimeout

asyncio.sleep

需 await

错误传播

和同步类似

except 捕获(task 需 gather)

见下文

回调转 Promise

new Promise(...)

loop.run_in_executor / future

见下文

阻塞与事件循环

主线程不能被阻塞

同步阻塞会卡住整个 loop

同理

多线程

Worker Threads

threading / concurrent.futures

受 GIL 限制

多进程

worker_threads / cluster

multiprocessing

CPU 密集任务用多进程

单线程限制

JS 单线程

GIL(CPython 解释器锁)

概念相近但机制不同

核心概念

1. JS 事件循环 vs Python asyncio 事件循环

两者都是单线程 + 事件循环 + 协作式并发模型:

  • Node.js:事件循环由运行时自动管理,你写代码不需要显式启动它。

  • Python:事件循环需要显式启动,用 asyncio.run() 作为入口。

// Node.js:事件循环自动跑,直接 await
async function main() {
  await fetch("https://api.example.com");
}
main();
# Python:必须用 asyncio.run() 启动事件循环
import asyncio

async def main():
    await some_async_call()

asyncio.run(main())   # 入口:启动事件循环,运行 main,再关闭

2. async/await 相同点与差异

语法高度相似,但有几个关键差异:

async function fetchData(url) {
  const res = await fetch(url);
  return res.json();
}
async def fetch_data(url):
    res = await fetch(url)     # 语法一致
    return res.json()

差异点:

  1. 调用 async 函数不执行,返回 coroutine 对象:在 Python 中,fetch_data(url) 只是创建协程对象,必须 await 或用 asyncio.run 执行才会真正运行。JS 里调用 async 函数会立即开始执行并返回 Promise。

async def hi():
    print("hi")

hi()                 # 只创建了 coroutine,什么都不打印!还会警告
asyncio.run(hi())    # 真正执行
  1. 没有顶层 await:Python 的 await 只能出现在 async def 函数内,模块顶层不能 await

3. Promise vs Task / Future

JS 的 Promise 在 Python 里对应几个层次的概念:

JS

Python

说明

Promise(待完成的值)

Future

结果占位符

async 函数返回值

Coroutine

未执行的协程对象

已调度的协程

Task

asyncio.create_task(coro) 包装后的对象

// JS:async 调用立即返回 Promise 并开始执行
const p = fetchData(url);   // 已开始执行
await p;
# Python:需要手动创建 task 才能"后台"执行
import asyncio

async def fetch_data(url):
    ...

task = asyncio.create_task(fetch_data(url))  # 调度执行,类似启动一个 Promise
result = await task                           # 等待结果
  • asyncio.create_task(coro) 相当于"把一个协程调度到事件循环后台执行",类似 JS 里 Promise 的"立即执行"语义。

  • await coroutine 直接等待,是串行;create_task 后统一 await 才是并发。

4. Promise.all vs asyncio.gather

// JS:并发请求多个
const [a, b, c] = await Promise.all([
  fetch(url1),
  fetch(url2),
  fetch(url3),
]);
# Python:asyncio.gather 并发
a, b, c = await asyncio.gather(
    fetch(url1),
    fetch(url2),
    fetch(url3),
)
  • gather 接收多个 awaitable(协程或 task),并发执行并保持顺序返回结果。

  • Promise.all 里某个 reject 会立即整体 reject;gather 默认也会传播异常,但可用 return_exceptions=True 收集而不中断。

results = await asyncio.gather(
    fetch(url1), fetch(url2),
    return_exceptions=True,   # 类似 Promise.allSettled 的行为
)

5. asyncio.run 入口

asyncio.run(main()) 是 Python 异步程序的唯一入口,它做三件事:创建事件循环、运行 main、关闭循环。

// JS:直接写即可
(async () => {
  await doWork();
})();
# Python
import asyncio

async def main():
    await do_work()

if __name__ == "__main__":
    asyncio.run(main())

6. 阻塞 vs 非阻塞

和 JS 一样,事件循环里绝不能放阻塞调用,否则整个循环卡死:

// JS:错误示范,阻塞事件循环
const fs = require("fs");
fs.readFileSync("/big/file");   // 同步阻塞,卡住循环
# Python:错误示范,阻塞事件循环
import time
import asyncio

async def main():
    time.sleep(5)          # 同步 sleep,阻塞整个 loop!应该用 await asyncio.sleep(5)
  • await asyncio.sleep(5) 代替 time.sleep(5)

  • 无法避免的阻塞操作(CPU 密集、同步 IO)用 loop.run_in_executor 丢到线程池。

7. GIL 概念初识(对比 JS 单线程)

  • JS 单线程:整个 JS 运行时(一个事件循环)只用一个线程,但 IO 交给底层(libuv 线程池),所以异步 IO 不阻塞。

  • Python 的 GIL(全局解释器锁):CPython 解释器在同一时刻只允许一个线程执行 Python 字节码。即使开了多线程,CPU 密集计算也不会真正并行。

关键区别:

JS

Python

语言线程模型

单线程(Worker 另算)

可多线程,但受 GIL 限制

IO 密集任务

事件循环 + libuv

asyncio / 多线程都能用

CPU 密集任务

Worker Threads / 子进程

多进程 multiprocessing

真正的并行

多 Worker

多进程(线程受 GIL 限制)

结论:IO 密集(网络请求、文件读写)用 asyncio;CPU 密集(计算)用 multiprocessing,别指望多线程提速。

代码示例

并发抓取多个 URL

// JS
async function fetchAll(urls) {
  const results = await Promise.all(
    urls.map(u => fetch(u).then(r => r.json()))
  );
  return results;
}
# Python
import asyncio

async def fetch_all(urls):
    results = await asyncio.gather(
        *[fetch_json(u) for u in urls]
    )
    return results

异步定时器 vs setTimeout

// JS
await new Promise(r => setTimeout(r, 1000));
# Python
await asyncio.sleep(1)

带异常处理的并发

// JS
const results = await Promise.allSettled(urls.map(fetch));
# Python
results = await asyncio.gather(
    *[fetch(u) for u in urls],
    return_exceptions=True,
)

最佳实践

  • 异步程序的入口统一用 asyncio.run(main()),并加 if __name__ == "__main__": 守卫。

  • 要并发就 asyncio.gather(或 create_task),不要用 for 循环逐个 await(那是串行)。

  • 异步函数内只用 await asyncio.sleep,别用 time.sleep

  • IO 密集用 asyncio,CPU 密集用 multiprocessing,别用多线程做 CPU 计算。

  • 大量并发请求配合 asyncio.Semaphore 限流(类似 JS 里的并发限制)。

  • 记不住 asyncio.run 时,优先用现代 HTTP 库(httpx)自带的 async client,能少踩很多坑。

前端开发者常见陷阱

  1. 调用 async 函数忘了 await/runfetch_data(url) 不会执行,只是创建 coroutine,还伴随 RuntimeWarning: coroutine was never awaited

  2. 忘记 asyncio.run() 入口:没有事件循环就 await,报 SyntaxError(模块顶层)或 RuntimeError

  3. 顶层 await 不存在:Python 模块顶层不能写 await,必须包进 async def

  4. for 串行而非 gather 并发for u in urls: await fetch(u) 是串行,性能差一个数量级。

  5. 阻塞调用卡死 looptime.sleep、同步 requests.get、大量 CPU 计算都会卡住事件循环。

  6. Task 异常被静默吞掉create_task 的任务若不被 await,其异常可能被忽略(JS 的 unhandled rejection 类比)。

  7. 混用同步库和异步库requests(同步)和 asyncio 不兼容,要用 httpx 的 async 接口。

  8. 以为多线程能加速 CPU 计算:GIL 使多线程对 CPU 密集任务无效,要用多进程。

  9. gather* 解包忘了asyncio.gather(*tasks) 需要 * 展开列表,直接传列表会报错。

  10. 在 Jupyter/已有循环里再跑 asyncio.runasyncio.run 不能在已有运行中的事件循环里调用。

总结

  • Python 的 asyncio 和 JS 事件循环是同一套心智模型:单线程 + 协作式并发。

  • async def / await 语法几乎一致,但调用 async 函数不执行,必须 await 或用 asyncio.run

  • Promise.allasyncio.gathercreate_task 对应"后台启动一个 Promise"。

  • 事件循环里绝不能阻塞;IO 密集用 asyncio,CPU 密集用多进程。

  • GIL 类比 JS 单线程:它让 CPython 多线程无法真正并行 Python 代码,理解它才能选对并发方案。