"进程是资源分配的最小单位,线程是 CPU 调度的最小单位,协程是用户态的轻量级线程"——这句话很多人能背,但真到写代码的时候还是会懵:Python 里开了 4 个线程跑计算为什么一点都不快?async def 定义的函数调用了怎么没反应?await 到底"等"的是什么?
背定义没用,得知道它们运转的机理。这篇文章从操作系统那一层开始,一路讲到 Python 的 async/await 语法糖底下发生了什么,中间所有代码都是真实跑过的,数据是实测的(Python 3.12.13,10 核 Mac)。
进程、线程、协程:三个层面的东西
这三个概念经常被放在一起背,但它们其实不在同一个层面上:进程和线程是操作系统的概念,协程是编程语言/运行时的概念。搞清楚这一层,后面就不容易混。
进程:资源分配的最小单位
每启动一个程序,操作系统就为它划出一块独立的虚拟地址空间,代码段、数据段、堆、栈都在里面。两个进程之间默认互相看不见对方的内存:
进程 A 进程 B
┌─────────────────┐ ┌─────────────────┐
│ 代码段 │ │ 代码段 │
│ 数据段 │ │ 数据段 │
│ 堆 │ │ 堆 │
│ 栈 │ │ 栈 │
└─────────────────┘ └─────────────────┘
独立地址空间 独立地址空间
\ /
互不可见,想通信得走内核
这个隔离性是好事——一个进程崩了不会把别的进程带崩。但代价是贵:
- 创建贵:
fork一个进程要复制一堆内核数据结构(页表、文件描述符表等) - 切换贵:切换进程要换地址空间(换页表基址寄存器),伴随 TLB(地址翻译缓存)大面积失效,切换后访问内存会慢一截
- 通信贵:进程间通信(IPC)必须借道内核——管道、消息队列、共享内存、socket,每一样都要在用户态和内核态之间拷贝数据
线程:CPU 调度的最小单位
线程是进程里的执行流。同一进程的所有线程共享地址空间,但各自有独立的栈和寄存器现场:
进程
┌──────────────────────────────────┐
│ 共享:代码段、数据段、堆、打开的文件 │
│ │
│ 线程 1:独立栈 + 寄存器 │
│ 线程 2:独立栈 + 寄存器 │
│ 线程 3:独立栈 + 寄存器 │
└──────────────────────────────────┘
线程切换比进程切换便宜,关键就在"不用换地址空间"——页表不动,TLB 大部分还能用,CPU 缓存命中率也高得多。
但共享内存是双刃剑。两个线程同时写同一个变量,结果不可预料(竞态条件),于是你得加锁;锁用不好,要么死锁,要么把多线程写成变相的单线程。这部分是所有语言的多线程编程共同的痛。
协程:把调度权拿回用户态
线程的调度由操作系统说了算:时钟中断一到,CPU 被夺走,切给另一个线程。这叫抢占式调度——你的代码在任何一条指令之后都可能被切走,所以共享数据处处要防。
协程不一样,它是协作式调度:一个协程只有自己主动让出(yield / await),控制权才会交给别人。不让出,就一直是它的。
这个区别带来几个实际后果:
- 切换极便宜。协程切换不陷入内核、没有系统调用,就是保存几个局部变量再跳个转,开销和一次普通函数调用差不多。线程切换在微秒级,协程切换在纳秒级,差着好几个数量级。
- 大多数时候不用锁。两次
await之间的代码不会被别的协程打断,竞态的可能只存在于await点上。 - 一个阻塞全都堵。协作的前提是守规矩——协程里调了一个阻塞函数,它不让出,同一线程里所有协程都得陪着等。这一点后面讲 asyncio 的坑时会重点说。
一张表对比
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 谁调度 | 操作系统 | 操作系统 | 用户态代码(事件循环) |
| 调度方式 | 抢占式 | 抢占式 | 协作式 |
| 内存 | 独立地址空间 | 共享进程地址空间 | 共享线程地址空间 |
| 切换成本 | 高(换页表、TLB 失效) | 中(换栈和寄存器) | 极低(一次函数调用级) |
| 数据共享 | 需 IPC,走内核 | 直接共享,但要加锁 | 直接共享,await 点之外无竞态 |
| 能用多核 | 能 | 看语言实现(Python 有 GIL) | 不能(单线程内) |
| 量级 | 几十~几百个 | 几百~几千个 | 几万~几十万个 |
注意最后一行:协程之所以动辄能开几万个,是因为每个协程的开销就是一个栈帧对象(KB 级),而每个线程光栈就要占掉几 MB 虚拟内存。
并发和并行:两个经常被混用的维度
进程、线程、协程回答的是:任务由什么承载、谁来调度、切换成本多高。并发和并行回答的是另一个问题:多个任务在时间上是怎样推进的。
所以它们不是同一类名词。不能把“进程、线程、协程、并发、并行”当成五种平级方案来选——前三个是执行载体和调度机制,后两个是多个任务之间的执行关系。
先把定义钉死
并发(concurrency):在一段重叠的时间范围内,多个任务都处于“已经开始但还没结束”的状态,并且都能向前推进。它强调的是同时应对多件事,不要求同一时刻真的有两段代码在运行。
并行(parallelism):在同一个物理时刻,多个执行单元真的在执行不同任务。它强调的是同时做多件事,需要多个 CPU 核、GPU 计算单元或其他能同时工作的硬件资源。
画到 CPU 时间线上,区别一眼就能看出来:
串行(一个任务彻底结束,再做下一个):
时间 ──────────────────────────────>
CPU 0: [ A A A A ][ B B B B ]
并发但不并行(单核交替推进):
时间 ──────────────────────────────>
CPU 0: [ A A ][ B ][ A ][ B B ][ A ][ B ]
任意一个瞬间仍然只有 A 或 B 在运行
并发且并行(双核同时推进):
时间 ──────────────────────────────>
CPU 0: [ A A A A A A A A ]
CPU 1: [ B B B B B B B B ]
同一个瞬间 A、B 都在运行
单核上的两个线程可以并发:操作系统每隔一小段时间在 A、B 之间抢占切换,宏观看起来都在前进,但微观上每一刻只有一个线程占着 CPU。单线程事件循环里的两个协程也可以并发:A 在 await 处主动让出,B 接着跑,同样没有两段 Python 代码在同一时刻执行。
多核上的两个进程则可以真正并行:核 0 跑 A 的机器指令时,核 1 同时跑 B 的机器指令。这不是“切得足够快所以看起来同时”,而是物理上的同时。
| 维度 | 并发 | 并行 |
|---|---|---|
| 关注点 | 多个任务能否在同一时间段内共同推进 | 多个任务能否在同一时刻真正执行 |
| 必要条件 | 存在调度和切换机会 | 存在多个硬件执行单元 |
| 主要价值 | 隐藏等待、提高响应能力、同时处理更多连接 | 缩短计算时间、提高 CPU 吞吐量 |
| 典型场景 | 网络请求、数据库查询、文件 IO | 图像处理、科学计算、压缩编码 |
| 常见手段 | 线程、协程、事件循环 | 多进程、多线程、SIMD/GPU |
它们和进程、线程、协程是什么关系
同一种执行载体,放到不同运行环境里,可能只有并发,也可能同时具备并行:
| 执行载体 | 能否并发 | 能否并行执行 CPU 代码 | Python 里的关键限制 |
|---|---|---|---|
| 多进程 | 能 | 多核机器上能 | 每个进程有独立解释器和 GIL,但创建、通信成本高 |
| 多线程 | 能 | 操作系统层面能 | 默认 CPython 中,GIL 阻止多个线程同时执行 Python 字节码;释放 GIL 的 C 代码除外 |
| 单线程内的多协程 | 能 | 不能 | 只在 await 处交替推进,始终由一个线程执行 |
表里最重要的是“运行环境”四个字。创建 4 个进程不等于一定并行:机器如果只有 1 个可用 CPU 核,4 个进程仍然只能轮流跑。创建 100 个线程也不等于有 100 份并行能力:8 核机器同一时刻最多只有大约 8 个 CPU 执行流真正占核,其余都在等待调度;到了默认 CPython,执行 Python 字节码时还要再受 GIL 限制。
协程也不是永远和并行绝缘。一个事件循环里的协程只负责并发,但可以启动多个 worker 进程,让每个进程各跑一个事件循环:进程之间并行,进程内部协程并发。后面提到的 Gunicorn + uvicorn,做的就是这两层组合。
IO 并发为什么会变快:重叠的是等待
假设有 3 个网络请求,每个请求等待服务端响应 500ms,响应回来后还要用 10ms 的 CPU 时间处理结果。
串行执行时,一个请求等完才发下一个:
请求 A: [等待 500ms][处理 10ms]
请求 B: [等待 500ms][处理 10ms]
请求 C: [等待 500ms][处理 10ms]
总耗时约 3 × (500ms + 10ms) = 1.53s
并发执行时,3 段等待可以重叠:
请求 A: [等待 500ms][处理 10ms]
请求 B: [等待 500ms] [处理 10ms]
请求 C: [等待 500ms] [处理 10ms]
总耗时理想情况下约 500ms + 3 × 10ms = 0.53s
这里没有任何两段 Python 处理代码在并行,CPU 处理结果的 30ms 仍然是一个接一个跑。省下来的 1 秒来自把原本闲着干等的时间叠到了一起。这就是线程和协程能加速 IO 密集任务的根本原因:它们不是让一次 IO 变快了,而是让 CPU 在 A 等待时去推进 B。
反过来,如果 4 个任务各需要 1 秒纯 CPU 计算,中间没有 IO、没有等待,那么在单线程事件循环里并发调度也仍要大约 4 秒。切换顺序不会凭空增加算力,反而还多了调度开销。想把它压到接近 1 秒,必须让 4 个核真正并行工作——在默认 CPython 里,通常就要用 4 个进程。
一句话概括:
并发提升 IO 密集任务:靠的是“不浪费等待时间”
并行提升 CPU 密集任务:靠的是“增加同时干活的计算资源”
并行为什么很难达到 N 核 N 倍
即使任务适合并行,开 4 个进程通常也拿不到严格的 4 倍加速。因为程序总有一部分不能并行:读取输入、拆分任务、汇总结果、进程通信,这些步骤仍然要串行执行。
Amdahl 定律给出了一个很实用的上限。假设程序中可并行部分占比为 P,使用 N 个执行单元,理论最大加速比是:
S(N) = 1 / ((1 - P) + P / N)
如果一个程序只有 80% 能并行,那么用 4 个核的理论上限是:
S(4) = 1 / (20% + 80% / 4) = 2.5
哪怕核数无限多,剩下那 20% 的串行部分也把极限锁死在 5 倍。现实还要再扣掉进程创建、任务调度、IPC、锁竞争、CPU 缓存失效和内存带宽争用,所以后面实测的 4 进程是 3.2 倍,而不是整整 4 倍,这是正常结果,不是多进程“没跑满”。
任务粒度也很关键。把 1ms 的计算拆给进程池,光序列化参数、跨进程传输和调度可能就花掉不止 1ms;把几秒钟的大块计算拆出去,通信成本占比才足够小。并行适合计算量大、任务之间依赖少、拆分后粒度足够粗的工作。
并发度不是越高越好
“能创建 10 万个协程”不等于“应该同时发出 10 万个请求”。并发度描述的是同一时间有多少任务在途,吞吐量描述的是单位时间完成多少任务,延迟描述的是一个任务从开始到完成要多久——这是三个不同指标。
在系统稳定时,它们大致符合 Little 定律:
平均在途任务数 = 平均吞吐量 × 平均响应时间
L = λ × W
例如服务每秒稳定完成 100 个请求,平均响应时间 0.2 秒,那么系统里平均约有 20 个请求在途。把客户端并发从 20 硬拉到 1000,并不会自动得到每秒 5000 个请求;如果数据库连接池只有 20 个连接,多出来的 980 个任务只是在内存里排队,还会带来更高延迟、超时、限流和内存占用。
因此高并发系统不仅要会“放出任务”,还要会限制任务:用连接池控制稀缺资源,用 asyncio.Semaphore 限制同时进行的操作,用有界队列制造背压。并发是一种资源预算,不是越大越好的性能开关。
三个最常见的误区
误区一:async 就是并行。 asyncio.gather 里的任务确实并发推进,但一个事件循环通常只跑在一个线程上;某个协程做纯计算时,其他协程只能等,CPU 也只用到一个核。
误区二:开了线程就一定并行。 操作系统允许多个线程分配到不同核,但默认 CPython 的 GIL 让 CPU 密集的 Python 字节码仍然排队执行。线程在 IO 等待期间释放 GIL,或者执行主动释放 GIL 的 C 扩展时,才可能和其他线程真正并行。
误区三:单线程协程没有竞态。 协程不会在任意指令之间被抢占,但会在 await 处主动切走。如果一次“读—改—写”横跨了 await,另一个协程就可能插进来:
import asyncio
counter = 0
async def increment():
global counter
old_value = counter
await asyncio.sleep(0) # 主动让出,另一个协程也读到了同一个旧值
counter = old_value + 1
async def main():
await asyncio.gather(increment(), increment())
print(counter) # 不是 2,而是 1
asyncio.run(main())
两个协程都先读到 0,恢复后又都写入 1,其中一次更新丢了。所以更准确的说法是:协程把可能发生交错的位置收敛到了 await,让竞态更容易分析,但没有消灭竞态。共享内存可以用 asyncio.Lock 保护;数据库中的共享状态还需要事务、原子更新或数据库锁。
到这里,再看后面的 GIL 和 asyncio 就有一条清晰主线了:GIL 解释的是为什么 CPython 线程能并发却难以并行执行字节码;事件循环解释的是为什么单线程不能并行,却依然能承载高并发 IO。
Python 线程:绕不开的 GIL
讲 Python 并发,GIL(Global Interpreter Lock,全局解释器锁)是绕不过去的一座山。
GIL 到底是什么
GIL 是 CPython 解释器里的一把全局互斥锁:同一时刻,只允许一个线程执行 Python 字节码。哪怕你的机器有 10 个核,开 10 个 Python 线程,真正在跑字节码的永远只有一个。
先划清一个常见误解:GIL 不是 Python 语言的规定,是 CPython 这个解释器的实现选择。Jython(跑在 JVM 上)和 IronPython(跑在 .NET 上)都没有 GIL。但你平时 python3 命令启动的、从 python.org 下载的,全是 CPython。
CPython 为什么要有这把锁
根子在内存管理上。CPython 靠引用计数回收内存:每个对象记着自己被引用了几次,归零就释放。问题是对计数做 +1/-1 不是原子操作,多线程同时改一个对象的计数,不加保护就会改坏——轻则内存泄漏,重则直接段错误。
怎么保护?两条路:
- 给每个对象的计数操作加锁(或用原子指令)——粒度细,能并行,但每一次赋值、每一次传参都伴随着锁操作,单线程性能被拖垮。
- 干脆整一把大锁,谁拿到锁谁跑字节码——实现简单,单线程零额外开销。
CPython 选了第二条。这不是没人试过第一条:1999 年 Greg Stein 就做过一版去掉 GIL 的 free-threaded 补丁,靠细粒度锁保护引用计数,结果单线程性能掉了约 40%,社区没接受。之后二十多年,GIL 就这么留了下来,成了 CPython 最著名的"特性"。
GIL 什么时候会释放
GIL 不是焊死的,它有两种让出的时机:
- 遇到 IO 操作时。线程调用阻塞式系统调用(读文件、等网络、甚至
time.sleep)之前,解释器会主动释放 GIL——反正这个线程要干等了,不如让别人跑。 - 每隔一小段时间强制切换。默认每 5 毫秒(可以用
sys.getswitchinterval()查看)检查一次,如果别的线程在等锁,就切过去。
>>> import sys
>>> sys.getswitchinterval()
0.005
这两条规则合起来,就决定了 Python 多线程的命运:
- IO 密集型任务:线程大部分时间在等 IO,GIL 频繁被释放,多线程能有效并发。
- CPU 密集型任务:线程们谁也不等 IO,就在那抢一把锁,每 5ms 换一次手。多核完全用不上,还白白搭上切换开销。
实测:CPU 密集型,多线程基本是白搭
光说不练没意义,直接跑数据。任务:计算 40 万以内有多少个质数(纯计算,无任何 IO),跑 4 份,分别用单线程串行、4 线程、4 进程、4 协程:
import time
import threading
import multiprocessing
import asyncio
def cpu_work(n: int) -> int:
"""纯计算:数 n 以内的质数个数(朴素试除)"""
count = 0
for num in range(2, n):
is_prime = True
for i in range(2, int(num ** 0.5) + 1):
if num % i == 0:
is_prime = False
break
if is_prime:
count += 1
return count
N = 400000
def run_threads():
threads = [threading.Thread(target=cpu_work, args=(N,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
实测结果(Python 3.12.13,10 核 Mac):
| 方案 | 耗时 | 对比串行 |
|---|---|---|
| 单线程串行 | 1.26s | 1.0x |
| 多线程 x4 | 1.24s | 几乎没快 |
| 多进程 x4 | 0.40s | 快了 3.2 倍 |
| 协程 x4 | 1.29s | 反而略慢 |
数据把 GIL 的影响展示得明明白白:4 个线程跑 CPU 密集任务,和串行一模一样——10 个核闲着 9 个,字节码还是一行一行排队执行。而多进程因为每个进程有自己独立的解释器和 GIL,4 个进程真正并行跑在 4 个核上,直接拿到 3 倍多的提速。
协程在这个场景也是零提升(还多了一层调度开销),原因在讲 asyncio 的坑时细说。
实测:IO 密集型,多线程是真有效
换个任务:模拟 8 次 IO,每次等待 0.5 秒:
import time
import threading
def io_work():
time.sleep(0.5) # 模拟一次网络/磁盘 IO 等待
def run_threads():
threads = [threading.Thread(target=io_work) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
实测结果:
| 方案 | 耗时 |
|---|---|
| 单线程串行 | 4.03s |
| 多线程 x8 | 0.51s |
8 倍提升,干净利落。原因就在前面说的第一条释放时机:time.sleep 会让线程陷入阻塞式系统调用,调用前 GIL 被释放,其他线程趁机上。8 个线程几乎同时在"等",0.5 秒后一起回来。
所以别再笼统地说"Python 多线程没用"——准确的说法是:对 CPU 密集没用,对 IO 密集很有用。爬虫、调数据库、读文件这类活,多线程依然是简单有效的方案。
Python 3.13:GIL 终于开始松动了
GIL 的故事在 2024 年迎来转折。PEP 703(Making the Global Interpreter Lock Optional)被接受,Python 3.13 引入了实验性的 free-threading 构建:
- 需要用
--disable-gil选项专门编译一个版本(可执行文件通常叫python3.13t),常规的 python3.13 默认仍带 GIL - 内部把全局引用计数换成了偏斜引用计数(biased reference counting)、每对象锁等细粒度方案,配合 3.12 就引入的 immortal objects(PEP 683),把无锁化后的单线程性能损失压到了可接受的范围
- 可以用
sys._is_gil_enabled()检测当前解释器 GIL 是否启用
现在的状态是"实验性":C 扩展生态(numpy、pandas 这些)要逐个适配,第三方库支持还在推进。短期生产环境别急着上,但方向已经很明确——GIL 这个二十多年的老设计,开始退场了。在那之前,记住结论:CPU 密集想并行,老实用多进程。
Python 协程:从生成器里长出来的
Python 的协程不是凭空设计的语法,它是从生成器(generator)一步步演化出来的。理解这条演化线,就能理解 async/await 的每一个设计。
生成器天生就是半个协程
协程需要的核心能力是什么?执行到一半能暂停,之后还能从暂停点继续。而生成器天生就会这个:
def gen():
print("第一步")
yield 1 # 暂停在这,把 1 交出去
print("第二步")
yield 2
g = gen() # 注意:什么都不会打印,函数体一行都没执行
next(g) # 打印"第一步",返回 1,暂停在第一个 yield
next(g) # 打印"第二步",返回 2
两个关键点:
- 调用生成器函数不会执行函数体,只是造出一个生成器对象——后面会看到,
async def的函数也是这个脾气。 yield把"暂停现场、交出控制权"这件事变成了一个语法元素。
手写一个 30 行的迷你事件循环
光一个生成器会暂停还不够,协程的威力在于多个任务交替执行。其实调度器简单得超乎想象——轮流驱动每个生成器往前走一步就行:
def task(name, steps):
"""一个用生成器写的'协程':每干一步活就 yield 让出控制权"""
for i in range(steps):
print(f"[{name}] 执行第 {i + 1} 步")
yield # 让出控制权
def run(tasks):
"""极简调度器:轮流驱动每个任务走一步"""
pending = list(tasks)
while pending:
for t in pending[:]:
try:
next(t) # 驱动这个协程走一步
except StopIteration:
pending.remove(t) # 任务跑完了,移出队列
run([task("A", 3), task("B", 2)])
跑出来的输出:
[A] 执行第 1 步
[B] 执行第 1 步
[A] 执行第 2 步
[B] 执行第 2 步
[A] 执行第 3 步
A 和 B 交替前进——这就是协作式调度的全部本质。run() 就是事件循环最朴素的模样:谁 yield 谁让出,调度器拿着任务列表挨个驱动。asyncio 的事件循环比这复杂得多(要处理 IO 就绪、定时器、回调),但骨架就是这个骨架。
yield from:离 await 只差一层窗户纸
上面的协程还有个短板:一个协程想调另一个协程,还得手动把里面的值一个个转发出来。Python 3.3 的 PEP 380 引入 yield from 解决了这个委托问题:
def subtask():
yield 1
yield 2
return "subtask 的返回值"
def maintask():
result = yield from subtask() # 委托给子生成器,还能拿到返回值
print(f"maintask 拿到: {result}")
yield 3
list(maintask())
# 输出: maintask 拿到: subtask 的返回值
# 返回: [1, 2, 3]
yield from 一次性做了三件事:把子生成器的 yield 值转发给外层、把外层的 send 传进子生成器、子生成器结束时接住它的 return 值。
把它和今天的语法对照一下,会发现惊人地像:
result = yield from subtask() # Python 3.3 的写法
result = await subtask() # Python 3.5+ 的写法
await 的前身就是 yield from,语义几乎一一对应:挂起自己、驱动子协程、等它完成、拿到返回值。
演化时间线
把整条线串起来:
2001 Python 2.2 PEP 255:生成器诞生(yield)
2006 Python 2.5 PEP 342:生成器支持 send()/throw(),能往里送值,成为真正的协程
2012 Python 3.3 PEP 380:yield from,协程之间可以互相委托
2014 Python 3.4 asyncio 进标准库,@asyncio.coroutine + yield from 写异步代码
2015 Python 3.5 PEP 492:async/await 成为正式语法,协程从生成器中独立出来
2018 Python 3.7 asyncio.run() 等高层 API,async/await 语法保留为关键字
2015 年那次是关键一步:从此协程有了自己的类型(coroutine),不再是"被借用的生成器",async def 和 await 两个关键字让异步代码的意图一目了然。你去看一些老代码里的 @asyncio.coroutine 装饰器配 yield from,那就是演化中途的形态,功能上没问题,但早就该换新写法了。
async/await 到底在做什么
语法好认,机理未必清楚。这一节把 async 和 await 拆开看。
async def:定义的是"可执行",不是"执行"
新手最常踩的坑就是这个——调用一个 async 函数,什么都不会发生:
import asyncio
async def hello():
print("hello 开始执行了")
return 42
coro = hello()
print(type(coro).__name__) # coroutine —— 注意,上面那行 print 没执行
result = asyncio.run(coro) # 到这里才真正执行,打印 "hello 开始执行了"
print(result) # 42
async def 定义的严格来说叫协程函数,调用它得到的是一个协程对象(coroutine object)。这个过程只是把函数体和参数打包好,函数体一行都没跑。这和生成器的行为一模一样——gen() 也不会执行函数体。
那协程对象怎么才能跑起来?必须有人驱动它:要么被另一个协程 await,要么交给事件循环(asyncio.run、create_task)。没有驱动的协程对象就是个死物。
如果你检查一下协程对象,会发现它身上带着 __await__、send、throw 这几个方法——这套接口和生成器一脉相承,驱动协程向前走的底层动作就是 send。
await 的三层含义
await x 这一行,语义上其实做了三件事:
1. 挂起当前协程:保存局部变量和执行位置(保存现场)
2. 把控制权还给事件循环:循环可以去驱动别的任务了
3. 等 x 完成后:从挂起点恢复执行,await 表达式的值就是 x 的返回值
第二点是灵魂。await 就是协程主动让出控制权的点,对应前面说的协作式调度里的"让出"。一个协程里一个 await 都没有,那它从被驱动到跑完,事件循环里其他任务一步都走不了。
看一段代码感受一下让出的时机:
async def worker(name):
print(f"{name} 开始")
await asyncio.sleep(0.1) # 让出点:挂起自己,循环去跑别的任务
print(f"{name} 结束") # 0.1 秒后被事件循环唤醒,从这里继续
async def main():
await asyncio.gather(worker("任务1"), worker("任务2"))
asyncio.run(main())
# 输出顺序: 任务1 开始 -> 任务2 开始 -> 任务1 结束 -> 任务2 结束
任务1 打印完"开始"就在 await 处挂起了,事件循环立刻去驱动任务2;两个任务都在等 sleep 到期,0.1 秒后先后被唤醒。输出顺序把这个交替过程照得清清楚楚。
什么对象能被 await:__await__ 协议
await 后面不是随便什么都能接的。试一下:
>>> await 42
TypeError: object int can't be used in 'await' expression
能被 await 的对象叫 awaitable,判定标准就一条:实现了 __await__ 方法,且该方法返回一个迭代器。Python 里内置的 awaitable 有三种:协程(coroutine)、任务(Task)、Future。
这意味着你甚至可以自己造一个能 await 的类:
import asyncio
import time
class Sleep:
def __init__(self, seconds):
self.seconds = seconds
def __await__(self):
# __await__ 必须返回一个迭代器;yield 就是把控制权交回事件循环
end = time.time() + self.seconds
while time.time() < end:
yield
return f"睡了 {self.seconds}s"
async def main():
result = await Sleep(0.3)
print(result) # 睡了 0.3s
asyncio.run(main())
这段代码能真实跑通。await Sleep(0.3) 时,解释器调 Sleep.__await__() 拿到迭代器,然后像驱动生成器一样驱动它,迭代结束时 StopIteration 携带的值就是 await 表达式的结果。
当然,这个 Sleep 是教学版——它的 yield 之间在空转忙等。真实的 asyncio.sleep 挂起时会往事件循环注册一个定时器回调,期间 CPU 完全可以去干别的活。但协议就是这个协议,分毫不差。协程、Task、Future 能被 await,靠的都是各自的 __await__ 实现。
事件循环在底下干什么
最后一块拼图。事件循环(event loop)本质上是一个跑在单线程里的调度循环,配合操作系统提供的 IO 多路复用机制(Linux 的 epoll、macOS/BSD 的 kqueue、Windows 的 IOCP)工作。它每一轮做的事大致是:
事件循环的一次转动:
1. 从就绪队列取出一个任务,send 驱动它往前跑
2. 任务跑到 await 一个 IO 操作(比如读 socket)
-> 把这个 IO 的 fd 注册到 epoll/kqueue,任务挂起
3. 所有任务都在等 IO,就绪队列空了
-> 整个线程阻塞在 epoll_wait,CPU 零占用
4. 某个 fd 就绪了(数据到了/连接建立/超时到期)
-> epoll_wait 返回,把对应任务放回就绪队列
5. 回到第 1 步
这套结构回答了一个关键问题:为什么单线程的 asyncio 能同时hold住上万个连接?因为线程根本不在"等"任何连接——它把所有连接都托管给了内核的 epoll,谁有数据了就处理谁。等待的成本从"每个连接一个线程"变成了"每个连接一个协程栈帧",量级完全不同。
这就是 asyncio 的全部:单线程 + IO 多路复用 + 协程 = 高并发 IO。
实战:坑和正确姿势
机理清楚了,坑就好理解了。asyncio 最常见的两个坑,本质都是"没有让出控制权"。
坑 1:在协程里调阻塞函数
前面 CPU 密集测试里协程零提升,原因就是它。再拿 IO 场景看一次,对比更刺眼——8 次模拟 IO,每次 0.5 秒:
import asyncio
import time
# 正确:用 asyncio.sleep,等待期间让出控制权
async def io_right():
await asyncio.sleep(0.5)
# 错误:在协程里调 time.sleep,阻塞整个事件循环
async def io_wrong():
time.sleep(0.5)
async def main():
await asyncio.gather(*[io_right() for _ in range(8)]) # 0.50s
await asyncio.gather(*[io_wrong() for _ in range(8)]) # 4.03s
实测:正确写法 0.50 秒,错误写法 4.03 秒——和完全串行一模一样。
time.sleep(0.5) 是一个普通同步函数,里面没有 await,事件循环拿不到控制权,只能干瞪眼等它返回。8 个任务所谓的"并发",实际是 8 个排队。协程的并发只发生在 await 点上,一个阻塞调用就能拖死整个事件循环里所有任务,这是 asyncio 生产事故的第一大来源。
对应到真实代码里,就是别在协程里直接用 requests、同步的数据库驱动、open().read() 这类阻塞调用。要么换异步生态的库(aiohttp、httpx、asyncpg、aiofiles),要么用下面的 to_thread 兜底。
坑 2:调了 async 函数却忘了 await
async def fetch():
return "data"
result = fetch() # 得到的只是一个 coroutine 对象,什么都没发生
这行代码不会报错,函数体也不会执行,只会在对象被垃圾回收时收到一条 RuntimeWarning: coroutine 'fetch' was never awaited。如果 fetch 里有重要逻辑(写库、发请求),那就是静默的业务丢失,排查起来很隐蔽。好在现在主流 IDE 和 linter 都能对"未 await 的协程"给出警告,值得打开。
正确姿势速查
日常高频用到的就这几个:
asyncio.gather:并发跑一批,等全部完成
results = await asyncio.gather(task1(), task2(), task3())
# 并发执行;结果按传入顺序排列(不是按完成顺序)
asyncio.create_task:登记到事件循环,主协程继续走
task = asyncio.create_task(background_job()) # 立刻登记,事件循环一有机会就调度它
# ... 干别的
result = await task # 需要结果时再 await
如果连结果都不要(fire-and-forget,点火后不管),那不 await 就行:
async def main():
asyncio.create_task(write_log()) # 不等它,主协程继续走
await do_something_else() # 主协程还活着,后台任务就能跑
看着简单,实测下来有三个坑得知道。
坑一:主协程先结束,后台任务会被取消,但不是被立刻“硬杀”。 下面这份脚本可以直接复制运行。它把四个最容易混淆的情况放在一起;每一段都单独调用一次 asyncio.run,这样才能观察主协程返回时,尚未完成的任务到底会发生什么:
import asyncio
print("=== 1. 未完成任务收到 CancelledError,finally 会执行 ===")
async def cancelled_job():
try:
print(" [job] 开始,要跑 10 秒")
await asyncio.sleep(10)
print(" [job] 正常结束") # 不会执行到这里
except asyncio.CancelledError:
print(" [job] 收到 CancelledError,知道自己被取消了")
raise # 不要吞掉取消请求
finally:
print(" [job] finally 执行了")
async def run_cancellation_case():
pending_task = asyncio.create_task(cancelled_job())
await asyncio.sleep(0.1) # 让 job 有机会启动并挂起
print(" [main] 主协程结束")
asyncio.run(run_cancellation_case())
print(" asyncio.run 返回,程序退出")
print()
print("=== 2. 收到一次取消后,finally 里再 await 会怎样 ===")
async def job_with_async_cleanup():
try:
await asyncio.sleep(10)
finally:
# 原来的取消已作为异常传递;这段异步清理仍可正常 await。
await asyncio.sleep(0.5)
print(" [job] 异步清理完成")
async def run_async_cleanup_case():
pending_task = asyncio.create_task(job_with_async_cleanup())
await asyncio.sleep(0.1)
asyncio.run(run_async_cleanup_case())
print(" asyncio.run 返回,程序退出")
print()
print("=== 3. 捕获并吞掉 CancelledError 会怎样 ===")
async def stubborn_job():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print(" [job] 收到取消,但我不 raise,继续干")
await asyncio.sleep(0.2)
print(" [job] 又干了一会才结束")
async def run_suppressed_cancellation_case():
pending_task = asyncio.create_task(stubborn_job())
await asyncio.sleep(0.1)
asyncio.run(run_suppressed_cancellation_case())
print(" asyncio.run 返回,程序退出")
print()
print("=== 4. 优雅退出:主协程退出前主动等后台任务收尾 ===")
async def background_worker(name, total_steps):
for step in range(total_steps):
await asyncio.sleep(0.1)
print(f" [{name}] 进度 {step + 1}/{total_steps}")
return f"{name} done"
async def run_graceful_shutdown_case():
tasks = [
asyncio.create_task(background_worker(f"worker{worker_index}", 3))
for worker_index in range(2)
]
await asyncio.sleep(0.15) # 主协程先干自己的活
print(" [main] 活干完了,等后台任务收尾再退出")
results = await asyncio.gather(*tasks)
print(f" [main] 全部收尾完成: {results}")
asyncio.run(run_graceful_shutdown_case())
读输出时抓住四点:
- 第 1 组:取消是协作式的。
run_cancellation_case()先让出 0.1 秒,cancelled_job()因而能启动并挂在sleep(10)上。主协程返回后,asyncio.run向它发起取消;它在这个挂起点收到CancelledError,执行finally,然后asyncio.run才返回。正常业务分支里的“正常结束”不会执行。 - 第 2 组:一次取消不会让后续每个
await自动失败。 原来的取消已经以CancelledError的形式传进任务,finally里的await asyncio.sleep(0.5)仍能完成,asyncio.run会等这段异步清理结束。只有又来了新的取消请求,异步清理才可能再次被打断。 - 第 3 组:吞掉取消会拖住退出。 捕获
CancelledError后不再raise,任务就能继续运行;asyncio.run不会硬杀它,而是等它自己结束。要是它一直不结束,程序退出也会一直卡住。 - 第 4 组:需要任务跑完就显式等待。 在主协程返回前
await asyncio.gather(*tasks),所有后台任务会自然收尾;这时asyncio.run不需要取消它们。
所以,“任务被取消”不是“任务瞬间消失”:它的正常业务逻辑不会继续跑,但仍有机会感知取消并执行清理。“点火后不管”的前提是事件循环得活得比任务久。Web 服务里事件循环的生命周期由框架托管,通常没问题;一次性脚本里就得自己保证主协程别提前跑完。
坑二:没人持有引用的任务,理论上可能被 GC 回收。 Python 官方文档对 create_task 有一条警告:事件循环对任务只持有弱引用,代码不保存引用的话,任务可能"中途消失"。推荐用一个集合持有强引用,任务跑完自动移出:
background_tasks = set()
task = asyncio.create_task(background_job())
background_tasks.add(task) # 集合持有强引用
task.add_done_callback(background_tasks.discard) # 跑完自动移出集合
实测 CPython 3.12 里挂起中的任务会被事件循环的回调链引用着,回收不掉——但这属于实现细节,不该依赖。set 模式是官方推荐写法,写上不亏。
坑三:任务里的异常会静默丢失。 没人 await 的任务抛了异常,不会打断任何人,只会在任务被回收时往 stderr 打一条日志,业务层面完全无感:
Task exception was never retrieved
future: <Task finished ... exception=ValueError('后台任务炸了')>
生产代码里应该用 done_callback 把异常捞出来记日志:
def log_exception(t: asyncio.Task):
if not t.cancelled() and (exc := t.exception()):
logger.error("后台任务异常: %s", exc)
task = asyncio.create_task(background_job())
task.add_done_callback(log_exception)
gather 适合"等一群任务一起完",create_task 适合"点火后先不管"——但"不管"不等于真的什么都不做,引用和异常这两件事得安排明白。
asyncio.to_thread:把同步阻塞库扔进线程池
import asyncio
import time
def blocking_io():
time.sleep(0.5) # 某个没法替换的同步阻塞库,比如 requests
return "完成"
async def main():
# 4 个阻塞调用通过线程池并发,总耗时约 0.5s 而不是 2s
results = await asyncio.gather(*[asyncio.to_thread(blocking_io) for _ in range(4)])
to_thread 的思路很巧:既然这个函数不会自己让出,那就把它扔到别的线程去跑,事件循环通过 Future 等它完事。实测 4 个 0.5 秒的阻塞调用并发总耗时 0.51 秒。这是异步代码里混用同步库的标准解法(底层是默认线程池,等价于老写法 run_in_executor)。
asyncio.run:程序入口,一个进程调一次
async def main():
...
asyncio.run(main()) # 创建事件循环 -> 驱动 main -> 循环关闭
多进程、多线程、协程:到底怎么选
把前面所有实测数据放在一起:
| 场景 | 串行 | 多线程 | 多进程 | 协程 |
|---|---|---|---|---|
| CPU 密集(质数计算 x4) | 1.26s | 1.24s | 0.40s | 1.29s |
| IO 密集(8 次 IO x0.5s) | 4.03s | 0.51s | — | 0.50s |
决策逻辑其实就三条:
CPU 密集 -> 多进程。GIL 之下线程并行不了字节码,只能靠多个解释器进程分摊。顺带一提,numpy、pandas 这类 C 扩展在做大块数值计算时会主动释放 GIL,所以"数值计算多线程"有时确实有效——但那是 C 层的功劳,别把它当成 Python 多线程的普遍能力。
IO 密集、并发量不大 -> 多线程够用。几个到几十个并发任务,写法和同步代码几乎一样,不用改造生态,心智成本最低。
IO 密集、高并发 -> 协程。连接数上到成千上万时,线程的内存和切换成本撑不住(一个线程几 MB 栈空间,一万个线程就是几十 GB),协程一个才 KB 级,单线程就能轻松 hold 住。代价是整个调用链都得是异步生态。
实际生产里经常是组合拳。比如 Python Web 服务的经典部署:Gunicorn 起多个 worker 进程(吃满多核),每个 worker 用 uvicorn 跑 asyncio 事件循环(进程内高并发 IO)。多进程负责利用 CPU,协程负责扛住连接,各干各的强项。
小结
一路捋下来,核心就这几条:
- 进程隔离资源,线程共享内存,协程把调度拿回用户态。三者是不同层面的方案,不是谁替代谁的关系。
- GIL 是 CPython 引用计数内存管理的保护锁,它让 CPU 密集的多线程彻底失效,但 IO 密集时线程会在等待中释放锁,多线程依然有效。Python 3.13 的 free-threading 正在拆掉这把锁,但生态成熟还需要时间。
- 协程从生成器演化而来:
yield提供了"暂停/恢复",yield from提供了委托,async/await只是让这套机制有了专属语法。理解await的最好方式就是记住它的前身yield from。 await的本质是挂起并让出控制权,事件循环靠 IO 多路复用(epoll/kqueue/IOCP)在单线程里调度成千上万的协程。- 协程的并发只发生在
await点上——在协程里调阻塞函数,等于一个人堵死整条流水线,这是 asyncio 最高发的事故。
最后给个一句话选型:
CPU 密集 -> 多进程
IO 密集、并发量小、图省事 -> 多线程
IO 密集、高并发 -> 协程(async/await)
阻塞库混进异步代码 -> asyncio.to_thread 兜底
记住"await 就是让出控制权的点"这一句,asyncio 的大部分行为和坑都能自己推出来。
系列导航
