Back to Journal
02 / Entry· 21 min read

进程、线程、协程,再到 Python 的 async/await:并发编程一次讲透

从操作系统的进程线程讲起,拆透 Python GIL 的来龙去脉、协程从生成器到 async/await 的演化史,用实测数据说清楚多进程、多线程、协程各自到底适合什么场景

🔊 系统朗读

"进程是资源分配的最小单位,线程是 CPU 调度的最小单位,协程是用户态的轻量级线程"——这句话很多人能背,但真到写代码的时候还是会懵:Python 里开了 4 个线程跑计算为什么一点都不快?async def 定义的函数调用了怎么没反应?await 到底"等"的是什么?

背定义没用,得知道它们运转的机理。这篇文章从操作系统那一层开始,一路讲到 Python 的 async/await 语法糖底下发生了什么,中间所有代码都是真实跑过的,数据是实测的(Python 3.12.13,10 核 Mac)。

进程、线程、协程:三个层面的东西

这三个概念经常被放在一起背,但它们其实不在同一个层面上:进程和线程是操作系统的概念,协程是编程语言/运行时的概念。搞清楚这一层,后面就不容易混。

进程:资源分配的最小单位

每启动一个程序,操作系统就为它划出一块独立的虚拟地址空间,代码段、数据段、堆、栈都在里面。两个进程之间默认互相看不见对方的内存:

text
进程 A                        进程 B
┌─────────────────┐          ┌─────────────────┐
│ 代码段           │          │ 代码段           │
│ 数据段           │          │ 数据段           │
│ 堆              │          │ 堆              │
│ 栈              │          │ 栈              │
└─────────────────┘          └─────────────────┘
   独立地址空间                   独立地址空间
        \                           /
         互不可见,想通信得走内核

这个隔离性是好事——一个进程崩了不会把别的进程带崩。但代价是贵:

  • 创建贵fork 一个进程要复制一堆内核数据结构(页表、文件描述符表等)
  • 切换贵:切换进程要换地址空间(换页表基址寄存器),伴随 TLB(地址翻译缓存)大面积失效,切换后访问内存会慢一截
  • 通信贵:进程间通信(IPC)必须借道内核——管道、消息队列、共享内存、socket,每一样都要在用户态和内核态之间拷贝数据

线程:CPU 调度的最小单位

线程是进程里的执行流。同一进程的所有线程共享地址空间,但各自有独立的栈和寄存器现场:

text
进程
┌──────────────────────────────────┐
│  共享:代码段、数据段、堆、打开的文件   │
│                                    │
│  线程 1:独立栈 + 寄存器             │
│  线程 2:独立栈 + 寄存器             │
│  线程 3:独立栈 + 寄存器             │
└──────────────────────────────────┘

线程切换比进程切换便宜,关键就在"不用换地址空间"——页表不动,TLB 大部分还能用,CPU 缓存命中率也高得多。

但共享内存是双刃剑。两个线程同时写同一个变量,结果不可预料(竞态条件),于是你得加锁;锁用不好,要么死锁,要么把多线程写成变相的单线程。这部分是所有语言的多线程编程共同的痛。

协程:把调度权拿回用户态

线程的调度由操作系统说了算:时钟中断一到,CPU 被夺走,切给另一个线程。这叫抢占式调度——你的代码在任何一条指令之后都可能被切走,所以共享数据处处要防。

协程不一样,它是协作式调度:一个协程只有自己主动让出(yield / await),控制权才会交给别人。不让出,就一直是它的。

这个区别带来几个实际后果:

  1. 切换极便宜。协程切换不陷入内核、没有系统调用,就是保存几个局部变量再跳个转,开销和一次普通函数调用差不多。线程切换在微秒级,协程切换在纳秒级,差着好几个数量级。
  2. 大多数时候不用锁。两次 await 之间的代码不会被别的协程打断,竞态的可能只存在于 await 点上。
  3. 一个阻塞全都堵。协作的前提是守规矩——协程里调了一个阻塞函数,它不让出,同一线程里所有协程都得陪着等。这一点后面讲 asyncio 的坑时会重点说。

一张表对比

维度进程线程协程
谁调度操作系统操作系统用户态代码(事件循环)
调度方式抢占式抢占式协作式
内存独立地址空间共享进程地址空间共享线程地址空间
切换成本高(换页表、TLB 失效)中(换栈和寄存器)极低(一次函数调用级)
数据共享需 IPC,走内核直接共享,但要加锁直接共享,await 点之外无竞态
能用多核看语言实现(Python 有 GIL)不能(单线程内)
量级几十~几百个几百~几千个几万~几十万个

注意最后一行:协程之所以动辄能开几万个,是因为每个协程的开销就是一个栈帧对象(KB 级),而每个线程光栈就要占掉几 MB 虚拟内存。

并发和并行:两个经常被混用的维度

进程、线程、协程回答的是:任务由什么承载、谁来调度、切换成本多高。并发和并行回答的是另一个问题:多个任务在时间上是怎样推进的

所以它们不是同一类名词。不能把“进程、线程、协程、并发、并行”当成五种平级方案来选——前三个是执行载体和调度机制,后两个是多个任务之间的执行关系。

先把定义钉死

并发(concurrency):在一段重叠的时间范围内,多个任务都处于“已经开始但还没结束”的状态,并且都能向前推进。它强调的是同时应对多件事,不要求同一时刻真的有两段代码在运行。

并行(parallelism):在同一个物理时刻,多个执行单元真的在执行不同任务。它强调的是同时做多件事,需要多个 CPU 核、GPU 计算单元或其他能同时工作的硬件资源。

画到 CPU 时间线上,区别一眼就能看出来:

串行、并发和并行的 CPU 时间线对比:串行依次完成;单核并发交替推进;双核并行在同一时刻一起运行
串行、并发和并行的 CPU 时间线对比:串行依次完成;单核并发交替推进;双核并行在同一时刻一起运行
text
串行(一个任务彻底结束,再做下一个):
时间 ──────────────────────────────>
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 时间处理结果。

串行执行时,一个请求等完才发下一个:

text
请求 A: [等待 500ms][处理 10ms]
请求 B:                         [等待 500ms][处理 10ms]
请求 C:                                                  [等待 500ms][处理 10ms]
总耗时约 3 × (500ms + 10ms) = 1.53s

并发执行时,3 段等待可以重叠:

text
请求 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 个进程。

一句话概括:

text
并发提升 IO 密集任务:靠的是“不浪费等待时间”
并行提升 CPU 密集任务:靠的是“增加同时干活的计算资源”

并行为什么很难达到 N 核 N 倍

即使任务适合并行,开 4 个进程通常也拿不到严格的 4 倍加速。因为程序总有一部分不能并行:读取输入、拆分任务、汇总结果、进程通信,这些步骤仍然要串行执行。

Amdahl 定律给出了一个很实用的上限。假设程序中可并行部分占比为 P,使用 N 个执行单元,理论最大加速比是:

text
S(N) = 1 / ((1 - P) + P / N)

如果一个程序只有 80% 能并行,那么用 4 个核的理论上限是:

text
S(4) = 1 / (20% + 80% / 4) = 2.5

哪怕核数无限多,剩下那 20% 的串行部分也把极限锁死在 5 倍。现实还要再扣掉进程创建、任务调度、IPC、锁竞争、CPU 缓存失效和内存带宽争用,所以后面实测的 4 进程是 3.2 倍,而不是整整 4 倍,这是正常结果,不是多进程“没跑满”。

任务粒度也很关键。把 1ms 的计算拆给进程池,光序列化参数、跨进程传输和调度可能就花掉不止 1ms;把几秒钟的大块计算拆出去,通信成本占比才足够小。并行适合计算量大、任务之间依赖少、拆分后粒度足够粗的工作。

并发度不是越高越好

“能创建 10 万个协程”不等于“应该同时发出 10 万个请求”。并发度描述的是同一时间有多少任务在途,吞吐量描述的是单位时间完成多少任务,延迟描述的是一个任务从开始到完成要多久——这是三个不同指标。

在系统稳定时,它们大致符合 Little 定律:

text
平均在途任务数 = 平均吞吐量 × 平均响应时间
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,另一个协程就可能插进来:

python
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 不是原子操作,多线程同时改一个对象的计数,不加保护就会改坏——轻则内存泄漏,重则直接段错误。

怎么保护?两条路:

  1. 给每个对象的计数操作加锁(或用原子指令)——粒度细,能并行,但每一次赋值、每一次传参都伴随着锁操作,单线程性能被拖垮。
  2. 干脆整一把大锁,谁拿到锁谁跑字节码——实现简单,单线程零额外开销。

CPython 选了第二条。这不是没人试过第一条:1999 年 Greg Stein 就做过一版去掉 GIL 的 free-threaded 补丁,靠细粒度锁保护引用计数,结果单线程性能掉了约 40%,社区没接受。之后二十多年,GIL 就这么留了下来,成了 CPython 最著名的"特性"。

GIL 什么时候会释放

GIL 不是焊死的,它有两种让出的时机:

  1. 遇到 IO 操作时。线程调用阻塞式系统调用(读文件、等网络、甚至 time.sleep)之前,解释器会主动释放 GIL——反正这个线程要干等了,不如让别人跑。
  2. 每隔一小段时间强制切换。默认每 5 毫秒(可以用 sys.getswitchinterval() 查看)检查一次,如果别的线程在等锁,就切过去。
python
>>> import sys
>>> sys.getswitchinterval()
0.005

这两条规则合起来,就决定了 Python 多线程的命运:

  • IO 密集型任务:线程大部分时间在等 IO,GIL 频繁被释放,多线程能有效并发。
  • CPU 密集型任务:线程们谁也不等 IO,就在那抢一把锁,每 5ms 换一次手。多核完全用不上,还白白搭上切换开销。

实测:CPU 密集型,多线程基本是白搭

光说不练没意义,直接跑数据。任务:计算 40 万以内有多少个质数(纯计算,无任何 IO),跑 4 份,分别用单线程串行、4 线程、4 进程、4 协程:

python
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.26s1.0x
多线程 x41.24s几乎没快
多进程 x40.40s快了 3.2 倍
协程 x41.29s反而略慢

数据把 GIL 的影响展示得明明白白:4 个线程跑 CPU 密集任务,和串行一模一样——10 个核闲着 9 个,字节码还是一行一行排队执行。而多进程因为每个进程有自己独立的解释器和 GIL,4 个进程真正并行跑在 4 个核上,直接拿到 3 倍多的提速。

协程在这个场景也是零提升(还多了一层调度开销),原因在讲 asyncio 的坑时细说。

实测:IO 密集型,多线程是真有效

换个任务:模拟 8 次 IO,每次等待 0.5 秒:

python
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
多线程 x80.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 的每一个设计。

生成器天生就是半个协程

协程需要的核心能力是什么?执行到一半能暂停,之后还能从暂停点继续。而生成器天生就会这个:

python
def gen():
    print("第一步")
    yield 1          # 暂停在这,把 1 交出去
    print("第二步")
    yield 2

g = gen()            # 注意:什么都不会打印,函数体一行都没执行
next(g)              # 打印"第一步",返回 1,暂停在第一个 yield
next(g)              # 打印"第二步",返回 2

两个关键点:

  1. 调用生成器函数不会执行函数体,只是造出一个生成器对象——后面会看到,async def 的函数也是这个脾气。
  2. yield 把"暂停现场、交出控制权"这件事变成了一个语法元素。

手写一个 30 行的迷你事件循环

光一个生成器会暂停还不够,协程的威力在于多个任务交替执行。其实调度器简单得超乎想象——轮流驱动每个生成器往前走一步就行:

python
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)])

跑出来的输出:

text
[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 解决了这个委托问题:

python
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 值。

把它和今天的语法对照一下,会发现惊人地像:

python
result = yield from subtask()   # Python 3.3 的写法
result = await subtask()        # Python 3.5+ 的写法

await 的前身就是 yield from,语义几乎一一对应:挂起自己、驱动子协程、等它完成、拿到返回值。

演化时间线

把整条线串起来:

text
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 defawait 两个关键字让异步代码的意图一目了然。你去看一些老代码里的 @asyncio.coroutine 装饰器配 yield from,那就是演化中途的形态,功能上没问题,但早就该换新写法了。

async/await 到底在做什么

语法好认,机理未必清楚。这一节把 asyncawait 拆开看。

async def:定义的是"可执行",不是"执行"

新手最常踩的坑就是这个——调用一个 async 函数,什么都不会发生:

python
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.runcreate_task)。没有驱动的协程对象就是个死物。

如果你检查一下协程对象,会发现它身上带着 __await__sendthrow 这几个方法——这套接口和生成器一脉相承,驱动协程向前走的底层动作就是 send

await 的三层含义

await x 这一行,语义上其实做了三件事:

text
1. 挂起当前协程:保存局部变量和执行位置(保存现场)
2. 把控制权还给事件循环:循环可以去驱动别的任务了
3. 等 x 完成后:从挂起点恢复执行,await 表达式的值就是 x 的返回值

第二点是灵魂。await 就是协程主动让出控制权的点,对应前面说的协作式调度里的"让出"。一个协程里一个 await 都没有,那它从被驱动到跑完,事件循环里其他任务一步都走不了。

看一段代码感受一下让出的时机:

python
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 后面不是随便什么都能接的。试一下:

python
>>> await 42
TypeError: object int can't be used in 'await' expression

能被 await 的对象叫 awaitable,判定标准就一条:实现了 __await__ 方法,且该方法返回一个迭代器。Python 里内置的 awaitable 有三种:协程(coroutine)、任务(Task)、Future。

这意味着你甚至可以自己造一个能 await 的类:

python
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)工作。它每一轮做的事大致是:

text
事件循环的一次转动:

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 秒:

python
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() 这类阻塞调用。要么换异步生态的库(aiohttphttpxasyncpgaiofiles),要么用下面的 to_thread 兜底。

坑 2:调了 async 函数却忘了 await

python
async def fetch():
    return "data"

result = fetch()        # 得到的只是一个 coroutine 对象,什么都没发生

这行代码不会报错,函数体也不会执行,只会在对象被垃圾回收时收到一条 RuntimeWarning: coroutine 'fetch' was never awaited。如果 fetch 里有重要逻辑(写库、发请求),那就是静默的业务丢失,排查起来很隐蔽。好在现在主流 IDE 和 linter 都能对"未 await 的协程"给出警告,值得打开。

正确姿势速查

日常高频用到的就这几个:

asyncio.gather:并发跑一批,等全部完成

python
results = await asyncio.gather(task1(), task2(), task3())
# 并发执行;结果按传入顺序排列(不是按完成顺序)

asyncio.create_task:登记到事件循环,主协程继续走

python
task = asyncio.create_task(background_job())  # 立刻登记,事件循环一有机会就调度它
# ... 干别的
result = await task  # 需要结果时再 await

如果连结果都不要(fire-and-forget,点火后不管),那不 await 就行:

python
async def main():
    asyncio.create_task(write_log())  # 不等它,主协程继续走
    await do_something_else()         # 主协程还活着,后台任务就能跑

看着简单,实测下来有三个坑得知道。

坑一:主协程先结束,后台任务会被取消,但不是被立刻“硬杀”。 下面这份脚本可以直接复制运行。它把四个最容易混淆的情况放在一起;每一段都单独调用一次 asyncio.run,这样才能观察主协程返回时,尚未完成的任务到底会发生什么:

python
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. 第 1 组:取消是协作式的。 run_cancellation_case() 先让出 0.1 秒,cancelled_job() 因而能启动并挂在 sleep(10) 上。主协程返回后,asyncio.run 向它发起取消;它在这个挂起点收到 CancelledError,执行 finally,然后 asyncio.run 才返回。正常业务分支里的“正常结束”不会执行。
  2. 第 2 组:一次取消不会让后续每个 await 自动失败。 原来的取消已经以 CancelledError 的形式传进任务,finally 里的 await asyncio.sleep(0.5) 仍能完成,asyncio.run 会等这段异步清理结束。只有又来了新的取消请求,异步清理才可能再次被打断。
  3. 第 3 组:吞掉取消会拖住退出。 捕获 CancelledError 后不再 raise,任务就能继续运行;asyncio.run 不会硬杀它,而是等它自己结束。要是它一直不结束,程序退出也会一直卡住。
  4. 第 4 组:需要任务跑完就显式等待。 在主协程返回前 await asyncio.gather(*tasks),所有后台任务会自然收尾;这时 asyncio.run 不需要取消它们。

所以,“任务被取消”不是“任务瞬间消失”:它的正常业务逻辑不会继续跑,但仍有机会感知取消并执行清理。“点火后不管”的前提是事件循环得活得比任务久。Web 服务里事件循环的生命周期由框架托管,通常没问题;一次性脚本里就得自己保证主协程别提前跑完。

坑二:没人持有引用的任务,理论上可能被 GC 回收。 Python 官方文档对 create_task 有一条警告:事件循环对任务只持有弱引用,代码不保存引用的话,任务可能"中途消失"。推荐用一个集合持有强引用,任务跑完自动移出:

python
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 打一条日志,业务层面完全无感:

text
Task exception was never retrieved
future: <Task finished ... exception=ValueError('后台任务炸了')>

生产代码里应该用 done_callback 把异常捞出来记日志:

python
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:把同步阻塞库扔进线程池

python
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:程序入口,一个进程调一次

python
async def main():
    ...

asyncio.run(main())   # 创建事件循环 -> 驱动 main -> 循环关闭

多进程、多线程、协程:到底怎么选

把前面所有实测数据放在一起:

场景串行多线程多进程协程
CPU 密集(质数计算 x4)1.26s1.24s0.40s1.29s
IO 密集(8 次 IO x0.5s)4.03s0.51s0.50s

决策逻辑其实就三条:

CPU 密集 -> 多进程。GIL 之下线程并行不了字节码,只能靠多个解释器进程分摊。顺带一提,numpypandas 这类 C 扩展在做大块数值计算时会主动释放 GIL,所以"数值计算多线程"有时确实有效——但那是 C 层的功劳,别把它当成 Python 多线程的普遍能力。

IO 密集、并发量不大 -> 多线程够用。几个到几十个并发任务,写法和同步代码几乎一样,不用改造生态,心智成本最低。

IO 密集、高并发 -> 协程。连接数上到成千上万时,线程的内存和切换成本撑不住(一个线程几 MB 栈空间,一万个线程就是几十 GB),协程一个才 KB 级,单线程就能轻松 hold 住。代价是整个调用链都得是异步生态。

实际生产里经常是组合拳。比如 Python Web 服务的经典部署:Gunicorn 起多个 worker 进程(吃满多核),每个 worker 用 uvicorn 跑 asyncio 事件循环(进程内高并发 IO)。多进程负责利用 CPU,协程负责扛住连接,各干各的强项。

小结

一路捋下来,核心就这几条:

  1. 进程隔离资源,线程共享内存,协程把调度拿回用户态。三者是不同层面的方案,不是谁替代谁的关系。
  2. GIL 是 CPython 引用计数内存管理的保护锁,它让 CPU 密集的多线程彻底失效,但 IO 密集时线程会在等待中释放锁,多线程依然有效。Python 3.13 的 free-threading 正在拆掉这把锁,但生态成熟还需要时间。
  3. 协程从生成器演化而来yield 提供了"暂停/恢复",yield from 提供了委托,async/await 只是让这套机制有了专属语法。理解 await 的最好方式就是记住它的前身 yield from
  4. await 的本质是挂起并让出控制权,事件循环靠 IO 多路复用(epoll/kqueue/IOCP)在单线程里调度成千上万的协程。
  5. 协程的并发只发生在 await 点上——在协程里调阻塞函数,等于一个人堵死整条流水线,这是 asyncio 最高发的事故。

最后给个一句话选型:

text
CPU 密集                    -> 多进程
IO 密集、并发量小、图省事     -> 多线程
IO 密集、高并发              -> 协程(async/await)
阻塞库混进异步代码           -> asyncio.to_thread 兜底

记住"await 就是让出控制权的点"这一句,asyncio 的大部分行为和坑都能自己推出来。


系列导航

下一篇:FastAPI 并发实战:进程、线程、协程在一个框架里的分工

分享
← 返回博客列表
🎁 有邀请福利哦,点击查看
🎁