《深度剖析CPython解释器》22. 解密Python中的生成器对象,从字节码的角度分析生成器的底层实现以及执行逻辑
楔子
下面我们来聊一聊Python中的生成器,它是我们理解后面协程的基础,生成器的话,估计大部分人在写程序的时候都想不到用。但是一旦用好了,确实能给程序带来性能上的提升,那么我们就来看一看吧。
生成器
基本用法
我们知道,一个函数如果它的内部出现了yield关键字,那么它就不再是普通的函数了,而是一个生成器函数。当我们调用的时候,就会创建一个生成器对象。
生成器对象一般用于处理循环结构,应用得当的话可以极大优化内存使用率。比如:我们读取一个大文件。
def read_file(file):
return open(file, encoding="utf-8").readlines()
print(read_file("假装是大文件.txt"))
# ['人生は一体何だろう\n', 'たぶん 輝いている同時に\n', '人を苦しくさせるものだろう']
这个版本的函数,直接将里面的内容全部读取出来了,返回了一个列表。如果文件非常大,那么内存的开销可想而知。
于是我们可以通过yield关键字,将函数变成一个生成器。
def read_file(file):
with open(file, encoding="utf-8") as f:
for line in f:
yield line
data = read_file("假装是大文件.txt")
print(data) # <generator object read_file at 0x0000019B4FA8BAC0>
# 这里返回了一个生成器对象, 我们需要使用for循环遍历
for line in data:
# 文件每一行自带换行符, 所以这里print就不用换行符了
print(line, end="")
"""
人生は一体何だろう
たぶん 輝いている同時に
人を苦しくさせるものだろう
"""
观察生成器的运行行为
那么生成器是怎么做到的呢?我们继续考察一下。
def read_file(file):
with open(file, encoding="utf-8") as f:
for line in f:
yield line
data = read_file("假装是大文件.txt")
print(data) # <generator object read_file at 0x0000019B4FA8BAC0>
# 当我们调用初始化函数(生成器函数)时, 会创建一个生成器
# 此时生成器就已经被创建了
# 当我们调用__next__的时候, 生成器会执行
# 一旦执行到yield的时候就会将生成器暂停, 并将yield后面的值返回
print(next(data), end="") # 人生は一体何だろう
# 此时生成器处于暂停状态, 如果我们不驱使它的话, 它是不会前进的
# 当我们再次执行__next__的时候, 生成器恢复执行, 继续处理并在下一个yield处暂停
print(next(data), end="") # たぶん 輝いている同時に
# 生成器会记住自己的执行进度, 每次调用next函数, 它总是处理并生产下一个数据, 完全不需要我们担心
print(next(data)) # 人を苦しくさせるものだろう
# 一旦next的时候, 就会找下一个yield, 但我们知道此时循环已经结束了
# 所以没有下一个yield了, 因此程序就会报错了
try:
print(next(data))
except StopIteration as e:
print("生成器执行完毕") # 生成器执行完毕
因此生成器和之前我们说的迭代器是类似的,毕竟生成器就是一个特殊的迭代器。
原谅我写到这里有点懈怠了,有点累了。所以这里后面只对生成器的底层实现进行剖析,至于一些Python层面的东西就不说了。
任务上下文
在经典的线程模型中,每个线程都有一个独立的执行流,只能执行一个任务。如果一个程序需要同时处理多个任务,可以借助多线程或者多进程技术。假设一个站点需要同时服务于多个客户端连接,可以为每个连接创建一个独立的线程进行处理。
但不管是线程还是进程,切换时都会带来巨大的性能开销:用户态和内核态的切换、执行上下文保存和恢复、CPU刷新缓存等等。因此,使用进程或者线程来驱动多个小任务的执行,显然不是一个理想的选择。
那么,除了进程和线程,还有其它的解决方案吗?显然是有的,答案就是协程。不过在讨论之前,我们先来总结多任务执行体系的关键之处。
一个程序想要同时处理多个任务,必须提供一种能够记录任务执行进度的机制。在经典线程模型中,这个机制由CPU提供:
如上图,程序内存空间分为代码、数据、堆以及栈等多个段,CPU 中的 CS 寄存器指向代码段,SS 寄存器指向栈段。当程序任务(线程)执行时,IP 寄存器指向代码段中当前正被执行的指令,BP 寄存器指向当前栈帧,SP 寄存器则指向栈顶。
有了 IP 寄存器,CPU 可以取出需要执行的下一条指令;有了 BP 寄存器,当函数调用结束时,CPU 可以回到调用者继续执行。因此,CPU 寄存器与内存地址空间一起构成了任务执行上下文,记录着任务执行进度。当任务切换时,操作系统先将 CPU 当前寄存器保存到内存,然后恢复待执行任务的寄存器。
至此,我们已经受到一些启发:生成器不是可以记住自己的执行进度吗?那么,是不是可以用生成器来实现任务执行流?由于生成器在用户态运行,切换成本比线程或进程小很多,是组织微型任务的理想手段。
现在,我们用生成器来写一个玩具协程,以此体会协程的运行机制:
def producer(n):
for i in range(1, n):
yield f"生产者生产第{i}包子"
def consumer(n):
for i in range(1, n):
yield f"消费者消费第{i}包子"
p = producer(3)
c = consumer(3)
我们创建一个生产包子的生产者,和一个消费包子的消费者,然后进行初始化。当我们调用next函数的时候,就可以驱动它们执行了。
print(next(p)) # 生产者生产第1包子
print(next(c)) # 消费者消费第1包子
当遇到第一个yield语句时,让出执行权,并将yield后面的值返回。但是在实例项目中,一般在遇到网络I/O时,才会让出执行权。
而且我们看到,我们还扮演着调度器的角色。
print(next(p)) # 生产者生产第2包子
print(next(c)) # 消费者消费第2包子
我们再次通过next驱动生成器执行,然后遇到yield之后继续暂停,将yield后面的值返回,并将执行权交给我们。
print(next(p))
"""
print(next(p)) # 生产者生产第2包子
StopIteration
"""
再次驱动生成器执行,但是发现已经找不到下一个yield了,所以抛出StopIteration异常告诉我们生成器已经将全部的值都生成了。
以上通过一个小例子,感受一下生成器的运行原理,它可以帮我们更好地理解后面的协程。因为协程的思想和生成器本质是一样的,而且在早期Python还没有提供原生协程的时候,就是通过生成器来模拟的协程。
字节码解密生成器
上面我们简单的考察了一下生成器的运行时行为,发现了它神秘的一面。生成器可以通过yield关键字暂停,并且还可以通过next函数重新恢复执行(从上一次暂停的位置)
,这个特性可以用来实现协程。
生成器的创建
而理解协程,首先要理解生成器。
def gen():
print("生成器开始执行了")
name = "夏色祭"
print("创建了一个局部变量name")
yield name
age = -1
print("创建了一个局部变量age")
yield age
gender = "female"
print("创建了一个局部变量gender")
yield gender
我们创建一个简单的生成器函数,当我们调用的时候就会得到一个生成器对象。
def gen():
print("生成器开始执行了")
name = "夏色祭"
print("创建了一个局部变量name")
yield name
age = -1
print("创建了一个局部变量age")
yield age
gender = "female"
print("创建了一个局部变量gender")
yield gender
# 虽然是生成器函数, 但它也是一个函数
print(gen) # <function gen at 0x000001DABAD951F0>
# 但是调用生成器函数并不会立刻执行, 而是会返回一个生成器对象
g = gen()
print(g) # <generator object gen at 0x000001D89E9D7270>
print(g.__class__) # <class 'generator'>
关于普通函数和生成器函数,有一个非常生动的栗子。普通函数可以想象成一匹马,只要调用了,那么不把里面的代码执行完毕誓不罢休;而生成器函数则好比一头驴,调用的时候并没有动,只是返回一个生成器对象,然后需要每次拿鞭子抽一下
(调用一次next)
才往前走一步。另外我们可以把生成器看成是可以暂停的函数,其中的yield就类似于return,只不过可以有多个yield。当执行到一个yield时,将值返回、同时暂停在此处,然后当使用next函数驱动时,从暂停的地方继续执行,直到找到下一个yield。如果找不到下一个yield,就会抛出StopIteration异常。
那么老规矩,肯定要看一下字节码。
1 0 LOAD_CONST 0 (<code object gen at 0x00000188E73D1450, file "generator", line 1>)
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 0x00000188E73D1450, file "generator", line 1>:
2 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('生成器开始执行了')
4 CALL_FUNCTION 1
6 POP_TOP
4 8 LOAD_CONST 2 ('夏色祭')
10 STORE_FAST 0 (name)
5 12 LOAD_GLOBAL 0 (print)
14 LOAD_CONST 3 ('创建了一个局部变量name')
16 CALL_FUNCTION 1
18 POP_TOP
6 20 LOAD_FAST 0 (name)
22 YIELD_VALUE
24 POP_TOP
8 26 LOAD_CONST 4 (-1)
28 STORE_FAST 1 (age)
9 30 LOAD_GLOBAL 0 (print)
32 LOAD_CONST 5 ('创建了一个局部变量age')
34 CALL_FUNCTION 1
36 POP_TOP
10 38 LOAD_FAST 1 (age)
40 YIELD_VALUE
42 POP_TOP
12 44 LOAD_CONST 6 ('female')
46 STORE_FAST 2 (gender)
13 48 LOAD_GLOBAL 0 (print)
50 LOAD_CONST 7 ('创建了一个局部变量gender')
52 CALL_FUNCTION 1
54 POP_TOP
14 56 LOAD_FAST 2 (gender)
58 YIELD_VALUE
60 POP_TOP
62 LOAD_CONST 0 (None)
64 RETURN_VALUE
字节码指令依旧是分为两部分,我们先来看看模块的。很简单,就是创建一个生成器对象,而且我们发现字节码和调用一个普通函数的字节码是一样的。那么Python是如何知道这是一个生成器函数呢?显然秘密就在CALL_FUNCTION里面。
由于我们已经分析过函数了,所以CALL_FUNCTION下一步会调用什么就不多说了,直接用我们之前的一张图:
CALL_FUNCTION中,它会调用Objects/call.c中的call_function函数。call_function函数可以根据被调用对象的类型进行区别处理,可分为:类方法、函数对象、普通可调用对象等等。
在这个例子中,被调用的是函数对象。因此call_function内部会调用位于Objects/call.c中的 _PyFunction_FastCallDict 函数,而它则进一步调用位于 Python/ceval.c 中的 _PyEval_EvalCodeWithName 函数,该函数会为PyFunctionObject创建一个栈帧,然后检查co_flags。如果带有 CO_GENERATOR 、CO_COROUTINE 或 CO_ASYNC_GENERATOR,那么便创建生成器返回。
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)
{
//......
//......
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;
}
_PyObject_GC_TRACK(f);
//直接返回一个生成器
return gen;
}
//......
//......
}
其中的 co_flags 是PyCodeObject中的一个域,显然它是在编译时由语法规则确定的。我们之前说它是用来判断参数特征的,但其实它还可以用来判断函数特征。
//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 的值为0x0020。
print(gen.__code__.co_flags & 0x0020) # 32
如果不是生成器函数,那么结果为0;现在结果不为0,说明是生成器函数。
下面我们可以看看生成器的底层定义了,位于 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; //标识生成器是否运行中
PyObject *gi_code; //PyCodeObject对象
PyObject *gi_weakreflist; //弱引用相关,不深入讨论
PyObject *gi_name; //生成器名
PyObject *gi_qualname; //全限定名
_PyErr_StackItem *gi_exc_state; //生成器执行状态
} PyGenObject;
所以我们可以画出一张模型图:
至此生成器对象的创建过程已经浮出水面,与普通函数对象一样,当生成器函数gen被调用时,Python将为其创建栈帧对象,用于维护函数执行上下文。PyCodeObject对象、全局名字空间、局部名字空间、以及运行时栈都在里面。
但和普通函数不同的是,gen函数的PyCodeObject对象带有生成器标识,在调用的时候,Python不会立刻执行PyCodeObject对象里面的字节码,栈帧对象也不会被接入到调用链中,所以f_back字段此时是空的。相反,Python创建了一个生成器对象,并将其作为函数结果返回。
我们可以使用Python来验证在我们得到的结论。
g = gen()
# 此时刚创建生成器对象, 还没有开始运行
print(g.gi_running) # False
# 栈帧对象
print(g.gi_frame) # <frame at 0x0000021B6B0969F0,....
# PyCodeObject对象
print(g.gi_code) # <code object gen...
# 当然我们还可以通过如下方式获取
print(g.gi_frame.f_code is g.gi_code is gen.__code__) # True
生成器的执行
当执行g = gen()之后,返回生成器对象并交给变量g指向,这个时候还没有开始执行。
g = gen()
print(g.gi_frame.f_lasti) # -1
栈帧对象 f_lasti 字段记录当前字节码执行进度,-1 表示尚未开始执行。
在使用Python时我们知道,借助 next 内建函数或者 send 方法可以启动生成器,并驱动它不断执行。这意味着,生成器执行的秘密可以通过这两个函数找到。
我们先从 next 函数入手,作为内建函数,它定义于 Python/bltinmodule.c 源文件,C 语言函数 builtin_next 是也。builtin_next 函数逻辑非常简单,除了类型检查等一些样板式代码,最关键的是这一行:
res = (*it->ob_type->tp_iternext)(it);
这行代码表明,next 函数实际上调用了生成器类型对象的 tp_iternext 函数完成工作。
next(g)等价于g.__next__()、等价于g.__class__.__next__(g)。
g = gen()
print(g.__next__())
"""
生成器开始执行了
创建了一个局部变量name
夏色祭
"""
# 其中g.__next__()的返回值就是yield后面的name
那么底层调用了哪个函数呢?我们说类型对象决定实例对象的行为,实例对象相关操作函数的指针都保存在类型对象中。生成器作为 Python 对象中的一员,当然也遵守这一法则。顺着生成器的类型对象 PyGen_Type ( Objects/genobject.c ),很快就可以找到 gen_iternext 函数。
另一方面, g.send 也可以启动并驱动生成器的执行,根据 Objects/genobject.c 中的方法定义,它底层调用 _PyGen_Send 函数:
static PyMethodDef coro_methods[] = {
{"send",(PyCFunction)_PyGen_Send, METH_O, coro_send_doc},
{"throw",(PyCFunction)gen_throw, METH_VARARGS, coro_throw_doc},
{"close",(PyCFunction)gen_close, METH_NOARGS, coro_close_doc},
{NULL, NULL} /* Sentinel */
};
不管 gen_iternext 函数还是 _PyGen_Send 函数,都是直接调用 gen_send_ex 函数完成工作的:
因为不管是哪一种方式驱动,最终都由 gen_send_ex 函数处理,next 和 send 的等价性也源于此。经过千辛万苦,我们终于找到了理解生成器运行机制的关键所在。不过这两者虽然是等价的,但是稍稍还有一点不同。
def test():
name = yield "value1"
print(f"name = {name}")
yield "value2"
t = test()
t.__next__()
t.__next__()
"""
name = None
"""
t = test()
t.__next__()
t.send("夏色祭") # name = 夏色祭
这里简单提一下,具体细节可以自己去了解,比较简单。
此外,gen_send_ex 函数同样位于 Objects/genobject.c 源文件,函数挺长,但最关键的代码只有两行:
f->f_back = tstate->frame;
// ...
result = PyEval_EvalFrameEx(f, exc);
首先,第一行代码将生成器栈帧挂到当前调用链(或者说栈帧链)上;然后,第二行代码调用 PyEval_EvalFrameEx 开始在生成器栈帧对象中执行字节码;生成器栈帧对象保存着生成器执行上下文,其中 f_lasti 字段跟踪生成器代码对象的执行进度。
剩下的逻辑我们显然之前就知道了,PyEval_EvalFrameEx 函数最终调用 _PyEval_EvalFrameDefault 函数执行 frame 对象中 f_code 内部的字节码。这个函数我们在虚拟机部分学习过,对它并不陌生。虽然它体量巨大,超过 3 千行代码,但逻辑却非常直白——内部由无限 for 循环逐条遍历字节码,然后交给内部的一个巨型switch case语句,根据不同的指令执行不同的case分支,每执行完一条字节码就自增 f_lasti 字段,直到字节码全部执行完毕、或者中间出现异常时结束循环。
生成器的暂停
我们知道,生成器可以利用 yield 语句,将执行权归还给调用者。因此,生成器暂停执行的秘密就隐藏在 yield 语句中。我们先来看看 yield 语句编译后,生成什么字节码。这里我们就直接分析上面生成器函数内部的字节码:
2 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('生成器开始执行了')
4 CALL_FUNCTION 1
6 POP_TOP
4 8 LOAD_CONST 2 ('夏色祭')
10 STORE_FAST 0 (name)
5 12 LOAD_GLOBAL 0 (print)
14 LOAD_CONST 3 ('创建了一个局部变量name')
16 CALL_FUNCTION 1
18 POP_TOP
6 20 LOAD_FAST 0 (name)
22 YIELD_VALUE
24 POP_TOP
8 26 LOAD_CONST 4 (-1)
28 STORE_FAST 1 (age)
9 30 LOAD_GLOBAL 0 (print)
32 LOAD_CONST 5 ('创建了一个局部变量age')
34 CALL_FUNCTION 1
36 POP_TOP
10 38 LOAD_FAST 1 (age)
40 YIELD_VALUE
42 POP_TOP
12 44 LOAD_CONST 6 ('female')
46 STORE_FAST 2 (gender)
13 48 LOAD_GLOBAL 0 (print)
50 LOAD_CONST 7 ('创建了一个局部变量gender')
52 CALL_FUNCTION 1
54 POP_TOP
14 56 LOAD_FAST 2 (gender)
58 YIELD_VALUE
60 POP_TOP
62 LOAD_CONST 0 (None)
64 RETURN_VALUE
我们看到每个 yield 语句编译后,都得到这样 3 条字节码指令:
LOAD_FAST
YIELD_VALUE
POP_TOP
这里是LOAD_FAST,也可以是LOAD_CONST、LOAD_NAME等等,首先这里的LOAD_FAST是将变量指向的值压入运行时栈,设置在栈顶。另外这里变量就是yield右边的值,然后执行 YIELD_VALUE 指令,显然关键就在此处。
case TARGET(YIELD_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;
}
f->f_stacktop = stack_pointer;
//直接通过goto语句跳出for循环
goto exit_yielding;
}
紧接着,_PyEval_EvalFrameDefault 函数将当前栈帧(也就是生成器的栈帧)从栈帧链中解开。注意到,yield 值被 _PyEval_EvalFrameDefault 函数返回,并最终被 send 方法或 next 函数返回给调用者。
生成器的恢复
当我们再次调用 next 函数或者 send 方法时,生成器将恢复执行:
另外通过 send 方法,可以传入一个值,赋给yield所在语句的等号左边的变量,那么这是如何做到的呢?
首先我们知道,send 方法被调用后,Python 先把生成器栈帧对象挂到栈帧链中,并最终调用 PyEval_EvalFrameEx 函数逐条执行字节码。在这个过程中,send 发送的数据会被放在生成器栈顶:
g = gen()
print(g.__next__())
"""
生成器开始执行了
创建了一个局部变量name
夏色祭
"""
print(g.gi_frame.f_lasti) # 22
print(g.gi_frame.f_locals) # {'name': '夏色祭'}
print(g.send("matsuri"))
"""
创建了一个局部变量age
-1
"""
print(g.gi_frame.f_lasti) # 40
print(g.gi_frame.f_locals) # {'name': '夏色祭', 'age': -1}
这里我们 send 一个字符串"matsuri",当然我们的 yield 语句中没有出现赋值,所以这里的值没有变量接收。因此在YIELD_VALUE指令后面就是POP_TOP了,将栈顶的值直接弹出、丢弃。尽管我们没有尝试,但如果假设我们使用变量接收了会怎么样呢?不用想,显然YIELD_VALUE指令后面的POP_TOP会变成STORE_FAST,从栈顶取出 send 发来的值,并保存在局部变量中。
所以当出现 next 函数或者 send 方法时,这里会再次将生成器对象的栈帧插入到栈帧链中。
而且我们看到 f_lasti ,它负责保存执行进度,这个进度显然是指字节码指令的便宜量。一开始是 -1 表示生成器没有执行,后面的 22 、40 则对应各自的YIELD_VALUE。此外,我们看到随着生成器的执行,f_locals也在不断变化。
再接着生成器按照逻辑有条不紊的执行,每次遇到 yield 就将后面的值返回,并将生成器所在栈帧从栈帧链中移除,同时将f_back设置为空;当使用next函数或者send方法时,再将生成器所在栈帧插入到栈帧链中,然后f_back指向next(g)或者g.send(value)对应的栈帧。然后next(g)或者g.send(value)继续执行,在找到下一个yield之后,继续返回,然后再将生成器所在栈帧的f_back设置为空、并从栈帧链中移除,至于next(g)或者g.send(value),由于它们已经执行完毕,所在栈帧就被销毁了。然后代码继续执行,如果再遇到next(g)或者g.send(value),那么再重复相同的动作,周而复始,直到生成器执行完毕。
g = gen()
print(g.__next__())
"""
生成器开始执行了
创建了一个局部变量name
夏色祭
"""
print(g.gi_frame.f_lasti) # 22
print(g.gi_frame.f_locals) # {'name': '夏色祭'}
# 我们说next函数或者send方法执行完毕时, 生成器所在栈帧就会从栈帧链中移除, 同时f_back设置为空
print(g.gi_frame.f_back) # None
# 如果是函数, 那么它的栈帧的f_back属性显然是模块对应的栈帧, 但是对于生成器而言则是None
# 只有在执行时才会被插入到栈帧链中, 并且f_back就是next函数或者send方法调用时所在的栈帧
print(g.send("matsuri"))
"""
创建了一个局部变量age
-1
"""
print(g.gi_frame.f_lasti) # 40
print(g.gi_frame.f_locals) # {'name': '夏色祭', 'age': -1}
print(next(g))
"""
创建了一个局部变量gender
-female
"""
print(g.gi_frame.f_lasti) # 58
print(g.gi_frame.f_locals) # {'name': '夏色祭', 'age': -1, 'gender': 'female'}
try:
next(g)
except StopIteration:
print("生成器执行完毕") # 生成器执行完毕
注意:一旦当生成器执行完毕之后,它的 gi_frame 会被设置为None。
print(g.gi_frame) # <frame at 0x000002353BB469F0...
try:
next(g)
except StopIteration:
print("生成器执行完毕") # 生成器执行完毕
print(g.gi_frame) # None
生成器小结
至此,生成器执行、暂停、恢复的全部秘密皆已揭开。流程如下:
生成器函数编译后代码对象带有 CO_GENERATOR 标识
如果函数代码对象带 CO_GENERATOR 标识,被调用时 Python 将创建生成器对象
生成器创建的同时,Python 还创建一个栈帧对象,用于维护代码对象执行上下文
调用 next函数或者send方法 驱动生成器执行,然后Python 将生成器栈帧对象插入栈帧链,f_back设置为调用的next函数或者send方法对应的栈帧, 开始执行字节码, 注意: 此时是在next函数或者send方法对应的栈帧中执行
执行到yield语句时,说明next函数或者send方法执行完毕了; 那么将yield右边的值压入运行时栈栈顶, 并终止字节码执行, 退回到上一级栈帧; 并且还会将生成器所在栈帧的f_back设置为空, 以及设置f_lasti等成员
yield值最终作为next函数或者send方法的返回值,被调用者取得
当再次调用next函数或者send方法时,Python会将生成器栈帧重新插入到栈帧链中,f_back设置为调用的next函数或者send方法对应的栈帧,然后继续执行生成器内部的字节码。从什么地方开始执行呢?显然是上一次中断的位置,那么上一次中断的位置Python如何得知?没错,显然是通过f_lasti(字节码偏移量),直接从f_lasti处开始执行
执行时,会从f_lasti、即上一次YIELD_VALUE处开始执行,并且会获得调用者通过send发来的值,然后继续往下执行
代码执行权就这样在调用者和生成器间来回切换,然后一直周而复始,直至生成器执行完毕。执行完毕之后,gi_frame就被设置为None了。
yield和yield from
除了yield还有一个yield from,估计有人理解不清它们的作用,这里我们简单的提一句。由于我们现在是分析解释器,所以这些Python层面的语法知识,就简单说一下。另外之所以会提到yield from,主要是为了后面的协程做铺垫。
def f1():
yield "夏色祭"
def f2():
yield from "夏色祭"
f1 = f1()
f2 = f2()
print(f1.__next__()) # 夏色祭
print(f2.__next__()) # 夏
yield后面可以跟可迭代对象和不可迭代对象,而yield from后面必须跟可迭代对象。当执行时,会将yield后面的值作为一个整体迭代出来,而yield from则是迭代"可迭代对象里面的一个值"。我们再举个栗子:
def foo1():
yield [1, 2, 3]
def foo2():
yield from [1, 2, 3]
f1 = foo1()
f2 = foo2()
print(f1.__next__()) # [1, 2, 3]
print(f2.__next__()) # 1
print(f2.__next__()) # 2
print(f2.__next__()) # 3
"""
所以我们发现了:
yield from [1, 2, 3]
等价于
for item in [1, 2, 3]
yield item
"""
如果要是但看上面的吗?会发现yield from貌似没什么特殊的地方,但是yield from它还可以作为委托生成器。
委托生成器:负责在调用方和子生成器之间建立一个双向通道。
def foo1():
for name in ["夏色祭", "神乐mea", "白上吹雪"]:
yield name
return "yagoo的偶像梦破灭了"
def foo2():
res = yield from foo1()
print(res)
f2 = foo2()
# 此时不经过foo2, 我们说调用方和子生成器之间建立了一个双向通道
print(f2.__next__()) # 夏色祭
print(f2.__next__()) # 神乐mea
print(f2.__next__()) # 白上吹雪
try:
f2.__next__()
except StopIteration:
print("迭代结束了")
"""
yagoo的偶像梦破灭了
迭代结束了
"""
这里在执行f2.__next__()的时候并没有经过foo2,因为它建立了一个双向通道,我们是直接找到foo1,同理foo1中yield后面的值也会直接返回给调用方。
一旦foo1中的代码执行完毕,理论上肯定会抛出StopIteration异常,但是有委托生成器。所以会将返回值交给委托生成器中的res,然后在委托生成器中继续寻找yield或者yield from。但是显然res = yield from foo1()这行代码下面已经没有yield或者yield from了,所以异常会由委托生成器抛出来。
def foo1():
for name in ["夏色祭", "神乐mea", "白上吹雪"]:
yield name
return "yagoo的偶像梦破灭了"
def foo2():
yield (yield from foo1())
f2 = foo2()
print(f2.__next__()) # 夏色祭
print(f2.__next__()) # 神乐mea
print(f2.__next__()) # 白上吹雪
print(f2.__next__()) # yagoo的偶像梦破灭了
# 显然这是最标准的写法
# 当foo1结束之后, 那么yield from foo1()就是foo1的返回值
# 因此等价于yield "yagoo的偶像梦破灭了", 在寻找下一个yield的时候, 正好将返回值迭代出来
小结
这次我们介绍了生成器,分析了它的执行原理,相信你对生成器会有一个更深的认识。另外在项目中,可以多尝试使用生成器。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏