Python

Python yield 关键字详解:从生成器到 asyncio 协程的底层逻辑

深入剖析 yield 的“暂停-恢复”机制,厘清 yield、yield from、async/await 的演进关系,并揭示 asyncio 事件循环如何利用 yield 实现单线程高并发。

Python yield 关键字详解:从生成器到 asyncio 协程的底层逻辑

一句话核心定义

yield 是一个“暂停并返回”的关键字。 当函数执行到 yield 时,它会暂停当前函数的运行,把 yield 后面的值返回给调用者,并保留当前函数的所有状态(变量、指令指针等)。当下次调用时,它会从暂停的地方继续执行。


1. 基础对比:return vs yield

为了直观理解,先看对比:

特性returnyield
执行结果返回一个值,函数彻底结束。返回一个生成器对象,函数暂停而非结束。
内存占用一次性生成所有数据,占用大内存。惰性求值(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, 1

4. 进阶用法: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 b

yield 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))  # 1

7. 实际应用场景(为什么重要)

场景为什么用 yield
处理超大文件读取几十GB的日志文件,逐行 yield,不会一次性把内存撑爆。
无限序列生成斐波那契数列、随机数流,理论上无穷无尽,无法用列表存储。
流式API数据从WebSocket或Kafka逐条获取数据,边拿边处理。
协程/异步编程asyncio 底层大量使用 yield 实现事件循环的挂起与恢复。
构建管道流将多个处理函数串联(如 source -> filter -> sink),每个环节都是生成器。

8. 经典陷阱与注意事项

  1. 生成器只能遍历一次
    生成器是有状态的,遍历完后无法重置。如果需要重用,只能重新调用生成器函数创建新对象。

    gen = (i for i in range(3))
    print(sum(gen))  # 3
    print(sum(gen))  # 0 (已经耗尽了)
  2. return 在生成器中的作用
    在生成器里写 return 会触发 StopIteration 异常,并可以附带返回值(Python 3.3+)。

    def test():
        yield 1
        return "结束"
        yield 2  # 这行永远不会执行
  3. 不要试图用 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 正式引入了 asyncawait 关键字。

  • async def 代替 def,定义原生协程(Native Coroutine)。
  • await 代替 yield from,表示“等待这个耗时的IO操作完成,并在此处挂起”。

最关键的一点:await 的底层实现,依然依赖 yield from 机制。

asyncio 的库函数(如 asyncio.sleep、网络读写)内部都封装了 yieldyield from。当我们写 await asyncio.sleep(1) 时,实际发生的流程是:

  1. 协程遇到 await,相当于调用了底层的 yield
  2. 协程把控制权 yield(让出)asyncio 的事件循环。
  3. 事件循环把这个协程标记为“睡眠中”,然后去执行其他就绪的协程。
  4. 1秒后,事件循环通过 send()__next__() 唤醒这个协程。
  5. 协程从刚才 yield 的位置继续往下执行。

9.4 代码对比:从 yieldasyncio

为了让你看清演变,我们实现同一个功能(睡眠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 的事件循环本质上维护了三个东西:

  1. 就绪队列(Ready Queue):等待被 next() 唤醒的协程。
  2. 睡眠/等待队列(Waiting Queue):等待IO或计时器的协程(它们通过 yield 挂起在这里)。
  3. 一个 while True 循环(调度器)。

当执行 await socket.read() 时:

  1. 协程调用底层的 yield,并把“我要读数据”这个事件注册到操作系统(如 epoll/select)。
  2. 协程暂停,事件循环切换到下一个协程。
  3. 操作系统检测到数据来了,事件循环拿到回调,通过 coro.send(None) 唤醒刚才那个协程。
  4. 协程从 yield 位置恢复,拿到数据,继续执行。

整个过程,没有线程切换,没有 GIL 锁竞争,全靠 yield 这一下“暂停”完成。

9.6 总结对比表

概念角色yield 的关系
yield汇编指令级的暂停工具提供“暂停并保存状态”的底层能力。
yield from管道/委派工具yield 基础上建立了调用方与子协程的双向通信,是协程调度的雏形。
async/await语法糖await 底层被编译成类似 yield from 的字节码,让异步代码看起来像同步代码。
asyncio调度系统(事件循环)它是一个巨大的 while 循环,专门用来管理成百上千个处于 yield(暂停)状态的任务,决定什么时候 next() 唤醒它们。