
楔子

后续打算深入介绍 Python 的协程,这里先补充一些前置的知识。
前面我们介绍了 Python 的 PyCodeObject 对象,它是解释器对源代码编译之后的结果。该对象内部有很多属性,比如 co_code 负责存储相应的字节码,也就是虚拟机要执行的指令序列;co_names、co_consts 等等则负责存储代码中的符号、常量等静态信息。
那么问题来了,既然源代码在经过编译之后,所有字节码指令以及相关的静态信息都存储在 PyCodeObject 对象当中,那么是不是意味着虚拟机就在 PyCodeObject 对象上进行所有的动作呢?
答案显然不是的,虽然 PyCodeObject 包含了关键的字节码指令以及静态信息,但有一个东西是没有包含、也不可能包含的,就是程序在运行时的执行环境,这个执行环境在 Python 里面叫做栈帧。

什么是栈帧

栈帧,它是字节码执行时的上下文。我们看一个示例:
name = "古明地觉"
def f():
name = "古明地恋"
print(name)
f()
print(name)
"""
古明地恋
古明地觉
"""
上面的代码中出现了两个 print(name),它们的字节码指令是相同的,但执行的效果却显然不同,而这样的结果正是执行环境的不同所产生的。因为环境的不同,name 的值也不同。
因此同一个符号在不同环境中可能指向不同的值,必须在运行时进行动态捕捉和维护,这些信息不可能在 PyCodeObject 对象中被静态存储。
因此虚拟机并不是在 PyCodeObject 对象上执行操作的,而是栈帧对象。虚拟机在执行时,会根据 PyCodeObject 对象动态创建出栈帧对象,然后在栈帧里面执行字节码。
因此对于上面的代码,我们可以大致描述一下流程:
-
当虚拟机在执行第一条语句时,已经创建了一个栈帧,这个栈帧显然是模块对应的栈帧,假设叫做 A;
-
所有的字节码都会在这个栈帧中执行,虚拟机可以从栈帧里面获取变量的值,也可以修改;
-
当发生函数调用的时候,这里是函数 f,那么虚拟机会在栈帧 A 之上,为函数 f 创建一个新的栈帧,假设叫 B,然后在栈帧 B 里面执行函数 f 的字节码指令;
-
在栈帧 B 里面也有一个名字为 name 的变量,但由于执行环境、或者说栈帧的不同,name 也不同。比如两个人都叫小明,但一个是北京的、一个是上海的,所以这两者没什么关系;
-
一旦函数 f 的字节码指令全部执行完毕,那么会将当前的栈帧 B 销毁(也可以保留下来),再回到调用者的栈帧当中。就像是递归一样,每当调用函数时,就会在当前栈帧之上创建一个新的栈帧,一层一层创建,一层一层返回;
而实际上虚拟机执行字节码这个过程,就是在模拟操作系统运行可执行文件。我们再用一段 Python 代码解释一下:
def f(a, b):
return a + b
def g():
return f()
g()
程序先调用函数 g,那么会为函数 g 创建栈帧;然后在函数 g 里面调用函数 f,那么系统就又会在地址空间中,于函数 g 的栈帧之上创建函数 f 的栈帧。
当程序执行函数 g 时,那么当前帧就是函数 g 的栈帧,调用者的帧则是模块的栈帧。而当程序执行函数 f 时,那么当前帧就是函数 f 的栈帧,而调用者的帧则是函数 g 的栈帧。
栈是先入后出的数据结构,内存地址从栈底到栈顶是减小的。对于一个函数而言,所有对局部变量的操作都在自己的栈帧中完成,而调用函数的时候则会为其创建新的栈帧。
当函数 f 的调用完成时,对应的栈帧就会被销毁,然后程序的运行空间会回到函数 g 的栈帧中。
那么下面我们就来看看栈帧在底层长什么样,注意:栈帧也是一个对象。

栈帧的底层结构

栈帧在底层是由 PyFrameObject 结构体表示的,但相比操作系统运行可执行文件时创建的栈帧,Python 的栈帧实际上包含了更多的信息。
typedef struct _frame {
//可变对象的头部信息
PyObject_VAR_HEAD
//上一级栈帧, 也就是调用者的栈帧
struct _frame *f_back;
//PyCodeObject对象
//通过栈帧的f_code属性可以获取对应的PyCodeObject对象
PyCodeObject *f_code;
//builtin名字空间,一个PyDictObject对象
PyObject *f_builtins;
//global名字空间,一个PyDictObject对象
PyObject *f_globals;
//local名字空间,一个PyDictObject对象
PyObject *f_locals;
//运行时的栈底位置
PyObject **f_valuestack;
//运行时的栈顶位置
PyObject **f_stacktop;
//回溯函数,打印异常栈
PyObject *f_trace;
//是否触发每一行的回溯事件
char f_trace_lines;
//是否触发每一个操作码的回溯事件
char f_trace_opcodes;
//是否是基于生成器的PyCodeObject构建的栈帧
PyObject *f_gen;
//上一条已执行完毕的指令在f_code中的偏移量
int f_lasti;
//当前字节码对应的源代码行号
int f_lineno;
//当前指令在栈f_blockstack中的索引
int f_iblock;
//当前栈帧是否仍在执行
char f_executing;
//用于try和loop代码块
PyTryBlock f_blockstack[CO_MAXBLOCKS];
//动态内存
//维护 "局部变量+cell对象集合+free对象集合+运行时栈" 所需要的空间
PyObject *f_localsplus[1];
} PyFrameObject;
因此虚拟机会根据 PyCodeObject 对象来创建一个栈帧,也就是 PyFrameObject 对象,虚拟机实际是在栈帧对象上执行操作的。
每一个 PyFrameObject 都会维护一个 PyCodeObject,换言之,每一个 PyCodeObject 都会隶属于一个 PyFrameObject。并且从 f_back 可以看出,Python 在实际执行时,会产生很多的 PyFrameObject 对象,而这些对象会被链接起来,形成一条执行环境链表,或者说栈帧链表。
而这正是操作系统栈帧之间关系的模拟,对于操作系统而言,栈帧之间通过 rsp 和 rbp 指针建立了联系,使得新栈帧在结束之后能够顺利地返回到旧栈帧中,而 Python 则是利用 f_back 来完成这个动作。
栈帧里面的 f_code 指向相应的 PyCodeObject 对象,而 f_builtins、f_globals、f_locals 则是指向三个独立的名字空间。在这里,我们看到了名字空间和执行环境(栈帧)之间的关系,前者只是后者的一部分。
名字空间负责维护变量和对象之间的映射关系,通过名字空间,我们能够找到一个符号被绑定在了哪个对象上。
另外在 PyFrameObject 的开头有一个 PyObject_VAR_HEAD,表示栈帧是一个变长对象,即每次创建的栈帧的大小可能是不一样的,那么这个变动在什么地方呢?
首先每一个 PyFrameObject 对象都维护了一个 PyCodeObject 对象,而每一个 PyCodeObject 对象都会对应一个代码块。在编译一段代码块的时候,会计算这段代码块执行时所需要的栈空间的大小,这个栈空间大小存储在 PyCodeObject 对象的 co_stacksize 中。
而不同的代码块所需要的栈空间是不同的,因此栈帧是一个变长对象。最后,其实栈帧里面的内存空间分为两部分,一部分是编译代码块需要的空间,另一部分是执行代码块所需要的空间,也称之为运行时栈(后续聊),不过我们只需要关注运行时栈即可。

在 Python 中访问栈帧

如果要在 Python 里面拿到栈帧对象,可以通过 inspect 模块。
import inspect
def f():
# 返回当前所在的栈帧
# 这个函数实际上是调用了 sys._getframe(1)
return inspect.currentframe()
frame = f()
print(frame)
"""
<frame at ..., file 'D:/satori/main.py', line 6, code f>
"""
print(type(frame))
"""
<class 'frame'>
"""
我们看到栈帧的类型是<class ‘frame’>,正如 PyCodeObject 对象的类型是 <class ‘code’> 一样。这两个类没有暴露给我们,所以不可以直接使用。
同理还有函数,类型是 <class ‘function’>;模块,类型是 <class ‘module’>。这些解释器都没有暴露给我们,如果直接使用的话,那么 frame、code、function、module 只是几个没有定义的变量罢了,这些类我们只能通过这种间接的方式获取。
下面我们就来访问一下栈帧的成员属性。
import inspect
def f():
name = "古明地觉"
age = 16
return inspect.currentframe()
def g():
name = "魔理沙"
age = 333
return f()
# 当我们调用函数 g 的时候,也会触发函数 f 的调用
# 而一旦 f 执行完毕,那么 f 对应的栈帧就被全局变量 frame 保存起来了
frame = g()
print(frame)
"""
<frame at ... 'D:/satori/main.py', line 6, code f>
"""
# 获取上一级栈帧,即调用者的栈帧
# 显然是函数 g 的栈帧
print(frame.f_back)
"""
<frame at ... 'D:/satori/main.py', line 11, code g>
"""
# 模块也是有栈帧的,我们后面会单独说
print(frame.f_back.f_back)
"""
<frame at ... 'D:/satori/main.py', line 27, code <module>>
"""
# 显然最外层就是模块了
# 模块对应的上一级栈帧是None
print(frame.f_back.f_back.f_back)
"""
None
"""
# 获取 PyCodeObject 对象
print(frame.f_code)
print(frame.f_back.f_code)
"""
<code object f ... "D:/satori/main.py", line 3>
<code object g ... "D:/satori/main.py", line 8>
"""
print(frame.f_code.co_name)
print(frame.f_back.f_code.co_name)
"""
f
g
"""
# 获取 f_locals
# 即栈帧内部的 local 名字空间
print(frame.f_locals)
"""
{'name': '古明地觉', 'age': 16}
"""
print(frame.f_back.f_locals)
"""
{'name': '魔理沙', 'age': 333}
"""
# 获取栈帧创建时对应的行号
print(frame.f_lineno)
print(frame.f_back.f_lineno)
"""
6
11
"""
# 行号为 6 的位置是: return inspect.currentframe()
# 行号为 11 的位置是: return f()
我们看到函数运行完毕之后,里面的局部变量居然还能获取,原因就是栈帧没被销毁,因为它被返回了,而且被外部变量接收了。同理该栈帧的上一级栈帧也不能被销毁,因为当前栈帧的 f_back 指向它了,引用计数不为 0,所以要保留。
通过栈帧可以获取很多的属性,我们后面还会慢慢说。此外,异常处理也可以获取到栈帧。
import sys
def foo():
try:
1 / 0
except ZeroDivisionError as e:
_, _, exc_tb = sys.exc_info()
# exc_tb 还可以通过 e.__traceback__ 获取
print(exc_tb)
"""
<traceback object at 0x00000135CEFDF6C0>
"""
# 调用 exc_tb.tb_frame 即可拿到异常对应的栈帧
print(exc_tb.tb_frame)
"""
<frame at ... 'D:/satori/main.py', line 15, code foo>
"""
print(exc_tb.tb_frame.f_back)
"""
<frame at ... 'D:/satori/main.py', line 31, code <module>>
"""
# 显然 exc_tb.tb_frame 是当前函数 foo 的栈帧
# 那么 exc_tb.tb_frame.f_back 就是整个模块对应的栈帧
# 那么再上一级的话, 栈帧就是 None 了
print(exc_tb.tb_frame.f_back.f_back)
"""
None
"""
foo()
通过以上两种方式即可在 Python 中获取栈帧对象。
很多动态信息无法静态地存储在 PyCodeObject 对象中,所以虚拟机会在其之上动态地构建出 PyFrameObject 对象,也就是栈帧。因此虚拟机是在栈帧里面执行的字节码,它包含了虚拟机在执行字节码时依赖的全部信息。
原文始发于微信公众号(古明地觉的编程教室):解密虚拟机的执行环境:栈帧对象
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/104887.html