楔子
上一篇文章我们介绍了生成器的基本概念和相关用法,如果一个函数内部出现了 yield 关键字,那么它就是生成器函数,调用之后会返回生成器。生成器可以通过 yield 关键字暂停执行,并且还可以通过 __next__ 方法从上一次暂停的位置重新恢复执行。
关于普通函数和生成器函数,我们举一个生动的例子。
普通函数可以想象成一匹马,只要调用了,那么不把里面的代码执行完毕誓不罢休,而函数内部的 return xxx,就是调用之后的返回值。
生成器函数则好比一头驴,调用的时候并没有动,只是返回一个生成器对象,然后需要每次拿鞭子抽一下(调用一次 __next__),才往前走一步。通过不断地驱动生成器,最终将里面的代码执行完毕,然后将设置了返回值的 StopIteration 抛出来。
另外我们也可以把生成器看成是可以暂停的函数,其中 yield 就类似于 return,只不过可以有多个 yield。当执行到一个 yield 时,将值返回、同时暂停在此处。然后当调用 __next__ 驱动时,从暂停的地方继续执行,直到找到下一个 yield。如果找不到下一个 yield,就会抛出 StopIteration 异常。
def gen():
print("生成器开始执行了")
name = "古明地觉"
print("创建了一个局部变量 name")
yield name
age = 16
print("创建了一个局部变量age")
yield age
gender = "female"
print("创建了一个局部变量gender")
yield gender
# 生成器函数也是一个函数
print(gen) # <function gen at 0x7f31171ac550>
print(type(gen)) # <class 'function'>
# 调用生成器函数并不会立刻执行,而是会返回一个生成器对象
g = gen()
print(g) # <generator object gen at 0x7f31170b0740>
print(g.__class__) # <class 'generator'>
那么本次就来看看生成器底层是怎么实现的?
生成器函数的创建
调用生成器函数会返回生成器,那么我们就要先看看生成器函数是如何构建的,它和普通函数有什么区别?
import dis
code_string = """
def gen():
yield 123
"""
dis.dis(compile(code_string, "<file>", "exec"))
看一下字节码指令:
0 LOAD_CONST 0 (<code object gen at 0x7f3...>)
2 LOAD_CONST 1 ('gen')
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (gen)
8 LOAD_CONST 2 (None)
10 RETURN_VALUE
Disassembly of <code object gen at 0x7f3...>:
0 LOAD_CONST 1 (123)
2 YIELD_VALUE
4 POP_TOP
6 LOAD_CONST 0 (None)
8 RETURN_VALUE
>>>
字节码指令依旧分为两部分,这里我们只看模块对应的字节码指令。可以发现,构建生成器函数时的指令和构建普通函数是一模一样的,原因也很好解释,因为生成器函数也是函数。当然啦,还有协程函数、异步生成器函数,它们在构建时的字节码指令都是一样的,因为它们都是函数,类型都是 <class 'function'>。
然后调用生成器函数,返回生成器对象;调用协程函数,返回协程对象;调用异步生成器函数,返回异步生成器对象;调用普通函数,会立刻执行内部代码,返回的就是函数的返回值。
那么问题来了,既然它们都是函数,那虚拟机在调用时是如何区分彼此的呢?毕竟返回的对象不同。还记得 PyCodeObject 的 co_flags 吗?它除了可以判断一个函数是否定义了 *args、**kwargs,更重要的是它还可以判断函数的类型。
def gen():
yield
# 生成器函数,co_flags & 0x20 为真
# 调用会得到生成器,而生成器的类型是 <class 'generator'>
print(gen.__code__.co_flags & 0x20) # 32
async def coro():
return
# 协程函数,co_flags & 0x80 为真
# 调用会得到协程,而协程的类型是 <class 'coroutine'>
print(coro.__code__.co_flags & 0x80) # 128
async def async_gen():
yield
# 异步生成器函数,co_flags & 0x200 为真
# 调用会得到异步生成器,而异步生成器的类型是 <class 'async_generator'>
print(async_gen.__code__.co_flags & 0x200) # 512
这些都是在语法解析的时候确定的,当编译器看到一个函数里面出现了 yield,那么它就知道这是生成器函数。于是创建 PyCodeObject 的时候,会设置 co_flags,让它 & 0x20 为真。
//Include/code.h
#define CO_OPTIMIZED 0x0001
#define CO_NEWLOCALS 0x0002
#define CO_VARARGS 0x0004
#define CO_VARKEYWORDS 0x0008
#define CO_NESTED 0x0010
#define CO_GENERATOR 0x0020
我们看到 CO_GENERATOR 的值为 0x20,如果想判断一个函数是否是生成器函数,那么就可以通过 co_flags & 0x20 是否为真来判断。
当生成器函数创建完毕后就要调用了,会得到一个生成器。还记得函数的调用流程吗?
由于这是一个生成器函数,因此调用时不会进入快速通道,而是会进入通用通道。
PyObject *
_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals,
PyObject *const *args, Py_ssize_t argcount,
PyObject *const *kwnames, PyObject *const *kwargs,
Py_ssize_t kwcount, int kwstep,
PyObject *const *defs, Py_ssize_t defcount,
PyObject *kwdefs, PyObject *closure,
PyObject *name, PyObject *qualname)
{
// ...
// 根据 co_flags 检测函数的种类,如果是生成器函数、协程函数、异步生成器函数三者之一
if (co->co_flags & (CO_GENERATOR | CO_COROUTINE | CO_ASYNC_GENERATOR)) {
PyObject *gen;
int is_coro = co->co_flags & CO_COROUTINE;
Py_CLEAR(f->f_back);
if (is_coro) {
// 如果是协程函数,创建协程
gen = PyCoro_New(f, name, qualname);
} else if (co->co_flags & CO_ASYNC_GENERATOR) {
// 如果是异步生成器函数,创建异步生成器
gen = PyAsyncGen_New(f, name, qualname);
} else {
// 否则说明是生成器函数,那么创建生成器
gen = PyGen_NewWithQualName(f, name, qualname);
}
if (gen == NULL) {
return NULL;
}
// 被 GC 跟踪
_PyObject_GC_TRACK(f);
// 返回
return gen;
}
// ...
}
在编译时将函数种类体现在 co_flags 中,调用时再根据 co_flags 创建不同的对象。
生成器的底层结构
通过源码我们得知,生成器对象是通过调用 PyGen_NewWithQualName 创建的,不过在看这个函数之前,我们先看一下生成器的底层结构。
// Include/genobject.h
#define _PyGenObject_HEAD(prefix) \
PyObject_HEAD \
struct _frame *prefix##_frame; \
char prefix##_running; \
PyObject *prefix##_code; \
PyObject *prefix##_weakreflist; \
PyObject *prefix##_name; \
PyObject *prefix##_qualname; \
_PyErr_StackItem prefix##_exc_state;
typedef struct {
_PyGenObject_HEAD(gi)
} PyGenObject;
如果我们将其整理一下,等价于如下:
typedef struct {
// 头部信息
PyObject_HEAD
// 生成器执行时对应的栈帧对象
struct _frame *gi_frame;
// 标识生成器是否在运行当中
char gi_running;
// 生成器函数的 PyCodeObject 对象
PyObject *gi_code;
// 弱引用相关,不深入讨论
PyObject *gi_weakreflist;
// 生成器的名字
PyObject *gi_name;
// 生成器的全限定名
PyObject *gi_qualname;
// 生成器执行出现异常时的异常栈,更准确的说,其实是异常栈的一个 entry
// 里面包含了 exc_type、exc_value、exc_traceback
// 以及通过 previous_item 指针指向上一个 entry
_PyErr_StackItem *gi_exc_state;
} PyGenObject;
所以生成器在底层对应 PyGenObject,它的类型则是 PyGen_Type。至此,生成器的结构就非常清晰了,我们来画一张图:
无论是普通函数还是生成器函数,在调用时,虚拟机都会为其创建栈帧,因为栈帧是函数执行的上下文。只是对于生成器函数来说,由于它的 co_flags 带有 CO_GENERATOR 标识,所以知道这是一个生成器函数。在调用时不会立刻执行里面的字节码,而是会创建一个生成器对象,并将栈帧交由 gi_frame 字段保存,然后将生成器返回。
我们可以从 Python 层面来验证得到的结论。
def gen():
yield
# 在内部会创建栈帧,但是和普通函数不同
# 虚拟机不会立即执行字节码,而是创建一个生成器
# 然后让 "生成器 -> gi_frame = 栈帧"
g = gen()
# 通过 gi_frame 即可拿到栈帧
print(g.gi_frame) # <frame at 0x7f3...>
# 由于生成器还没有运行,所以栈帧的 f_back 是 None
# 如果是普通函数的栈帧,那么它的 f_back 应该是模块对应的栈帧
# 因为对于普通函数而言,能拿到它的栈帧,说明一定执行了
# 而生成器则不同,它还没有运行,所以 f_back 是 None
print(g.gi_frame.f_back) # None
# f_lasti 表示上一条已执行完毕的字节码指令的偏移量
# -1 代表尚未执行
print(g.gi_frame.f_lasti) # -1
# 所以 gi_running 也是 False
print(g.gi_running) # False
# 还可以获取 PyCodeObject,有三种方式
print(
g.gi_code is g.gi_frame.f_code is gen.__code__
) # True
所以生成器相当于对栈帧进行了一个封装,当我们驱动它执行时,会将内部的栈帧插入到栈帧链中执行。一旦遇到 yield,停止运行,将栈帧从栈帧链中移除。
然后再次驱动执行,继续将内部的栈帧插入到栈帧链中执行字节码指令,由于 f_lasti 记录了上一条已执行完毕的指令的偏移量,所以会从上次中断的位置开始执行。如果又遇到了 yield,那么依旧停止运行,将栈帧从栈帧链中移除。
就这样不断重复,直到执行完毕。而生成器一旦执行完毕(return 之后),会将栈帧设置为 None。
def gen():
yield
g = gen()
print(g.gi_frame is None) # False
for _ in g:
pass
print(g.gi_frame is None) # True
所以生成器只能顺序遍历一次,从这里我们也可以看出原因,因为遍历完之后栈帧都没了。
g = (x for x in [1, 2, 3])
print(tuple(g)) # (1, 2, 3)
print(tuple(g)) # ()
还是很好理解的。
生成器的创建
然后再来看看生成器的创建过程,我们上面提到,生成器是在通用通道里面调用 PyGen_NewWithQualName 创建的,来看看这个函数长什么样子。
// Objects/genobject.c
PyObject *
PyGen_NewWithQualName(PyFrameObject *f, PyObject *name, PyObject *qualname)
{
return gen_new_with_qualname(&PyGen_Type, f, name, qualname);
}
该函数接收三个参数,分别是栈帧对象、__name__、__qualname__。注意这个栈帧对象,它是在通用通道里面创建好的,然后将其作为参数传递到 PyGen_NewWithQualName 里面进行调用。
而该函数又调用了 gen_new_with_qualname,所以具体逻辑在这个函数里面,来看一下。
// Objects/genobject.c
static PyObject *
gen_new_with_qualname(PyTypeObject *type, PyFrameObject *f,
PyObject *name, PyObject *qualname)
{
// 为生成器对象申请内存
PyGenObject *gen = PyObject_GC_New(PyGenObject, type);
if (gen == NULL) {
Py_DECREF(f);
return NULL;
}
// 将栈帧交给 gi_frame 保存,所以普通函数和生成器函数调用时都会创建栈帧
// 但普通函数调用时,会在栈帧里面将字节码全部执行完毕
// 而生成器函数调用时,会返回生成器对象,并将栈帧保存在里面
gen->gi_frame = f;
// 注意这里,又让栈帧的 f_gen 字段保存生成器对象
// 如果是普通函数,那么 f_gen 显然为空
f->f_gen = (PyObject *) gen;
Py_INCREF(f->f_code);
// 让生成器的 gi_code 也保存 PyCodeObject
gen->gi_code = (PyObject *)(f->f_code);
// 初始时,gi_running 为 0
gen->gi_running = 0;
// 弱引用列表为空
gen->gi_weakreflist = NULL;
// gi_exc_state 和异常栈相关,内部字段初始为 NULL
gen->gi_exc_state.exc_type = NULL;
gen->gi_exc_state.exc_value = NULL;
gen->gi_exc_state.exc_traceback = NULL;
gen->gi_exc_state.previous_item = NULL;
// 设置 gi_name
if (name != NULL)
gen->gi_name = name;
else
gen->gi_name = ((PyCodeObject *)gen->gi_code)->co_name;
Py_INCREF(gen->gi_name);
// 设置 gi_qualname
if (qualname != NULL)
gen->gi_qualname = qualname;
else
gen->gi_qualname = gen->gi_name;
Py_INCREF(gen->gi_qualname);
// 让生成器对象被 GC 跟踪
_PyObject_GC_TRACK(gen);
// 返回
return (PyObject *)gen;
}
所以生成器就是对栈帧进行了一个封装,通过 yield 和 __next__、send,我们可以操控栈帧的执行。但普通函数没有给我们这个机会,它在创建完栈帧之后,不将字节码全执行完是不会罢休的。
生成器的执行
由于调用 __next__、send 方法可以驱动生成器执行,因此相关细节就隐藏在这两个函数当中。
__next__ 对应类型对象 PyGen_Type 的 tp_iternext,而 send 是一个普通的方法,所以它位于 tp_methods 中。通过源码可以看出,如果调用 __next__,底层会执行 gen_iternext,如果调用 send,底层会执行 _PyGen_Send。
// Objects/genobject.c
static PyObject *
gen_iternext(PyGenObject *gen)
{
return gen_send_ex(gen, NULL, 0, 0);
}
PyObject *
_PyGen_Send(PyGenObject *gen, PyObject *arg)
{
return gen_send_ex(gen, arg, 0, 0);
}
无论哪种方式,在底层最终都是通过 gen_send_ex 函数完成的,只是 __next__ 不接收参数,因此 gen_iternext 在调用时传递了一个空。而 send 接收一个参数,因此 _PyGen_Send 在调用时传了一个 arg。
所以核心逻辑显然在 gen_send_ex 函数里面。
static PyObject *
gen_send_ex(PyGenObject *gen, PyObject *arg, int exc, int closing)
{
// 获取线程状态对象
PyThreadState *tstate = _PyThreadState_GET();
// 获取生成器内部保存的栈帧对象
PyFrameObject *f = gen->gi_frame;
// 返回值
PyObject *result;
// ...
// 重点来了,f 就是生成器内部的栈帧,f->f_back 表示生成器内部栈帧的上一级栈帧
// 而 tstate->frame 表示当前栈帧,也就是调用 __next__ 或者 send 时所在的栈帧
// 假设我们是在模块作用域中调用了生成器的 __next__ 方法,那么 tstate->frame 就是模块对应的栈帧
// 而当下面这行代码执行完毕后,tstate->frame 就变成了生成器内部栈帧的上一级栈帧
// 生成器内部的栈帧则变成了当前栈帧,所以这是不是就相当于将生成器内部的栈帧插入到栈帧链当中了呢?
f->f_back = tstate->frame;
// 生成器正在运行
gen->gi_running = 1;
gen->gi_exc_state.previous_item = tstate->exc_info;
tstate->exc_info = &gen->gi_exc_state;
// 而插入到栈帧链之后要干啥?显然是在栈帧中执行字节码
// 栈帧对象保存着生成器的执行上下文,f_lasti 字段则跟踪生成器内部代码的执行进度
// 当遇到 yield 之后,将后面的值返回给 result
result = PyEval_EvalFrameEx(f, exc);
tstate->exc_info = gen->gi_exc_state.previous_item;
gen->gi_exc_state.previous_item = NULL;
gen->gi_running = 0;
// ...
}
至于剩下的逻辑我们显然再清楚不过了,最终会调用帧评估函数在栈帧对象中执行字节码。每执行完一条指令就自增 f_lasti 字段、next_instr 字段,直到字节码全部执行完毕、或者中间出现异常时结束循环。当然啦,遇到 yield 也会结束循环。
举例说明:
def gen():
yield 1
yield 2
yield 3
g = gen()
print(g.gi_frame.f_back) # None
g.__next__()
g = gen() 之后会创建一个生成器,但此时生成器的代码还没有执行,所以生成器内部栈帧的上一级栈帧为空。等到调用生成器的 __next__ 方法时,会接入到栈帧链并开始执行。另外,由于我们是在模块作用域中调用的 __next__,所以当前栈帧链里面其实只有一个栈帧,就是模块的栈帧。
所以接入栈帧链,其实就是让生成器内部栈帧的 f_back 字段,指向调用 __next__ 或 send 时所在的栈帧。比如当前是在模块作用域中调用的 __next__,那么就让 f_back 字段指向模块栈帧。就好像在模块的栈帧之上,创建了一个新的栈帧一样,开始执行,直到遇见 yield。
生成器的暂停
先看看生成器内部的字节码长什么样子?
def gen():
name = "古明地觉"
yield name
age = 16
res = yield age
gender = "female"
yield gender
字节码如下:
// 加载字符串常量 "古明地觉"
0 LOAD_CONST 1 ('古明地觉')
// 使用局部变量 name 保存
2 STORE_FAST 0 (name)
// 对应 yield name
// 加载局部变量 name,然后将值 yield 出去
// 注意:执行完 YIELD_VALUE 之后,生成器会暂停
4 LOAD_FAST 0 (name)
6 YIELD_VALUE
// 这里为啥会出现 POP_TOP 呢?
// 因为驱动生成器执行时,我们是可以传值的
// 但是 yield name 左边没有变量接收,所以是 POP_TOP
8 POP_TOP
// 加载整数常量 16
10 LOAD_CONST 2 (16)
// 使用局部变量 age 保存
12 STORE_FAST 1 (age)
// 加载局部变量 age,然后将值 yield 出去
14 LOAD_FAST 1 (age)
16 YIELD_VALUE
// 但这里需要注意,因为是 res = yield age
// 所以这里是 STORE_FAST,会将调用方传递的值使用 res 变量保存
18 STORE_FAST 2 (res)
// 加载字符串常量 "female"
20 LOAD_CONST 3 ('female')
// 使用局部变量 gender 保存
22 STORE_FAST 3 (gender)
// 加载局部变量 gender,然后将值 yield 出去
24 LOAD_FAST 3 (gender)
26 YIELD_VALUE
// 弹出调用方传递的值
28 POP_TOP
// return None
30 LOAD_CONST 0 (None)
32 RETURN_VALUE
指令很好理解,显然重点是在 YIELD_VALUE 上面,我们看一下这个指令:
case TARGET(YIELD_VALUE): {
// 执行 yield value 之前,会先将 value 压入运行时栈
// 然后这里再将 value 从栈里面弹出
retval = POP();
// 异步生成器逻辑,当前不用关注
if (co->co_flags & CO_ASYNC_GENERATOR) {
PyObject *w = _PyAsyncGenValueWrapperNew(retval);
Py_DECREF(retval);
if (w == NULL) {
retval = NULL;
goto error;
}
retval = w;
}
// stack_pointer 指向运行时栈的栈顶
// 因为要跳出循环了,所以赋值给 f->f_stacktop
f->f_stacktop = stack_pointer;
// 直接通过 goto 语句跳出 for 循环,来到 exit_yielding 标签
goto exit_yielding;
}
紧接着,帧评估函数会将当前栈帧(也就是生成器内部的栈帧)从栈帧链中移除,至于移除方式也很简单,只需要将它的 f_back 设置为 None 即可,然后回退到上一级栈帧。
同理,当再次调用 __next__ 或者 send 方法时,生成器将恢复执行,底层会调用 gen_send_ex 函数。然后虚拟机会把生成器的栈帧对象挂到栈帧链中,并最终调用帧评估函数逐条执行字节码。
通过不断调用 __next__,最终将整个生成器内部的代码执行完毕,我们实际演示一下这个过程。
def gen():
name = "古明地觉"
yield name
age = 16
res = yield age
gender = "female"
yield gender
g = gen()
# 生成器尚未执行,f_lasti 初始为 -1
print(g.gi_frame.f_lasti) # -1
# 调用 __next__,会将生成器内部栈帧插入到栈帧链中,开始执行
# 那什么时候暂停呢?如果站在 Python 的角度,肯定是在 yield 处暂停
# 但如果从虚拟机的角度,应该是在 YIELD_VALUE 的结束位置暂停
g.__next__()
# f_lasti 表示上一条已执行的指令的偏移量,而上一条执行的指令是 YIELD_VALUE,因此是 6
print(g.gi_frame.f_lasti) # 6
print(g.gi_frame.f_locals) # {'name': '古明地觉'}
# 第二个 YIELD_VALUE 的偏移量是 16
g.__next__()
print(g.gi_frame.f_lasti) # 16
print(g.gi_frame.f_locals) # {'name': '古明地觉', 'age': 16}
# 第三个 YIELD_VALUE 的偏移量是 26
g.__next__()
print(g.gi_frame.f_lasti) # 26
print(g.gi_frame.f_locals) # {'name': '古明地觉', 'age': 16, 'res': None, 'gender': 'female'}
# 再次调用 g.__next__(),生成器执行完毕
try:
g.__next__()
except StopIteration:
pass
# 一旦执行完毕,gi_frame 会设置为 None,因此生成器只能顺序遍历一次
print(g.gi_frame) # None
遇见 yield 产生中断,调用 __next__、send 恢复执行,并且在这个过程中,f_lasti 也在不断变化,并始终维护着生成器的执行进度。而基于 f_lasti,生成器就可以记住自己的中断位置,并在下一次被驱动的时候,能够从中断的位置恢复执行。
而以上也正是协程能够实现的理论基础,虽然 Python 在 3.5 提供了基于 async def 的原生协程,但它底层依旧是使用了生成器。
小结
以上我们就从源码的角度剖析了生成器的实现原理,下面总结一下。
1)Python 具有词法作用域,当函数内部出现 yield 关键字时,会给对应的 PyCodeObject 增加一个 CO_GENERATOR 标识。这个标识让虚拟机在调用时能够分辨出是普通函数、还是生成器函数。
2)和普通函数一样,生成器函数在调用时,也会由虚拟机创建栈帧,作为执行上下文。但和普通函数不同的是,调用生成器函数时创建的栈帧不会立即进入帧评估函数执行字节码。而是以栈帧为参数,创建生成器对象。
3)可以调用 __next__、send 方法驱动生成器执行,虚拟机会将生成器的栈帧插入栈帧链,也就是将它的 f_back 设置为调用 __next__、send 时所在的栈帧。然后生成器的栈帧就变成了当前栈帧,于是开始执行字节码。
4)执行到 YIELD_VALUE 指令时,说明生成器该暂停了。于是修改 f_stacktop,通过一个 goto 语句跳出执行指令的 for 循环,回退到上一级栈帧,然后将 yield 右边的值压入运行时栈。并且还会将生成器内部栈帧的 f_back 设置为空,以及设置 f_lasti 等字段。
5)当再次调用 __next__ 或者 send 方法时,虚拟机仍会修改 f_back,将生成器的栈帧重新插入到栈帧链中,然后继续执行生成器内部的字节码。但是从什么地方开始执行呢?显然是从上一次中断的位置,那么上一次中断的位置虚拟机要如何得知呢?没错,显然是通过 f_lasti,直接从偏移量为 f_lasti + 2 的指令开始执行即可。所以执行时,会从上一个 YIELD_VALUE 的下一条指令开始执行,另外由于要获取调用者传递的值,因此 YIELD_VALUE 的下一条指令一般是 POP_TOP 或者 STORE_FAST。
6)随着不断驱动生成器执行,总有执行完毕的那一刻。而当生成器执行完毕后,gi_frame 会被设置为 None,因此生成器只能顺序遍历一次。
欢迎大家关注我的公众号:古明地觉的编程教室。
如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。