楔子
前面我们分析了虚拟机执行字节码的原理,并且也介绍了不少指令,但这些指令都是从上往下顺序执行的,不涉及任何的跳转。而像流程控制语句,比如 if、for、while、try 等等,它们在执行时会发生跳转,因此 Python 底层一定还存在相应的跳转指令。
那么从现在开始,就来分析一下这些流程控制语句的实现原理,本文先来介绍 if 语句。
if 字节码
if 语句应该是最简单也是最常用的流程控制语句,那么它的字节码是怎么样的呢?当然这里的 if 语句指的是 if elif else 整体,里面的某个条件叫做该 if 语句的分支。
我们看一下 if 语句的字节码长什么样子。
import dis
code_string = """
score = 90
if score >= 85:
print("Good")
elif score >= 60:
print("Normal")
else:
print("Bad")
"""
dis.dis(compile(code_string, "<file>", "exec"))
反编译得到的字节码指令比较多,我们来慢慢分析。另外为了阅读方便,源代码行号就不显示了。
0 RESUME 0
# 加载常量 90 并压入运行时栈
2 LOAD_CONST 0 (90)
# 加载符号表中索引为 0 的符号 "score"
# 弹出运行时栈的栈顶元素 90
# 然后将两者绑定起来,存放在当前的名字空间中
4 STORE_NAME 0 (score)
# 加载变量 score
6 LOAD_NAME 0 (score)
# 加载常量 85
8 LOAD_CONST 1 (85)
# 进行比较,操作符是 >=,这个指令之前介绍过的
10 COMPARE_OP 92 (>=)
# 如果比较结果为 False,就进行跳转,从名字也能看出指令的含义
# 那么跳转到什么地方呢?指令参数 9 表示向前跳转 9 个指令
# 根据后面的提示,我们看到会跳转到偏移量为 34 的指令
# 很明显,就是当前分支的下一个分支。关于具体是怎么跳转的,一会儿说
14 POP_JUMP_IF_FALSE 9 (to 34)
# 如果走到这里说明没有跳转,当前分支的条件为真
# 那么开始执行该分支内部的逻辑
# PUSH_NULL 指令做的事情很简单,就是往栈里 PUSH 一个 NULL
16 PUSH_NULL
# 以下 4 条指令对应 print("Good")
18 LOAD_NAME 1 (print)
20 LOAD_CONST 2 ('Good')
22 CALL 1
30 POP_TOP
# if 语句只有一个分支会被执行
# 如果执行了某个分支,那么整个 if 语句就结束了
32 RETURN_CONST 6 (None)
# 对应 score >= 60
>> 34 LOAD_NAME 0 (score)
36 LOAD_CONST 3 (60)
38 COMPARE_OP 92 (>=)
# 如果比较结果为假,跳转到偏移量为 62 的指令
42 POP_JUMP_IF_FALSE 9 (to 62)
# 和上面类似
44 PUSH_NULL
46 LOAD_NAME 1 (print)
48 LOAD_CONST 4 ('Normal')
50 CALL 1
58 POP_TOP
60 RETURN_CONST 6 (None)
# 最后一个是 else 分支,而 else 分支没有判断条件
# 逻辑依旧和上面类似
>> 62 PUSH_NULL
64 LOAD_NAME 1 (print)
66 LOAD_CONST 5 ('Bad')
68 CALL 1
76 POP_TOP
78 RETURN_CONST 6 (None)
我们看到字节码偏移量之前有几个 >> 这样的符号,显然这是 if 语句中的每一个分支开始的地方。
经过分析,整个 if 语句的字节码指令还是很简单的。就是从上到下依次判断每一个分支,如果某个分支条件成立,就执行该分支的代码,执行完毕后结束整个 if 语句;否则跳转到下一个分支。
显然核心就在于 POP_JUMP_IF_FALSE 指令,我们看一下它的逻辑。
POP_JUMP_IF_FALSE
COMPARE_OP 执行完之后会将比较的结果压入运行时栈,而该指令则是将结果从栈顶弹出并判断真假。如果为假,那么跳到下一个分支,否则执行此分支的代码。
TARGET(POP_JUMP_IF_FALSE) {
PREDICTED(POP_JUMP_IF_FALSE);
// 从栈顶弹出比较结果,当然这里其实只是获取
// 如果再结合最下面的 STACK_SHRINK(1),那么等价于弹出
PyObject *cond = stack_pointer[-1];
#line 2157 "Python/bytecodes.c"
// 如果 cond is False,那么 Py_IsFalse(cond) 就是真
// 此时会通过 JUMPBY 跳转到 if 语句的下一个分支,关于 JUMPBY 一会儿介绍
if (Py_IsFalse(cond)) {
JUMPBY(oparg);
}
// 但对于 if 语句来说,除了 False 之外,像 None、0、""、[] 等也表示假
// 那么当 cond 也不是 True 的时候(说明不是布尔值),要继续判断
else if (!Py_IsTrue(cond)) {
// Py_IsTrue(cond):等价于 Python 的 cond is True
// PyObject_IsTrue(cond):等价于 Python 的 bool(cond) is True
// 所以 if cond 和 if bool(cond) 是等价的
int err = PyObject_IsTrue(cond);
#line 3047 "Python/generated_cases.c.h"
Py_DECREF(cond);
#line 2163 "Python/bytecodes.c"
// 如果 PyObject_IsTrue 返回 0,说明 bool(cond) 不是 True
// 即 cond 为假,意味着条件不成立,那么要跳转到 if 语句的下一个分支
if (err == 0) {
JUMPBY(oparg);
}
// 如果返回值小于 0,说明出错了(基本不会发生)
// 由于运行时栈里面还有一个元素
// 那么跳转到帧评估函数中的 pop_1_error
else {
if (err < 0) goto pop_1_error;
}
}
#line 3057 "Python/generated_cases.c.h"
STACK_SHRINK(1);
DISPATCH();
}
逻辑不难理解,但是里面出现了几个判断布尔值的函数,我们补充一下。
// Objects/object.c
// 等价于 Python 的 x is y
int Py_Is(PyObject *x, PyObject *y)
{
return (x == y);
}
// 等价于 Python 的 x is None
int Py_IsNone(PyObject *x)
{
return Py_Is(x, Py_None);
}
// 等价于 Python 的 x is True
int Py_IsTrue(PyObject *x)
{
return Py_Is(x, Py_True);
}
// 等价于 Python 的 x is False
int Py_IsFalse(PyObject *x)
{
return Py_Is(x, Py_False);
}
// 等价于 Python 的 bool(v) is True
int
PyObject_IsTrue(PyObject *v)
{
Py_ssize_t res;
// 如果 v 本身就是布尔值 True,返回 1
if (v == Py_True)
return 1;
// 如果 v 本身就是布尔值 False,返回 0
if (v == Py_False)
return 0;
// 如果 v 是 None,返回 0
if (v == Py_None)
return 0;
// 如果 v 是数值型对象,并且实现了 nb_bool(对应 __bool__)
// 那么调用,如果结果不为 0,返回 1,否则返回 0
else if (Py_TYPE(v)->tp_as_number != NULL &&
Py_TYPE(v)->tp_as_number->nb_bool != NULL)
res = (*Py_TYPE(v)->tp_as_number->nb_bool)(v);
// 如果 v 是映射型对象,并且实现了 mp_length(对应 __len__)
// 那么调用,返回对象的长度
else if (Py_TYPE(v)->tp_as_mapping != NULL &&
Py_TYPE(v)->tp_as_mapping->mp_length != NULL)
res = (*Py_TYPE(v)->tp_as_mapping->mp_length)(v);
// 如果 v 是序列型对象,并且实现了 sq_length(对应 __len__)
// 那么调用,返回对象的长度
else if (Py_TYPE(v)->tp_as_sequence != NULL &&
Py_TYPE(v)->tp_as_sequence->sq_length != NULL)
res = (*Py_TYPE(v)->tp_as_sequence->sq_length)(v);
// 如果以上条件都不满足,直接返回 1,比如自定义类的实例对象(默认为真)
else
return 1;
// 如果 res > 0 返回 1,否则返回 0
return (res > 0) ? 1 : Py_SAFE_DOWNCAST(res, Py_ssize_t, int);
}
// 等价于 Python 的 not v
int
PyObject_Not(PyObject *v)
{
int res;
res = PyObject_IsTrue(v);
if (res < 0)
return res;
// 如果 v 是真,res == 1,那么 res == 0 结果是 0
// 如果 v 是假,res == 0,那么 res == 0 结果是 1
// 相当于取反
return res == 0;
}
// Objects/boolobject.c
static PyObject *
bool_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
// <class 'bool'> 是一个 Python 类,这里的 bool_new 便是它的构造函数
PyObject *x = Py_False;
long ok;
// 不接收关键字参数
if (!_PyArg_NoKeywords("bool", kwds))
return NULL;
// 只接收 0 ~ 1 个参数,如果不传,那么默认返回 False
if (!PyArg_UnpackTuple(args, "bool", 0, 1, &x))
return NULL;
// 调用 PyObject_IsTrue,所以我们说 if v 和 if bool(v) 是等价的
// 因为当 v 不是布尔值时,if v 对应的指令内部会调用 PyObject_IsTrue
// 而 bool(v) 也会调用 PyObject_IsTrue,所以两者是等价的
ok = PyObject_IsTrue(x);
if (ok < 0)
return NULL;
// 调用 PyBool_FromLong 创建布尔值,ok 为 1 返回 True,为 0 返回 False
return PyBool_FromLong(ok);
}
PyObject *PyBool_FromLong(long ok)
{
return ok ? Py_True : Py_False;
}
相信你现在明白了为什么 if 后面不跟布尔值也是可以的,因为有一个 C 函数 PyObject_IsTrue,可以判断任意对象的真假。如果 if 后面跟着的不是布尔值,那么会自动调用该函数。另外由于 bool(v) 也会调用该函数,所以 if v 和 if bool(v) 是等价的。
注:没有 PyObject_IsFalse。
说完了 POP_JUMP_IF_FALSE 指令,再补充一个和它相似的指令叫 POP_JUMP_IF_TRUE,它表示当比较结果为真时,跳到下一个分支,否则执行当前分支的代码。可能有人觉得,这不对吧,比较结果为真,难道不应该执行当前分支的逻辑吗?所以 POP_JUMP_IF_TRUE 指令似乎本身就是矛盾的。
仔细想想你应该能够猜到原因,答案就是使用了 not。
import dis
code_string = """
if 2 > 1:
print("古明地觉")
"""
# 只打印部分字节码
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_FALSE 9 (to 30)
"""
code_string = """
if not 2 > 1:
print("古明地觉")
"""
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_TRUE 9 (to 30)
"""
正常情况下如果比较结果为 False,则跳转到 if 语句的下一个分支,所以 POP_JUMP_IF_FALSE 指令是合理的。至于 POP_JUMP_IF_TRUE 指令从逻辑上似乎就不该存在,因为它和 if 语句本身是相矛盾的。
但现在我们明白了,该指令其实是为 not 关键字准备的。如果比较结果为真,那么 not 取反就是假,于是跳转到 if 语句的下一个分支,所以整个逻辑依旧是正确的。
当然这里只有一个 not,即使有很多个 not 也是可以的,尽管这没太大意义。
import dis
# 这里有 4 个 not,因为是偶数个,两两相互抵消
# 所以结果等价于 if 2 > 1
code_string = """
if not not not not 2 > 1:
print("古明地觉")
"""
# 只打印部分字节码
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_FALSE 9 (to 30)
"""
# 这里有 5 个 not,因为是奇数个,两两相互抵消之后还剩下一个
# 所以结果等价于 if not 2 > 1
code_string = """
if not not not not not 2 > 1:
print("古明地觉")
"""
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_TRUE 9 (to 30)
"""
然后再看一下 POP_JUMP_IF_TRUE 指令的内部逻辑,显然它和 POP_JUMP_IF_FALSE 是类似的。
TARGET(POP_JUMP_IF_TRUE) {
// 获取栈顶元素
PyObject *cond = stack_pointer[-1];
#line 2173 "Python/bytecodes.c"
// 如果 cond is True,跳转到 if 语句的下一个分支
if (Py_IsTrue(cond)) {
JUMPBY(oparg);
}
// 如果 cond 不是 True,那么看 bool(cond) 是否为 True
else if (!Py_IsFalse(cond)) {
int err = PyObject_IsTrue(cond);
#line 3070 "Python/generated_cases.c.h"
Py_DECREF(cond);
#line 2179 "Python/bytecodes.c"
// err > 0,说明结果为真,跳转到 if 语句的下一个分支
if (err > 0) {
JUMPBY(oparg);
}
// 否则说明比较出错了(基本不会发生)
else {
if (err < 0) goto pop_1_error;
}
}
#line 3080 "Python/generated_cases.c.h"
STACK_SHRINK(1);
DISPATCH();
}
以上就是 POP_JUMP_IF_FALSE 和 POP_JUMP_IF_TRUE 的内部逻辑,可以说非常简单。
JUMPBY
指令跳转是由 JUMPBY 实现的,它内部的逻辑长啥样呢?
// Python/ceval_macros.h
#define JUMPBY(x) (next_instr += (x))
字节码指令的遍历是通过 next_instr 实现的,如果将指令执行的方向代表前进的方向,显然它表示从当前指令所在的位置向前跳转 x 个指令。
图片
POP_JUMP_IF_FALSE 指令的偏移量为 14,向前跳转 9 个指令,一个指令的大小是 2 字节,所以结果是 14 + 18 = 32。咦,不是应该跳转到偏移量为 34 的指令吗,为啥结果是 32 呢?
很简单,TARGET 是一个宏,它在展开之后,还会对 next_instr 做一次自增操作。
图片
除了 JUMPBY 之外,还有一个 JUMPTO,它表示从字节码的起始位置向前跳转 x 个指令。
// Python/ceval_macros.h
// 从字节码的起始位置向前跳转 x 个指令
#define JUMPTO(x) (next_instr = _PyCode_CODE(frame->f_code) + (x))
// 从 next_instr 处(指向当前待执行的指令)向前跳转 x 个指令
#define JUMPBY(x) (next_instr += (x))
所以 JUMPTO 表示绝对跳转,JUMPBY 表示相对跳转。不难发现,JUMPTO 既可以向前跳转(偏移量增大),也可以向后跳转(偏移量减小);而 JUMPBY 只能向前跳转。
假设参数为 n,当前指令的偏移量为 m。对于 JUMPTO 而言,跳转之后的偏移量始终为 2n,如果 m < 2n 就是向前跳转,m > 2n 就是向后跳转。但对于 JUMP_BY 而言,由于它是从当前待执行的指令开始跳转的,所以只能向前跳转(偏移量增大)。
指令预测
CPython 3.12 里面引入了计算跳转,可以避免不必要的匹配。因为整个指令集合是已知的,这就说明某条指令在执行时,便可知道它的下一条指令是什么。
所以当前指令处理完后,可以直接跳转到下一条指令对应的处理逻辑中,这就是计算跳转。但如果不使用计算跳转,那么每次读取到指令后,都要进入 switch,顺序匹配数百个 case 分支,找到匹配成功的那一个。
因此使用计算跳转可以避免不必要的匹配,既然提前知道下一条指令是啥了,那么直接精确跳转就行,无需多走一遍 switch。不过要想实现计算跳转,需要 GCC 支持标签作为值,即 goto *label_addr 用法,由于 label_addr 是一个标签地址,那么解引用之后就是标签了。至于具体会跳转到哪一个标签,取决于 label_addr 保存了哪一个标签的地址,因此这种跳转是动态的,在运行时决定跳转目标。
goto 标签:静态跳转,标签需要显式地定义好,跳转位置在编译期间便已经固定。
goto *标签地址:动态跳转(计算跳转),跳转位置不固定,可以是已有标签中的任意一个。至于具体是哪一个,需要在运行时经过计算才能确定。
虚拟机为每个指令的处理逻辑都定义了一个标签,对于计算跳转来说,goto 的结果是 *标签地址,这个地址是运行时计算得出的。我们举个例子,随便看一段字节码指令集。
比如当前正在执行 LOAD_NAME 指令,那么下一条指令可以是 STORE_NAME、LOAD_NAME 以及 BUILD_LIST 等。当开启计算跳转时:
- 如果下一条指令是 STORE_NAME,那么之后就会跳转 STORE_NAME 对应的标签;
- 如果下一条指令是 LOAD_NAME,那么之后就会跳转到 LOAD_NAME 对应的标签;
- 如果下一条指令是 BUILD_LIST,那么之后就会跳转到 BUILD_LIST 对应的标签;
所以在运行时判断指令的值,获取对应的标签,从而实现精确跳转,这就是计算跳转。当然这些内容在剖析虚拟机执行字节码时已经说过了,这里再回顾一下。
接下来说一说指令预测,不难发现,如果是计算跳转,那么指令预测功能貌似没啥用,因为总是能精确跳转到下一个指令对应的标签中。没错,指令预测只有在不使用计算跳转的情况下有用,那什么是指令预测呢?
在不使用计算跳转时,goto 后面必须是一个静态的标签,跳转位置在编译阶段便已经固定,换句话说一个指令执行完毕后要跳转到哪一个标签是写死的,不能保证跳转后的标签正好对应下一条指令的处理逻辑。比如 LOAD_NAME 的下一条指令可以是 STORE_NAME 和 BUILD_LIST,那么应该跳转到哪一个指令对应的标签中呢?
正因为这种不确定性,绝大部分指令在执行完毕后都会直接跳转到 switch 语句所在位置,然后顺序匹配 case 分支。
但也有那么几个指令,由于彼此的关联性很强,很多时候都是成对出现的,面对这样的指令,虚拟机会进行预测。比如 A 和 B 两个指令的关联性很强,尽管 A 的下一条指令除了是 B 之外,也有可能是其它指令,但 B 出现的概率是最大的,因此虚拟机会预测下一条指令是 B 指令。于是在执行完 A 指令之后,会验证自己的预测是否正确,即检测下一条指令是否是 B 指令。如果预测对了,可以实现精确跳转,如果预测错了,就只能回到 switch 语句逐一匹配 case 分支了。
总结一下:指令在执行时,它的下一条指令是已知的,但是不固定,有多种可能。如果不使用计算跳转,由于 goto 后面必须是一个写死的标签,而下一条指令却不固定,那么只能跳转到 switch 语句所在的位置,顺序匹配 case 分支。但也有那么几对指令,关联性很强,虽然不能保证百分百,但值得做一次尝试,这便是指令预测。
当然啦,如果使用计算跳转,情况则不一样了,此时压根用不到指令预测。因为 goto 后面是 *标签地址,而地址是可以动态获取的。由于所有标签的地址都保存在了一个数组中,不管接下来要处理哪一条指令,都可以获取到对应的标签地址,实现精确跳转。
以上有很多都是之前说过的内容,再重复一遍加深印象。好,关于指令预测我们已经知道是啥了,那么在源码层面又是如何体现的呢?
在 POP_JUMP_IF_FALSE 指令中,我们看到有这么一行逻辑。
图片
里面有一个宏 PREDICTED。
// Python/ceval_macros.h
#define PREDICTED(op) PREDICT_ID(op):
#define PREDICT_ID(op) PRED_##op
这个宏展开之后又是一个标签,由于调用时结尾加了分号,所以这还是一个空标签。整体效果如下:
图片
那么它有什么用呢?我们再看一个指令就明白了。
图片
MATCH_SEQUENCE 指令是做什么的,我们以后再说,总之虚拟机认为该指令执行完之后,大概率会执行 POP_JUMP_IF_FALSE 指令,所以做了一个预测。而相关逻辑位于 PREDICT 中,看一下它长什么样子。
// Python/ceval_macros.h
#define PREDICT_ID(op) PRED_##op
// 如果开启计算跳转,那么指令预测不生效
// 因为本身就知道该跳转到哪个指令对应的标签
#if USE_COMPUTED_GOTOS
#define PREDICT(op) if (0) goto PREDICT_ID(op)
#else
// 如果不开启计算跳转,那么会比较预测的指令和实际的指令是否相等
// 所以 MATCH_SEQUENCE 指令处理逻辑里面的 PREDICT(POP_JUMP_IF_FALSE)
// 就是在判断下一条指令是不是自己预测的 POP_JUMP_IF_FALSE
// 如果是,说明预测成功,那么 goto PRED_POP_JUMP_IF_FALSE
// 否则说明预测失败,那么会执行 DISPATCH(),然后 goto 到 switch 语句所在位置
#define PREDICT(next_op) \
do { \
_Py_CODEUNIT word = *next_instr; \
opcode = word.op.code; \
if (opcode == next_op) { \
oparg = word.op.arg; \
INSTRUCTION_START(next_op); \
goto PREDICT_ID(next_op); \
} \
} while(0)
#endif
以上便是指令预测,说白了就是如果指令 A 和指令 B 具有极高的关联性(甚至百分百),那么执行完 A 指令后会判断下一条指令是不是 B。如果是,那么直接跳转即可,就省去了匹配 case 分支的时间,如果不是,那就只能挨个匹配了。
因为是静态跳转,goto 后面的标签是写死的,编译阶段就确定了,所以只有那种关联度极高的指令才会开启预测功能,因为预测成功的概率比较高。但如果指令 A 的下一条指令有多种可能(假设有 6 种),并且每种指令出现的概率还差不多,那么这时不管预测哪一个,成功的概率都只有 1/6。显然这就不叫预测了,这是在掷骰子,因此对于这样的指令,虚拟机不会为它开启预测功能。
图片
比如 LOAD_NAME 的下一个指令可以是 STORE_NAME、LOAD_NAME、BUILD_LIST 等等,不管预测哪一种,成功的概率都不是特别高,因此它没有进行指令预测。
所以就一句话:只有 A 和 B 两个指令的关联度极高的时候,执行 A 之后才会预测下一条指令是否是 B。预测成功直接跳转,预测失败执行 DISPATCH(),跳转到 switch 语句所在位置,即 dispatch_opcode 标签。
但如果使用了计算跳转,情况就不一样了,此时不会开启指令预测,或者说指令预测里的逻辑会变得无效。
图片
很明显,使用计算跳转后,PREDICT(op) 不会产生任何效果,因此也可以理解为没有开启指令预测。而之所以不用预测,是因为执行 DISPATCH() 的时候,本身就可以精确跳转到指定位置。
小结
本篇文章我们就分析了 if 语句的实现原理,总的来说不难理解。依旧是在栈桢中执行字节码,只是多了一个指令跳转罢了,至于怎么跳转、跳转到什么地方,全部都体现在字节码中。