知识点思维导图
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 换成 def、async 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 包一层,避免卡住事件循环:
五、最容易踩的坑
-
调了协程却没 await。
fetch_user(1)单独写一行不会执行,只会触发RuntimeWarning: coroutine was never awaited。这是 JS 老手最高频的坑——JS 里一调就跑,Python 里不 await 就是废纸。 -
以为 async 能加速计算。async 只解决"等待",不解决"算得慢"。把一堆数学计算塞进
async def不会快分毫,反而把事件循环卡死。CPU 活儿一律走多进程。 -
在协程里写同步阻塞调用。
time.sleep、同步版的requests.get、同步数据库驱动,都会把整个事件循环冻住,所有并发瞬间归零。要么换 async 版库(httpx、aiomysql等),要么用run_in_executor隔离。 -
多进程忘了
if __name__ == "__main__"。不加这层保护,子进程重新导入模块时会再次创建进程,无限递归直接炸掉。这是 Python 多进程的硬性规矩,JS 里没有对应概念,特别容易漏。 -
把 GIL 和 async 搅在一起理解。再强调一次:GIL 管的是多线程并行执行字节码,async 是单线程协作式并发,两者根本不在一个维度。面试时这俩混答几乎必挂。
-
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 过来最先踩的坑。
学完自测
选择所有正确答案;提交后逐项核对判断依据。