从 Python 字节码与栈帧的层面来理解 yield 的机制

从 Python 字节码与栈帧的层面来理解 yield 的机制

获取字节码的堆栈(Stack Frame 栈帧)

def foo():
    bar()

def bar():
    pass

>>> import dis
>>> dis.dis(foo)
  2           0 LOAD_GLOBAL              0 (bar)
              2 CALL_FUNCTION            0
              4 POP_TOP
              6 LOAD_CONST               0 (None)
              8 RETURN_VALUE

字节码与栈帧的一些属性

字节码通常是由 compile() 函数返回的代码对象,表示原始的字节编译可执行代码。它不包含任何上下文,也不会保存任何默认参数的信息。一般而言一行字节码只会保存诸如被引用的变量名,当前的变量名,代码所在的文件名,变量个数等等

而栈帧是字节码形成的堆栈,用于表示一个执行帧,栈帧会保存很多信息,如命名空间的局部/全局字典,当前正在执行的字节码信息,索引指针等等


__code__.co_flags 解释器 flags 属性(字节码属性,见附录)

def foo():
    result = yield 111
    print(f'result of yield: {result}')
    result2 = yield 222
    print(f'result of 2nd yield: {result2}')
    return 'done'

>>> import dis
>>> dis.dis(foo)
  2           0 LOAD_CONST               1 (111)
              2 YIELD_VALUE
              4 STORE_FAST               0 (result)

  3           6 LOAD_GLOBAL              0 (print)
              8 LOAD_CONST               2 ('result of yield: ')
             10 LOAD_FAST                0 (result)
             12 FORMAT_VALUE             0
             14 BUILD_STRING             2
             16 CALL_FUNCTION            1
             18 POP_TOP

  4          20 LOAD_CONST               3 (222)
             22 YIELD_VALUE
             24 STORE_FAST               1 (result2)

  5          26 LOAD_GLOBAL              0 (print)
             28 LOAD_CONST               4 ('result of 2nd yield: ')
             30 LOAD_FAST                1 (result2)
             32 FORMAT_VALUE             0
             34 BUILD_STRING             2
             36 CALL_FUNCTION            1
             38 POP_TOP

  6          40 LOAD_CONST               5 ('done')
             42 RETURN_VALUE

每一个 Python 的方程的对象都有一个 __code__.co_flags 属性,这是一串预转换为 int 类型的 bin 二进制比特串,用于记录当前方程的种种标识(flags)

生成器的 flags 特征值为 32,即 100000。当编译器在解释一个方程并产生字节码时,如上述的 foo() ,遇到了 yield 关键字后解释器就会将 100000 加至方程的 __code__.co_flags 标识中,然后通过比特运算获取 flags 中包含的种种信息:

验证方式:将 32__code__.co_flags 进行位的与运算 &

>>> bool(foo.__code__.co_flags & 32)
True


调用一个带有 yield 的函数

当调用一个函数时,Python 会首先检查 __code__.co_flags 来识别函数的属性(flags)。所以在调用 foo() 函数时 Python 通过查询属性得知它是一个生成器,所以它并不会执行函数,而是返回一个生成器对象

>>> gen = foo()
>>> type(gen)
<class 'generator'>

该生成器对象会封装一个字节码堆栈,并通过属性 gi_code.co_name (字节码属性,见附录)记住原始函数名(reference):

>>> gen.gi_code.co_name
'foo'

带有 yield 的函数有一个非常特别的特点(以函数 foo() 为例):由函数 foo() 生成的所有生成器实例都会拥有相同的代码,但却拥有相互独立的字节码堆栈。并且生成器的栈帧不存在于当前 Python 程序的栈帧中:它们是存在于内存堆上时刻等待被调用


生成器字节码的 “last instruction 最终指令” f_lasti 指针(栈帧属性,见附录)

每一个栈帧都拥有一个最终指令指针,指向该栈帧中最后被调用的那个指令。它的初始值是 -1。对于生成器的栈帧而言,意味着这是一个新的,尚未使用的生成器

>>> gen.gi_frame.f_lasti
-1

当我们第一次使用 send 命令时,生成器会开始运作,直到它到达第一个 yield 后停止。此时我们可以获得 send 的返回值 111,因为这个是生成器 gen 传递给 yield 的的值

此时我们再此查看最终指令指针:

>>> gen.send(None)  # 在生成器运行之前只能够 send(None)
111  # 得到第一个 yield 的返回值 111
>>> gen.gi_frame.f_lasti
2    # 指针限制指向了第 2 个字节码
>>> len(gen.gi_code.co_code)
44   # 目前总共执行了 44 个字节的 Python 代码


生成器的恢复执行

生成器再暂停后可以在任何方程内的任何时间恢复运行:因为生成器的栈帧它不是真正地存在于整个 Python 程序的栈帧中,它是在内存堆中,所以它无需遵守普通方程的堆栈,可以在任何时候被执行

现在可以尝试再次使用 send 命令,现在这次发送的数据成为了第一个 yield 表达式的值,并赋值给变量 result。然后生成器继续执行,直到遇到第二个 yield

>>> gen.send('hello')
result of yield: hello  # send 的数据成为了第一个 yield 的表达式的值,并赋值给 result
222  # 得到了第二个 yield 的返回值

可以通过 gi_frame.f_locals 查看局部变量值

>>> gen.gi_frame.f_locals
{'result': 'hello'}

然后我们再次发送 send 命令:

>>> gen.send('goodbye')
result of 2nd yield: goodbye  # send 的数据成为了第二个 yield 的表达式的值,并赋值给 result2
Traceback (most recent call last):
  File "<input>", line 1, in <module>
StopIteration: done    # foo() 函数的返回值


附录:

字节码的常见属性:

  • co_name:函数名
  • co_argcount:参数个数(包括默认值)
  • co_nlocals:函数使用的局部变量个数
  • co_varname:函数使用的局部变量名的元组,包含所有当前局部变量
  • co_cellvars:包含闭包中所引用的变量名的元组
  • co_freevars:包含嵌套函数所使用的自由变量名的元组
  • co_code:表示原始字节码的字符串
  • co_consts:包含字节码所用字面量的元组
  • co_names:包含字节码所用名称的元组
  • co_filename:被编译的代码所在的文件名
  • co_firstlineno:函数所在行号
  • co_lnotab:字符串编码字节码相对于行号的偏移
  • co_stacksize:所需的堆栈大小
  • co_flags:解释器 flag

栈帧的常见属性:

  • f_back:之前的栈帧
  • f_code:当前正在执行的字节码内容
  • f_locals:局部变量字典
  • f_globals:全局变量字典
  • f_builtins:内置名称字典
  • f_lineno:当前行号
  • f_lasti:当前索引指针(指针会随着执行的顺序逐个扫描当前栈帧中的字节码),返回当前行号
编辑于 2018-05-21