代码语言

知识点思维导图

25 个知识节点

Python(17) - 异步与并发

读完后,你应能完成以下任务:

  • 绘制“Python(17) - 异步与并发 / 先建立前端锚点”的关键对象与数据流,解释“核心切入点:Python 的 asyncio 就是把 JS 那套"单线程 + 事件循环 + 协作式并发"搬了过来,心智模型几乎可以直接平移。”,并用源码位置、日志或 Trace 标注证据。
  • 为“Python(17) - 异步与并发 / async/await:心智模型直接搬 JS”设计正常与异常输入,验证“这是从 JS 过来最先踩的坑。”,输出首个偏差位置与回归测试结果。
  • 实现“Python(17) - 异步与并发 / 并发:Promise.all → asyncio.gather”的最小代码或配置,检验“注:这里每个 async def 出现的地方,背后都是同一套事件循环在调度,机制和细节在本篇第三、四节讲透。”,输出命令、结果与 Diff,并说明不适用边界。

你在 Node 里写惯了 async/await,第一眼看 Python 的 async def 会觉得"这不就是一模一样吗"。语法层面确实几乎逐字对应,但底下藏着一个 JS 世界里完全不存在的东西——GIL(全局解释器锁)。本篇解决两个问题:怎么用和 JS 心智模型几乎一致的方式写异步代码;以及为什么 Python 有"真线程"却还是不能靠多线程跑满多核 CPU,到底该用 async、线程、还是进程。

一、先建立前端锚点

你在 JS/Node 里这么写 Python 对应写法 说明
async function f() {} async def f(): ... 声明协程函数
await fetch(url) await client.get(url) 等待一个异步结果
Promise.all([a, b]) asyncio.gather(a, b) 并发等多个任务
setTimeout / await sleep await asyncio.sleep(n) 非阻塞等待
顶层 await(或 IIFE) asyncio.run(main()) 启动事件循环的入口
事件循环(V8/libuv) asyncio 事件循环 调度协程的引擎

核心切入点:Python 的 asyncio 就是把 JS 那套"单线程 + 事件循环 + 协作式并发"搬了过来,心智模型几乎可以直接平移。先用这个直觉快速上手,第三节再立刻划清两个世界真正的分水岭——GIL 和"Python 还有真线程/真进程"。


二、async/await:心智模型直接搬 JS

2.1 它和 JS 长得有多像

先并排看。JS:

// fetchUser:异步取用户,返回 Promise
async function fetchUser(id) {
  await sleep(1000)              // 等 1 秒(非阻塞)
  return { id, name: 'imber' }   // 返回值会被包进 Promise
}

async function main() {
  const user = await fetchUser(1) // await 拿到真正的结果
  console.log(user)
}
main()

Python 几乎逐字对应,只是把 function 换成 defasync function 换成 async def

逐行对照:

JS Python
async function f(){} async def f(): ...
await x await x
f() 返回 Promise f() 返回协程对象(coroutine)
await sleep(1000) await asyncio.sleep(1)(单位是秒)
顶层启动 main() asyncio.run(main())

2.2 一个关键差异:调用了不 await,等于没执行

这是从 JS 过来最先踩的坑。JS 里 fetchUser(1) 一调用,函数体就立刻开始跑(只是返回 Promise);Python 里 fetch_user(1) 调用后函数体一行都不会执行,只是造了个协程对象,必须 await 它(或交给事件循环)才会真正跑。

记法:Python 的协程更"懒",没 await 就是一张没兑现的票

2.3 并发:Promise.all → asyncio.gather

串行 await 三次要等三倍时间,要并发就用 gather,和 Promise.all 一一对应:

// 等价 JS
const results = await Promise.all([fetchOne(1), fetchOne(2), fetchOne(3)])

注:这里每个 async def 出现的地方,背后都是同一套事件循环在调度,机制和细节在本篇第三、四节讲透。


三、边界:GIL——JS 世界里不存在的东西

类比建立了直觉,现在必须立刻划清差异,否则你会带着错误预期写出诡异的代码。

3.1 一句话理解 GIL

JS 本身就是单线程语言,你从没纠结过"多线程能不能跑满 CPU",因为根本没有线程这个概念(worker 是独立的隔离环境)。Python 不一样——它有真正的操作系统线程,但有一把全局锁:

GIL(Global Interpreter Lock,全局解释器锁):同一时刻,一个 Python 进程里只允许一个线程在执行 Python 字节码。

意思是:你开 8 个线程跑纯计算,它们不会真的并行占满 8 个核,而是被 GIL 强制"轮流"执行,本质还是一个核在转。这是 CPython(最主流的 Python 实现)的机制,不是语言规范,但你日常用的就是它。

3.2 关键澄清:async 不受 GIL 影响(别搞混)

这是新手最容易脑补错的地方:GIL 限制的是"多线程并行跑 Python 代码",和你刚学的 async/await 是两码事

  • asyncio单线程的协作式并发,本来就只用一个线程,根本没想并行跑计算,所以 GIL 对它毫无影响——它和 JS 的事件循环模型完全一致。
  • async 的价值在于:等 IO(网络、磁盘、数据库)的时候,把这个线程让出去跑别的协程,而不是干等。它解决的是"等待",不是"算得快"。

记法:async 省的是"等的时间",GIL 限制的是"算的并行",两者不冲突也不互相替代

3.3 那 GIL 到底卡了谁

任务类型 例子 该用什么 受 GIL 影响吗
IO 密集 网络请求、读写文件、查数据库 asyncio 或多线程 不受影响(等 IO 时锁会释放)
CPU 密集 大量数学计算、图像处理、加解密 多进程 受影响,多线程没用

结论先记住:IO 密集用 async,CPU 密集用多进程。下一节展开。


四、三种并发手段:async / 线程 / 进程

Python 比 JS 多了"真线程"和"真进程"两个工具,搞清楚什么时候用哪个,是本篇的实战重点。

4.1 asyncio:IO 密集首选(最像 JS)

绝大多数后端场景(接口里查数据库、调下游服务、读 Redis)都是 IO 密集,asyncio 是首选,写法你第二节已经会了。要注意的是别在协程里调用阻塞函数,否则会卡死整个事件循环:

这和 JS 里"别在 async 函数里写同步死循环卡住主线程"是同一个道理。

4.2 多线程:用 threading,但记住 GIL 的限制

线程适合"IO 密集但库不支持 async"的老代码(比如某个只有同步版本的 SDK)。语法上 Python 有 threading,但因为 GIL,多线程跑纯计算不会变快

更省心的写法是用线程池 ThreadPoolExecutor,避免手动管理线程:

4.3 多进程:CPU 密集的唯一正解

要真正跑满多核做计算,必须开多进程——每个进程有自己独立的 Python 解释器和独立的 GIL,互不干扰。接口和 ThreadPoolExecutor 几乎一样,只是把 Thread 换成 Process:

JS 里对应的概念是 worker_threads / cluster——独立隔离、靠消息通信。Python 多进程同理:进程间不共享内存,数据要靠序列化传递,所以传大对象有开销。

4.4 一张表收口选型

场景 选什么 理由
查库、调接口、读文件(IO 密集,库支持 async) asyncio 单线程事件循环,最轻量,最像 JS
IO 密集但只有同步库 ThreadPoolExecutor 等 IO 时 GIL 会释放,线程有效
大量计算(CPU 密集) ProcessPoolExecutor 唯一能绕开 GIL 跑满多核的方式

4.5 混合场景:在 async 里跑阻塞/计算任务

如果你已经在 asyncio 世界里,又必须调一个阻塞函数或重计算,正确做法是把它丢到线程/进程池,用 run_in_executor 包一层,避免卡住事件循环:


五、最容易踩的坑

  1. 调了协程却没 awaitfetch_user(1) 单独写一行不会执行,只会触发 RuntimeWarning: coroutine was never awaited。这是 JS 老手最高频的坑——JS 里一调就跑,Python 里不 await 就是废纸。

  2. 以为 async 能加速计算。async 只解决"等待",不解决"算得慢"。把一堆数学计算塞进 async def 不会快分毫,反而把事件循环卡死。CPU 活儿一律走多进程。

  3. 在协程里写同步阻塞调用time.sleep、同步版的 requests.get、同步数据库驱动,都会把整个事件循环冻住,所有并发瞬间归零。要么换 async 版库(httpxaiomysql 等),要么用 run_in_executor 隔离。

  4. 多进程忘了 if __name__ == "__main__"。不加这层保护,子进程重新导入模块时会再次创建进程,无限递归直接炸掉。这是 Python 多进程的硬性规矩,JS 里没有对应概念,特别容易漏。

  5. 把 GIL 和 async 搅在一起理解。再强调一次:GIL 管的是多线程并行执行字节码,async 是单线程协作式并发,两者根本不在一个维度。面试时这俩混答几乎必挂。

  6. gather 里一个任务报错会牵连其他asyncio.gather 默认任一协程抛异常就整体抛出(其余任务不会自动取消但结果丢失)。要容错可加 return_exceptions=True,让异常作为结果返回而不中断:


六、综合示例:并发抓取多个接口

把本篇知识串成一个真实后端常见场景——并发请求多个下游服务。用 httpx(支持 async 的 HTTP 客户端,相当于 async 版的 axios/fetch):

对照等价 JS,几乎是逐行翻译:

async function main() {
  const urls = [/* ...三个地址... */]
  const tasks = urls.map(u => fetch(u).then(r => [u, r.status]))
  const results = await Promise.all(tasks)
  console.log(results)
}

这就是 async 的核心价值:三个各等 1 秒的请求,并发只花 1 秒。这套模型也正是下一阶段 FastAPI 接口的底座——接口处理函数本身就是 async def(详见 FastAPI 篇)。


七、总结

  • 先建立前端锚点:核心切入点:Python 的 asyncio 就是把 JS 那套"单线程 + 事件循环 + 协作式并发"搬了过来,心智模型几乎可以直接平移。
  • async/await:心智模型直接搬 JS:| await sleep(1000) | await asyncio.sleep(1)(单位是秒) |
  • 边界:GIL——JS 世界里不存在的东西:类比建立了直觉,现在必须立刻划清差异,否则你会带着错误预期写出诡异的代码。
  • 三种并发手段:async / 线程 / 进程:Python 比 JS 多了"真线程"和"真进程"两个工具,搞清楚什么时候用哪个,是本篇的实战重点。
  • 综合示例:并发抓取多个接口:这就是 async 的核心价值:三个各等 1 秒的请求,并发只花 1 秒。
  • 一个关键差异:调用了不 await,等于没执行:这是从 JS 过来最先踩的坑。

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“异步与并发”中,需要同时满足“先建立前端锚点”与“它和 JS 长得有多像”。给定正文约束“Python 的 asyncio 就是把 JS 那套"单线程 + 事件循环 + 协作式并发"搬了过来,心智模型几乎可以直接平移。”,哪些判断保持了原有处理机制?多选
2“异步与并发”出现偏差:“在“异步与并发 / 一个关键差异:调用了不 await,等于没执行”中,即使不满足“Python 里 fetchuser(1) 调用后函数体一行都不会执行,只是造了个协程对象,必须 await 它(或交给事件循环)才会真正跑”,结果与副作用仍会保持不变。”已成为实际行为。围绕“一个关键差异:调用了不 await,等于没执行”与“并发:Promise.all → asyncio.gather”,哪些判断能定位被改变的职责或边界?多选
3评审“异步与并发”方案时,验收条件包含“类比建立了直觉,现在必须立刻划清差异,否则你会带着错误预期写出诡异的代码。”。关于“边界:GIL——JS 世界里不存在的东西”与“一句话理解 GIL”的哪些决策符合正文机制?多选