Python 协程调度:从 yield 到 async/await,理解协程的运行时机制

一、协程的困惑:为什么 async def 不是多线程
Python 的协程(coroutine)是最容易被误解的并发机制。很多人以为 async def 就是"自动多线程",但实际上协程是单线程的——同一时刻只有一个协程在执行,协程之间的切换是协作式的(在 await 点主动让出),而非抢占式的(操作系统强制切换)。
协程的核心价值不是并行计算,而是高效等待:当一个协程在等待 IO(网络请求、数据库查询)时,它可以主动让出执行权,让事件循环调度其他协程运行。等待结束后,事件循环再恢复该协程。这样,单线程就能处理数千个并发连接,而每个线程的内存开销只有 2KB(vs 线程的 8MB)。
二、Python 协程的演进:从生成器到原生协程
flowchart TB
A[Python 协程演进] --> B[生成器 yield]
B --> C[yield from 委托]
C --> D[async/await 原生协程]
B --> B1[Python 2.5: yield 暂停函数执行]
C --> C1[Python 3.3: yield from 委托子生成器]
D --> D1[Python 3.5: async def + await]
D --> E[事件循环 asyncio]
E --> E1[注册协程到循环]
E --> E2[调度: await 时切换]
E --> E3[IO 就绪后恢复]
style D fill:#ff6b6b,color:#fff
style E fill:#4d96ff,color:#fff
三代协程的对比:
- yield 生成器:函数中使用
yield暂停执行,调用方通过send()恢复。本质是迭代器,但可以用来实现简单的协程。缺点是语法不直观,无法区分"生成数据"和"暂停执行"。 - yield from 委托:允许生成器将部分操作委托给子生成器,简化了协程的组合。但仍基于生成器,语义不够清晰。
- async/await 原生协程:Python 3.5 引入,
async def定义协程函数,await等待可等待对象。语法清晰、语义明确,是现代 Python 异步编程的标准。
三、协程调度机制实现
# 从零实现一个简化的事件循环,理解协程调度原理
import time
from collections import deque
from typing import Coroutine, Any
class SimpleEventLoop:
"""简化的事件循环:理解协程调度的核心机制"""
def __init__(self):
self.ready_queue: deque[Coroutine] = deque()
self.running = False
def create_task(self, coro: Coroutine):
"""将协程加入就绪队列"""
self.ready_queue.append(coro)
def run(self):
"""运行事件循环,直到所有协程完成"""
self.running = True
while self.ready_queue:
# 取出就绪队列中的下一个协程
coro = self.ready_queue.popleft()
try:
# 推进协程执行,直到下一个 await 点
# send(None) 等同于 next(coro)
result = coro.send(None)
# result 是 await 表达式返回的值
# 在真实事件循环中,这里会处理 Future/Task
# 简化实现:直接将协程重新加入队列
self.ready_queue.append(coro)
except StopIteration as e:
# 协程执行完毕,返回值在 e.value 中
pass
except Exception as e:
# 协程抛出异常
print(f"协程异常: {e}")
# 简化的 awaitable 对象
class WaitForSeconds:
"""模拟 asyncio.sleep 的简化实现"""
def __init__(self, seconds: float):
self.seconds = seconds
self.start_time = None
def __await__(self):
# __await__ 返回一个迭代器,事件循环通过 send() 驱动
self.start_time = time.time()
while time.time() - self.start_time < self.seconds:
# 还没到时间,让出执行权
yield
# 时间到了,返回 None
return None
# 使用简化事件循环
async def task(name: str, count: int):
for i in range(count):
print(f"[{name}] 步骤 {i+1}/{count}")
await WaitForSeconds(0.1) # 模拟异步等待
print(f"[{name}] 完成!")
return f"{name} done"
loop = SimpleEventLoop()
loop.create_task(task("A", 3))
loop.create_task(task("B", 2))
loop.run()
# 输出:A 和 B 交替执行,而非顺序执行
# 真实事件循环的关键机制 — asyncio 源码级理解
import asyncio
import time
# 机制一:Future — 异步结果的容器
async def future_example():
"""Future 是 asyncio 的底层原语,代表一个未来的结果"""
loop = asyncio.get_event_loop()
# 创建 Future
future = loop.create_future()
# 模拟异步操作完成后设置结果
loop.call_later(1.0, future.set_result, "操作完成")
# await 等待 Future 完成
result = await future
print(f"Future 结果: {result}")
# 机制二:Task — 协程的调度包装
async def task_example():
"""Task 是对协程的包装,由事件循环调度执行"""
# create_task 将协程包装为 Task 并立即开始调度
task1 = asyncio.create_task(fetch_data("API-1"))
task2 = asyncio.create_task(fetch_data("API-2"))
# 并发执行两个任务
result1, result2 = await asyncio.gather(task1, task2)
print(f"结果: {result1}, {result2}")
async def fetch_data(name: str) -> str:
await asyncio.sleep(0.5)
return f"{name} 数据"
# 机制三:gather vs wait — 批量调度的两种模式
async def gather_vs_wait():
"""gather 和 wait 的区别"""
tasks = [fetch_data(f"API-{i}") for i in range(5)]
# gather:等待全部完成,保持顺序
results = await asyncio.gather(*tasks)
print(f"gather 结果顺序: {results}")
# wait:更灵活,可指定返回条件
done, pending = await asyncio.wait(
tasks,
timeout=2.0, # 超时控制
return_when=asyncio.FIRST_COMPLETED # 最早完成时返回
)
print(f"wait 最早完成: {len(done)} 个")
# 机制四:协程取消
async def cancellation_example():
"""协程取消与清理"""
task = asyncio.create_task(long_running_operation())
# 1 秒后取消
await asyncio.sleep(1.0)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("任务已取消")
async def long_running_operation():
try:
for i in range(100):
await asyncio.sleep(0.1)
print(f"处理中... {i}")
except asyncio.CancelledError:
# 清理资源
print("收到取消信号,清理资源...")
raise # 重新抛出,让调用方知道任务已取消
# 协程调度性能对比 — 协程 vs 线程 vs 进程
import asyncio
import time
import threading
import multiprocessing
async def async_io_task():
"""协程 IO 任务"""
await asyncio.sleep(0.01)
return 1
def thread_io_task():
"""线程 IO 任务"""
time.sleep(0.01)
return 1
def benchmark():
"""对比三种并发模型的吞吐"""
task_count = 1000
# 协程
start = time.time()
async def run_async():
tasks = [async_io_task() for _ in range(task_count)]
await asyncio.gather(*tasks)
asyncio.run(run_async())
async_time = time.time() - start
# 线程
start = time.time()
threads = [threading.Thread(target=thread_io_task) for _ in range(task_count)]
for t in threads:
t.start()
for t in threads:
t.join()
thread_time = time.time() - start
print(f"协程: {async_time:.3f}s, 线程: {thread_time:.3f}s")
# 典型输出:协程 0.02s, 线程 1.5s
# 协程快 70 倍,因为线程的创建和切换开销远大于协程
四、协程调度的工程陷阱
阻塞调用杀死事件循环:在 async 函数中调用 time.sleep()、requests.get()、subprocess.run() 等同步阻塞函数,会阻塞整个事件循环,所有协程都无法推进。必须用 asyncio.sleep()、aiohttp、asyncio.create_subprocess_exec() 替代,或用 asyncio.to_thread() 将阻塞调用移到线程池。
协程泄漏:asyncio.create_task() 创建的 Task 如果没有被 await,其异常会被静默吞掉。解决方案是在 Python 3.11+ 中使用 TaskGroup,它会自动等待所有子任务完成并传播异常。
GIL 下的 CPU 密集型:协程无法利用多核——Python 的 GIL 限制了同一时刻只有一个线程执行 Python 代码。CPU 密集型任务应使用 ProcessPoolExecutor + asyncio.get_event_loop().run_in_executor(),将计算移到独立进程中。
事件循环的线程安全:asyncio 的事件循环不是线程安全的。从其他线程向事件循环提交任务,必须使用 asyncio.run_coroutine_threadsafe(),而非直接 create_task()。跨线程操作事件循环是 Bug 的高发区。
五、总结
Python 协程调度的核心原则:单线程协作式调度、await 是让出点、阻塞操作必须隔离。落地建议:
- 理解机制:协程是单线程的,await 是主动让出,事件循环负责调度。这不是魔法,是协作式并发。
- 消灭阻塞:async 函数中禁止同步 IO,用
asyncio.to_thread()隔离无法避免的阻塞调用。 - 批量调度:用
gather并发等待全部、wait灵活控制返回条件、TaskGroup自动管理生命周期。 - CPU 密集型:用
ProcessPoolExecutor将计算移到进程池,协程只负责调度和 IO。
协程不是万能的并发方案,但在 IO 密集型场景中,它是 Python 最轻量、最高效的选择。理解调度机制,才能写出正确的异步代码。
1132

被折叠的 条评论
为什么被折叠?



