Python yield 关键字详解:从生成器到 asyncio 协程的底层逻辑
深入剖析 yield 的“暂停-恢复”机制,厘清 yield、yield from、async/await 的演进关系,并揭示 asyncio 事件循环如何利用 yield 实现单线程高并发。
Python yield 关键字详解:从生成器到 asyncio 协程的底层逻辑
一句话核心定义
yield 是一个“暂停并返回”的关键字。 当函数执行到 yield 时,它会暂停当前函数的运行,把 yield 后面的值返回给调用者,并保留当前函数的所有状态(变量、指令指针等)。当下次调用时,它会从暂停的地方继续执行。
1. 基础对比:return vs yield
为了直观理解,先看对比:
| 特性 | return | yield |
|---|---|---|
| 执行结果 | 返回一个值,函数彻底结束。 | 返回一个生成器对象,函数暂停而非结束。 |
| 内存占用 | 一次性生成所有数据,占用大内存。 | 惰性求值(Lazy Evaluation),边用边生成,省内存。 |
| 执行次数 | 调用一次,执行一次。 | 可以多次 next() 调用,执行多次。 |
| 状态保留 | 不保留局部变量。 | 保留局部变量和执行位置。 |
2. 最简单的例子
def my_generator():
print("开始执行")
yield 1 # 暂停点 1
print("继续执行")
yield 2 # 暂停点 2
print("结束执行")
# 注意:调用函数不会执行代码,而是创建一个生成器对象
gen = my_generator()
print(gen) # <generator object my_generator at 0x...>
# 第一次调用 next()
print(next(gen))
# 输出:
# 开始执行
# 1
# 第二次调用 next()
print(next(gen))
# 输出:
# 继续执行
# 2
# 第三次调用 next(),没有 yield 了,抛出 StopIteration 异常
# print(next(gen)) # StopIteration执行流程图解:
next(gen) -> 执行到 yield 1 -> 返回 1,冻结状态 -> next(gen) -> 从 yield 1 下一行继续执行 -> 执行到 yield 2 -> 返回 2。
3. 生成器的两种使用方式
方式一:使用 next() 手动迭代
如上例所示,适合底层控制。
方式二:使用 for 循环(最常用)
for 循环会自动捕获 StopIteration 异常,非常优雅。
def count_down(n):
while n > 0:
yield n
n -= 1
for i in count_down(5):
print(i)
# 输出:5, 4, 3, 2, 14. 进阶用法:yield 接收数据(双向通信)
yield 不仅可以向外返回数据,还可以从外部接收数据。这通过 gen.send(value) 实现。
def echo():
while True:
# received 会接收外部 send 进来的值
received = yield "等待输入..."
print(f"收到了: {received}")
gen = echo()
# 必须先启动生成器(执行到第一个 yield)
print(next(gen)) # 输出: 等待输入...
# 发送数据进去
print(gen.send("Hello"))
# 输出: 收到了: Hello
# 输出: 等待输入... (下一个 yield 的返回值)注意: 首次调用必须先 next(gen) 或 gen.send(None),让代码执行到第一个 yield,否则无法发送数据。
5. yield from:扁平化迭代(委托生成器)
当你想在一个生成器中调用另一个生成器时,yield from 可以把子生成器的每个值逐个 yield 出去,避免写嵌套循环。
传统写法(繁琐):
def chain1():
for i in range(3):
yield i
for j in 'ab':
yield j使用 yield from(优雅):
def chain2():
yield from range(3) # 等同于 yield 0,1,2
yield from 'ab' # 等同于 yield 'a','b'
for val in chain2():
print(val) # 输出: 0 1 2 a byield from 的真正强大之处在于:它可以作为双向管道,将调用者的 send() 和 throw() 无缝传递给子生成器,常用于协程(Coroutine)的高级编程。
6. 生成器表达式(简易版 yield)
类似于列表推导式,但把 [] 换成 (),就是生成器表达式,省内存。
# 列表推导式:立即生成所有数据,占用内存
list_comp = [x * x for x in range(10)]
# 生成器表达式:惰性计算,不占内存
gen_exp = (x * x for x in range(10))
print(next(gen_exp)) # 0
print(next(gen_exp)) # 17. 实际应用场景(为什么重要)
| 场景 | 为什么用 yield |
|---|---|
| 处理超大文件 | 读取几十GB的日志文件,逐行 yield,不会一次性把内存撑爆。 |
| 无限序列 | 生成斐波那契数列、随机数流,理论上无穷无尽,无法用列表存储。 |
| 流式API数据 | 从WebSocket或Kafka逐条获取数据,边拿边处理。 |
| 协程/异步编程 | asyncio 底层大量使用 yield 实现事件循环的挂起与恢复。 |
| 构建管道流 | 将多个处理函数串联(如 source -> filter -> sink),每个环节都是生成器。 |
8. 经典陷阱与注意事项
-
生成器只能遍历一次
生成器是有状态的,遍历完后无法重置。如果需要重用,只能重新调用生成器函数创建新对象。gen = (i for i in range(3)) print(sum(gen)) # 3 print(sum(gen)) # 0 (已经耗尽了) -
return在生成器中的作用
在生成器里写return会触发StopIteration异常,并可以附带返回值(Python 3.3+)。def test(): yield 1 return "结束" yield 2 # 这行永远不会执行 -
不要试图用
yield实现递归全部展开
递归生成器时记得用yield from,否则只会得到生成器对象本身。
9. 深入底层:yield 与协程、asyncio 的关系
9.1 底层本质:yield 提供了“主动让出CPU”的能力
要实现协程,最核心的问题是:一个任务在执行到一半时,怎么停下来,把执行权交给另一个任务,之后还能接着跑?
- 多线程是靠操作系统强行抢占(抢锁、切换上下文)。
- 协程是靠 “协作式”,即任务自己主动说:“我现在要等数据(IO阻塞),**我主动让出(yield)**CPU,你们先跑。”
yield 在这里扮演的角色就是“让出”。当函数执行到 yield 时,它暂停并返回控制权给调用者。有了这个机制,我们就可以在调用者(调度器)中手动控制多个任务依次执行:
# 用 yield 实现的最简“调度器”雏形
def task1():
while True:
print("任务1 干活")
yield # 主动让出CPU
def task2():
while True:
print("任务2 干活")
yield # 主动让出CPU
# 创建两个协程任务
t1 = task1()
t2 = task2()
# 简陋的事件循环:轮流执行它们
while True:
next(t1) # 唤醒任务1,执行到yield暂停
next(t2) # 唤醒任务2,执行到yield暂停
# 这就是 asyncio 事件循环的最原始雏形!9.2 历史的演进:yield from 打通了“嵌套调用”
早期(Python 3.3之前),用原生 yield 写异步代码非常痛苦,因为如果一个协程要调用另一个协程,需要手动处理嵌套循环。
于是 Python 3.3 引入了 yield from。它不仅仅是为了扁平化,更重要的是它自动建立了一个双向通道:
- 子生成器(子协程)可以接收外部
send()来的数据。 - 子生成器抛出的异常可以直接传给调用者。
这就让**“协程委派”变得可行。此时,yield from 实际上已经是一种微型的协程调度器**了。
9.3 现代形态:asyncio + async/await(语法糖)
虽然 yield from 能实现协程,但代码看起来混乱(既有生成器逻辑,又有协程逻辑),容易写错。
于是 Python 3.5 正式引入了 async 和 await 关键字。
async def代替def,定义原生协程(Native Coroutine)。await代替yield from,表示“等待这个耗时的IO操作完成,并在此处挂起”。
最关键的一点:await 的底层实现,依然依赖 yield from 机制。
asyncio 的库函数(如 asyncio.sleep、网络读写)内部都封装了 yield 或 yield from。当我们写 await asyncio.sleep(1) 时,实际发生的流程是:
- 协程遇到
await,相当于调用了底层的yield。 - 协程把控制权
yield(让出) 给asyncio的事件循环。 - 事件循环把这个协程标记为“睡眠中”,然后去执行其他就绪的协程。
- 1秒后,事件循环通过
send()或__next__()唤醒这个协程。 - 协程从刚才
yield的位置继续往下执行。
9.4 代码对比:从 yield 到 asyncio
为了让你看清演变,我们实现同一个功能(睡眠1秒后打印):
阶段一:纯 yield 实现(极其原始)
import time
def sleep(seconds):
start = time.time()
while time.time() - start < seconds:
yield # 死循环检查时间,不断让出CPU,极其低效
yield "醒了"
def main():
coro = sleep(1)
while True:
result = next(coro)
if result:
print(result)
break阶段二:asyncio 实现(现代标准)
import asyncio
async def my_coroutine():
print("开始睡")
await asyncio.sleep(1) # await 底层就是 yield,但由事件循环高效驱动
print("醒了")
asyncio.run(my_coroutine())9.5 终极内核:asyncio 是如何利用 yield 的?
asyncio 的事件循环本质上维护了三个东西:
- 就绪队列(Ready Queue):等待被
next()唤醒的协程。 - 睡眠/等待队列(Waiting Queue):等待IO或计时器的协程(它们通过
yield挂起在这里)。 - 一个 while True 循环(调度器)。
当执行 await socket.read() 时:
- 协程调用底层的
yield,并把“我要读数据”这个事件注册到操作系统(如 epoll/select)。 - 协程暂停,事件循环切换到下一个协程。
- 操作系统检测到数据来了,事件循环拿到回调,通过
coro.send(None)唤醒刚才那个协程。 - 协程从
yield位置恢复,拿到数据,继续执行。
整个过程,没有线程切换,没有 GIL 锁竞争,全靠 yield 这一下“暂停”完成。
9.6 总结对比表
| 概念 | 角色 | 与 yield 的关系 |
|---|---|---|
yield | 汇编指令级的暂停工具 | 提供“暂停并保存状态”的底层能力。 |
yield from | 管道/委派工具 | 在 yield 基础上建立了调用方与子协程的双向通信,是协程调度的雏形。 |
async/await | 语法糖 | await 底层被编译成类似 yield from 的字节码,让异步代码看起来像同步代码。 |
asyncio | 调度系统(事件循环) | 它是一个巨大的 while 循环,专门用来管理成百上千个处于 yield(暂停)状态的任务,决定什么时候 next() 唤醒它们。 |