「Python 进阶之路」系列 Day19
写在前面
Day17、Day18 分别讲了多线程和多进程,两者都是"重量级"的并发方案------线程有 GIL 限制,进程有创建开销和内存隔离。面对成千上万个 I/O 密集型任务(比如同时发出几千个网络请求),这两种方案都不够轻便。今天讲的 asyncio 协程,就是专门为这种场景设计的更轻量的并发方式。
一、是什么:协程、event loop 与 await
- 协程(coroutine) :用
async def定义的函数。调用它不会立即执行函数体,而是返回一个协程对象(类似 Day08 讲的生成器) - event loop(事件循环) :asyncio 的核心调度器,在单线程内切换执行多个协程------谁在等待 I/O 就先切到别的协程去执行,等 I/O 完成再切回来
- await :暂停当前协程的执行,把控制权交还给 event loop,直到
await的对象完成
python
import asyncio
async def say_hello():
print("hello")
return "done"
result = say_hello() # 只是调用,不会打印hello!
print(type(result)) # <class 'coroutine'>
# 必须用 asyncio.run() 或者在另一个协程里 await 它,才会真正执行
r = asyncio.run(say_hello())
print(r) # hello(先打印) done(返回值)
二、为什么:协作式调度比线程更轻量
asyncio 本质上是单线程内的并发 ,不是并行------它用的是协作式多任务(cooperative multitasking) :协程代码自己在 await 这个点主动让出控制权,而不是像线程那样由操作系统/GIL 在任意时刻抢占式地切换(Day16/17 讲过 GIL 的切换是不可预测的)。
这带来两个好处:
- 切换开销远小于线程:线程切换需要操作系统参与(保存/恢复线程上下文),协程切换只是函数调用级别的操作,可以轻松开出成千上万个协程,而线程数量一旦上百就会有明显的内存和调度开销(呼应 Day17)
- 切换时机是可预测的 :协程只会在
await这一行被切走,其他代码是"原子"执行的------这意味着很多场景下不需要像多线程那样处处加锁,因为你能精确知道哪些点可能被切换
三、怎么用
1. 顺序 await vs 并发执行的耗时对比
python
async def fake_io(name, seconds):
print(f"{name} 开始等待 {seconds}s")
await asyncio.sleep(seconds)
print(f"{name} 完成")
return name
async def sequential():
await fake_io("A", 1)
await fake_io("B", 1)
# 耗时 2.00s ------ 一个接一个地等
async def concurrent_version():
await asyncio.gather(fake_io("C", 1), fake_io("D", 1))
# 耗时 1.00s ------ 两个"同时"在等
实测:顺序 await 两次耗时 2.00s,用 asyncio.gather 并发执行两个协程只需要 1.00s。原因是 await asyncio.sleep(1) 并不会真的阻塞整个程序------协程在这里让出控制权,event loop 趁这个空档去推进另一个协程,两个协程的等待时间就重叠在了一起。
2. 常见坑:在协程里误用阻塞式 sleep
python
async def bad_io(name, seconds):
print(f"{name} 开始(阻塞式sleep)")
time.sleep(seconds) # ❌ 错误!这是阻塞整个event loop的
print(f"{name} 完成")
return name
async def bad_concurrent():
await asyncio.gather(bad_io("E", 1), bad_io("F", 1))
实测耗时 2.01s------和顺序执行没有任何区别,完全没有拿到并发的好处。原因是 time.sleep() 是同步阻塞调用,它不会主动让出控制权,会把整个线程(也就是这个 event loop 所在的唯一线程)死死占住,其他协程根本没有机会被推进,直到这次 sleep 结束。在协程里必须用 await asyncio.sleep()(或其他专门的异步 I/O 库)而不是同步阻塞调用,否则 asyncio 的并发优势完全发挥不出来。
3. 协作式调度的好处:没有 await 就不会被切换
python
shared = []
async def append_task(name):
shared.append(f"{name}-开始")
# 中间没有await,这一段不会被切走
shared.append(f"{name}-结束")
async def run_two():
await asyncio.gather(append_task("X"), append_task("Y"))
asyncio.run(run_two())
print(shared)
# ['X-开始', 'X-结束', 'Y-开始', 'Y-结束'] ------ X的两步连续出现,不会和Y交叉
因为 append_task 函数体内没有任何 await,协程调度器根本没有机会在这两行之间切换到别的协程去------X-开始 和 X-结束 必然是连续的,不会被 Y 插进来。这正是"协程只在 await 点才可能被切换"这条规则的直接体现,也是很多 asyncio 代码不需要显式加锁的原因。
4. 手写极简 event loop 理解底层原理
用生成器 + yield(呼应 Day08)手写一个极简版的调度器,帮助理解 event loop 到底在干什么:
python
def simple_coroutine(name, times):
for i in range(times):
print(f"{name} 执行第{i+1}步")
yield # 主动让出控制权
def simple_event_loop(coros):
coros = list(coros)
while coros:
for co in coros[:]:
try:
next(co) # 推进一步
except StopIteration:
coros.remove(co)
simple_event_loop([simple_coroutine("任务1", 2), simple_coroutine("任务2", 2)])
# 任务1 执行第1步
# 任务2 执行第1步
# 任务1 执行第2步
# 任务2 执行第2步
输出是交替进行的------simple_event_loop 就是一个最简化的"事件循环":轮流推进每个"协程"(这里用生成器模拟)一步,遇到 yield 就让出,直到 StopIteration 说明这个协程跑完了才移出队列。真正的 asyncio event loop 原理复杂得多(要处理真正的 I/O 事件、定时器等),但核心思想就是这样"轮流推进,谁让出就换下一个"。
四、面试追问
Q1:什么是协程,和线程有什么区别?
协程是用 async def 定义、能在 await 点主动让出执行权的函数,调用它会返回一个协程对象,需要驱动才会真正执行。线程由操作系统/GIL 抢占式调度,切换时机不可预测;协程是协作式调度,只在代码里明确写了 await 的地方才可能被切换,且协程切换是函数调用级别的开销,远小于线程切换。
Q2:event loop 是什么,是怎么工作的?
event loop 是 asyncio 的核心调度器,在单线程内维护多个协程的执行进度。它轮流推进每个协程,直到某个协程遇到 await 让出控制权,就转去推进别的协程;等某个协程等待的事情(比如网络响应)完成,就把它重新排入队列继续执行。
Q3:为什么协程适合 I/O 密集型任务,且比多线程更轻量?
I/O 等待期间协程会主动让出控制权,让 event loop 去推进别的协程,多个任务的 I/O 等待时间可以重叠进行------实测两个各等待 1 秒的任务并发执行只需 1 秒,而不是顺序执行的 2 秒。此外协程切换不需要操作系统参与,开销远小于线程切换,可以轻松开出远超线程数量级的并发协程。
Q4:在 async 函数里用 time.sleep() 而不是 await asyncio.sleep() 会有什么问题?
time.sleep() 是同步阻塞调用,不会主动让出控制权,会把整个 event loop 所在的线程完全占住。实测两个各"睡眠"1 秒的协程如果用 time.sleep(),并发执行耗时依然是 2 秒,和顺序执行没有区别,asyncio 的并发优势完全没有发挥出来,必须换成 await asyncio.sleep() 或其他真正异步的 I/O 调用。
Q5:协程是并行还是并发?
是单线程内的并发,不是并行------同一时刻依然只有一段代码在真正执行,只是通过在 I/O 等待点主动切换,让多个任务的等待时间重叠起来,从而缩短总耗时。这和 Day18 讲的多进程真正利用多核 CPU 并行计算是完全不同的概念:多进程解决的是"同时算",协程解决的是"同时等"。
下一篇预告
Day20 讲 asyncio 进阶------Task、asyncio.gather 到底是怎么配合 event loop 工作的,以及并发数量很大时该怎么用 Semaphore 控制并发上限,避免一次性发起几千个请求把目标服务器打垮。