本系列文章首先概述了CPython VM。我们了解到,要运行一个Python程序,CPython首先将其编译成字节码,我们在第二部分中研究了该编译器是如何工作的。上一次我们单步执行了CPython源代码,从main()函数开始,一直到计算循环,这是执行Python字节码的地方。我们花时间研究这些东西的主要原因是为今天开始的讨论做准备。讨论的目标是理解CPython如何执行我们告诉它做的事情,即它如何执行我们编写的代码编译到的字节码。
注:在这篇文章中,我指的是CPython3.9。随着CPython的发展,一些实现细节肯定会发生变化。我将尝试跟踪重要更改并添加更新笔记。
让我们简要回顾一下我们在前几部分中学到的东西。我们通过编写Python代码告诉CPython要做什么。然而,CPython VM只理解Python字节码。这是编译器将Python代码转换为字节码的工作。编译器将字节码存储在代码对象中,代码对象是一种完全描述代码块(如模块或函数)功能的结构。要执行代码对象,CPython首先要为其创建一个称为Frame对象的执行状态。然后,它将帧对象传递给帧求值函数以执行实际计算。默认帧求值函数为_PyEval_EvalFrameDefault(),该函数在Python/ceval.c中定义。该函数实现了CPython VM的核心。也就是说,它实现了执行Python字节码的逻辑。所以,这个函数就是我们今天要学习的。
要理解_PyEval_EvalFrameDefault()的工作原理,关键是要了解它的输入(Frame对象)是什么。Frame对象是由以下C结构定义的Python对象:
//tyfinf struct_Frame PyFrameObject;在其他地方Struct_Frame{PyObject_VAR_Head Struct_Frame*f_back;/*上一帧或空*/PyCodeObject*f_code;/*代码段*/PyObject*f_Builtins;/*内置符号表(PyDictObject)*/PyObject*f_Globals;/*全局符号表(PyDictObject)*/PyObject*f。/*在f_valuestack中的最后一个本地*//*下一个可用槽之后的点数。帧创建设置为f_valuestack。框架求值通常为空,但生成的框架会将其设置为当前堆栈顶部。*/PyObject**f_stacktop;PyObject*f_trace;/*跟踪函数*/char f_TRACE_LINES;/*发出逐行跟踪事件?*/char f_trace_opcodes;/*发出每操作码跟踪事件?*//*借用对生成器的引用*/PyObject*f_gen;int f_lasti;/*调用最后一条指令*/int f_lineno;/*当前行号*/int。/*索引在f_block STACK中*/char f_Executing;/*帧是否还在执行*/PyTryBlock f_block STACK[CO_MAXBLOCKS];/*for try and loop块*/PyObject*f_localplus[1];/*LOCALS+STACK,动态调整大小*/};
帧对象的f_code字段指向代码对象。代码对象也是Python对象。以下是它的定义:
Struct PyCodeObject{PyObject_head int co_argcount;/*#参数,除*args*/int co_posonlyargcount;/*#仅位置参数*/int co_kwan lyargcount;/*#仅关键字参数*/int co_nlocals;/*#局部变量*/int co_stacksize;/*#求值堆栈所需的条目*/int co_flag;/*CO_...,见下文*/int co_firstlineno。/*指令操作码*/PyObject*co_consts;/*List(使用的常量)*/PyObject*co_names;/*字符串列表(使用的名称)*/PyObject*co_varname;/*字符串数组(局部变量名称)*/PyObject*co_freevars;/*字符串数组(自由变量名称)*/PyObject*co_cellvars;/*字符串数组(单元格变量名称)*//*其余字符串数组为。T在散列或比较中使用,但co_name除外,在两者中都使用。这样做是为了保留用于回溯和调试器的名称和行号;否则,持续的重复数据删除将导致在不同行上定义的相同函数/lambda崩溃。*/py_ssize_t*co_cell2arg;/*映射作为参数的单元格变量。*/PyObject*co_filename;/*unicode(从中加载)*/PyObject*co_name;/*unicode(名称,仅供参考)*/PyObject*co_lnotab;/*string(编码地址<;->;lineno映射)有关详细信息,请参阅Objects/lnotab_notes.txt。*/void*co_zombieframe;/*仅用于优化(请参阅framework object.c)*/PyObject*co_neware refirist;/*支持对代码对象的弱引用*//*暂存空间用于与代码对象相关的额外数据。Type是一个空*,表示在codeobject.c中保持格式私有,从而强制人们通过正确的API。*/无效*
如果上述结构中的一些成员对你来说仍然是个谜,请不要担心。随着我们继续尝试理解CPythonVM是如何执行字节码的,我们将了解它们的用途。
执行Python字节码的问题对您来说可能是轻而易举的。实际上,VM所要做的就是迭代这些指令并根据它们执行操作。这就是本质上_PyEval_EvalFrameDefault()所做的事情。它包含一个无限的for(;;)循环,我们称之为求值循环。在该循环中,所有可能的操作码都有一个巨大的switch语句。每个操作码都有一个对应的CASE块,其中包含用于执行该操作码的代码。字节码由16位无符号整数数组表示,每条指令一个整数。VM使用Next_instr变量跟踪要执行的下一条指令,该变量是指向指令数组的指针。在评估循环的每次迭代开始时,VM通过分别取下一条指令的最低有效字节和最高有效字节来计算下一操作码及其参数,并递增Next_instr。_PyEval_EvalFrameDefault()函数将近3000行,但其精髓可以通过以下简化版本获取:
PyObject*_PyEval_EvalFrameDefault(PyThreadState*tState,PyFrameObject*f,int throwflag){//...。局部变量的声明和初始化//...。宏定义//...。呼叫深度处理//...。用于跟踪和分析(;;){//...。检查是否必须挂起字节码执行,//例如,其他线程请求GIL//NEXTOPARG()MACRO_Py_CODEUNIT WORD=*NEXT_INSTR;//_Py_CODEUNIT是uint16_t opcode=_Py_OPCODE(Word);oparg=_Py_OPARG(Word);Next_instr++;Switch(Opcode){CASE TARGET(NOP){FAST_TARE(FAST_)的类型定义。//稍后将详细介绍}案例目标(LOAD_FAST){//...。用于加载局部变量的代码}//...每个可能的操作码增加117个案例}//...。错误处理}//...。终止}。
为了获得更真实的画面,让我们更详细地讨论一些省略的部分。
当前运行的线程会不时地停止执行字节码,以执行其他操作或不执行任何操作。发生这种情况的原因有四种:
有一些信号需要处理。当您使用signal.ignal()将函数注册为信号处理程序时,CPython会将该函数存储在处理程序数组中。当线程收到信号时,实际调用的函数是Signal_Handler()(在类Unix系统上,它被传递给sigaction()库函数)。当被调用时,Signal_Handler()设置一个布尔变量,告知必须调用与接收到的信号对应的处理程序数组中的函数。主解译器的主线程会定期调用被触发的处理程序。
有挂起的呼叫要呼叫。挂起调用是一种允许从主线程调度要执行的函数的机制。此机制由Python/C API通过Py_AddPendingCall()函数公开。
将引发异步异常。异步异常是在一个线程中设置的来自另一个线程的异常。这可以使用由Python/C API提供的PyThreadState_SetAsyncExc()函数来完成。
请求当前运行的线程丢弃GIL。当它看到这样的请求时,它丢弃GIL并等待,直到它再次获得GIL。
CPython为这些事件中的每一个都提供了指示器。指示存在要调用的处理程序的变量是Runtime->;Cval的成员,它是_Cval_Runtime_State结构:
STRUT_CAVAL_RUNTIME_STATE{/*检查信号的请求。它由所有口译员共享(见BPO-40513)。任何解释器的任何线程都可以接收信号,但只有主解释器的主线程可以处理信号:请参见_Py_ThreadCanHandleSignals()。*/_Py_ATOM_INT Signals_Pending;Struct_Gil_Runtime_State Gil;};
STRUT_CAVAL_STATE{int RECURSION_LIMIT;/*记录是否对任何线程启用跟踪。统计tstate->;c_tracefunc为非空的线程数,因此如果值为0,我们就知道不必检查该线程的c_tracefunc。这加快了FAST_NEXT_OPCODE之后的_PyEval_EvalFrameDefault()中的if语句的运行速度。*/int Tracing_Possible;/*这个单一变量合并了所有请求,以便在val循环中跳出快速路径。*/_Py_ATOM_INT EVAL_BREAKER;/*丢弃GIL请求*/_Py_ATOM_INT GIL_DROP_REQUEST;STRUT_PENDING_CALLES PENDING;};
对所有指示符进行OR运算的结果存储在eval_Breaker变量中。它告诉当前运行的线程是否有任何理由停止其正常的字节码执行。评估循环的每一次迭代都以检查eval_Breaker是否为真开始。如果是真的,线程检查指示符以确定它到底被要求做什么,这样做并继续执行字节码。
求值循环的代码充满了目标()和调度()等宏。这些不是使代码更紧凑的方法。它们可以扩展到不同的代码,具体取决于特定的优化是否计算了Gotos(也就是。使用";线程代码";)。这种优化的目标是通过以这种方式编写代码来加速字节码的执行,以便CPU可以使用其分支预测机制来预测下一个操作码。
它从求值函数返回。这在VM执行RETURN_VALUE、YIELD_VALUE或YIELD_FROM指令时发生。
它处理错误并继续执行,或者从具有异常集的求值函数返回。例如,当VM执行BINARY_ADD指令,而要添加的对象没有实现__add__和__radd__方法时,可能会出现该错误。
它会继续执行死刑。如何让虚拟机执行下一条指令?最简单的解决方案是用Continue语句结束每个不返回的CASE块。真正的解决方案,艰难的,要稍微复杂一点。
要查看简单的Continue语句的问题,我们需要了解Switch编译成什么。操作码是介于0和255之间的整数。因为范围很密集,所以编译器可以创建一个跳转表来存储CASE块的地址,并使用操作码作为该表的索引。现代的编译器确实做到了这一点,因此用例的分派被有效地实现为单个间接跳转。这是实现交换机的一种有效方式。但是,将Switch放在循环中并添加Continue语句会产生两个效率低下的问题:
CASE块末尾的Continue语句添加了另一个跳转。因此,要执行操作码,VM必须跳转两次:跳到循环的开头,然后跳到下一个CASE块。
由于所有操作码都是由一次跳转调度的,因此CPU预测下一个操作码的机会很小。它最多只能选择最后一个操作码,或者可能是最频繁的操作码。
优化的想法是在每个不返回的CASE块的末尾放置一个单独的分派跳转。首先,它省去了一次跳跃。其次,CPU可以预测下一个操作码作为当前操作码之后最可能的操作码。
可以启用或禁用优化。这取决于编译器是否支持名为值的GCC C扩展标签。启用优化的效果是,某些宏(如Target()、Dispatch()和FAST_DISPATCH())以不同的方式展开。这些宏在求值循环的整个代码中广泛使用。每个case表达式都有一个表单目标(Op),其中op是表示操作码的整型文字的宏。每个不返回的案例块都以DISPATCH()或FAST_DISPATCH()宏结尾。让我们先来看看在禁用优化时这些宏将扩展到哪些地方:
对于(;;){//...。检查字节码执行是否必须挂起FAST_NEXT_OPCODE://NEXTOPARG()MACRO_Py_CODEUNIT WORD=*NEXT_INSTR;OPCODE=_Py_OPCODE(Word);oparg=_Py_OPARG(Word);Next_instr++;Switch(OPCODE){//目标(NOP)扩展为NOP案例NOP:{GOTO FAST_NEXT_OPCODE;//FAST_DISPATCH()。大小写BINARY_MULPLY:{//...。二进制乘法代码继续;//Dispatch()宏}//...}//...。错误处理}。
当不希望在执行某些操作码后挂起求值循环时,FAST_DISPATCH()宏用于该操作码。否则,实现就非常简单了。
如果编译器支持将";标签作为值扩展,我们可以在标签上使用一元&;运算符来获取其地址。它的值类型为void*,因此我们可以将其存储在指针中:
此扩展允许在C中将跳转表实现为标签指针数组。这就是CPython所做的:
静态无效*操作码_目标[256]={&;&;_UNKNOWN_OPCODE,&;&;TARGET_POP_TOP,&;&;TARGET_ROT_Two,&;&;TARGET_ROT_Three,&;TARGET_DUP_TOP_TABLE,&;&;TARGET_ROT_Four,&;&;_UNKNOWN_OPCODE,&;&;_UNKNOWN_OPCODE、&;&;TARGET_NOP、&;&;TARGET_UNARY_PERIAL、&;&TARGET_UNARY_NECTIVE、&;&;TARGET_UNARY_NOT、//...。多了几个人,{##**$$}。
对于(;;){//...。检查是否必须挂起字节码执行FAST_NEXT_OPCODE://NEXTOPARG()MACRO_Py_CODEUNIT WORD=*NEXT_INSTR;OPCODE=_Py_OPCODE(Word);oparg=_Py_OPARG(Word);Next_instr++;Switch(OPCODE){//TARGET(NOP)Expand to NOP:TARGET_NOP://TARGET_NOP是标签案例NOP:TARGET_NOP://TARGET_NOP。F_LASTI=INSTR_OFFSET();NEXTOPARG();GOTO*OPCODE_TARGETS[操作码];}//...。CASE BINARY_MULPLY:TARGET_BINARY_MULPLY:{//...。二进制乘法//Dispatch()宏的代码,如果(!_Py_ATOM_LOAD_RELAX(Val_Breaker)){Fast_Dispatch();}Continue;}//...}//...。错误处理}。
该扩展由GCC和Clang编译器支持。因此,当您运行python时,您可能已经启用了优化。当然,问题是它如何影响表现。在这里,我将依靠源代码中的评论:
在撰写本文时,线程代码版本比正常的Switch版本快15%-20%,具体取决于编译器和CPU架构。
这一节应该让我们了解CPython VM如何从一条指令转到另一条指令,以及它在这两条指令之间可能做些什么。下一个合乎逻辑的步骤是更深入地研究VM如何执行单个指令。CPython3.9有119个不同的操作码。当然,我们不会在这篇文章中研究每个操作码的实现。相反,我们将重点介绍VM用来执行它们的一般原则。
幸运的是,关于CPython VM最重要也是非常简单的事实是它是基于堆栈的。这意味着要进行计算,VM会从堆栈中弹出(或偷看)值,对它们执行计算,然后将结果推回。下面是一些例子:
GET_ITER操作码从堆栈中弹出值,对其调用ITER()并推送结果。
BINARY_ADD操作码从堆栈中弹出值,从顶部查看另一个值,将第一个值与第二个值相加,然后用结果替换顶部的值。
值堆栈驻留在Frame对象中。它是作为名为f_localplus的数组的一部分实现的。数组被分成几个部分来存储不同的内容,但只有最后一部分用于值堆栈。此部分的开始是堆栈的底部。Frame对象的f_valuestack字段指向它。为了定位堆栈顶部,CPython保留STACK_POINTER局部变量,该变量指向堆栈顶部之后的下一个槽。F_localplus数组的元素是指向Python对象的指针,而指向Python对象的指针是CPythonVM实际使用的对象。
并非所有由VM执行的计算都是成功的。假设我们尝试将一个数字添加到一个类似1+';41';的字符串中。编译器生成BINARY_ADD操作码来添加两个对象。当VM执行此操作码时,它会调用PyNumber_add()来计算结果:
案例目标(BINARY_ADD):{PyObject*right=POP();PyObject*Left=top();PyObject*sum;//...。字符串加法SUM=PyNumber_ADD(左、右);Py_DECREF(左);Py_DECREF(右);SET_TOP(SUM);IF(SUM==NULL)转到错误;Dispatch();}。
现在对我们来说重要的不是如何实现PyNumber_add(),而是调用它会导致错误。该错误意味着两件事:
PyNumber_add()将当前异常设置为TypeError异常。这涉及到设置tstate->;curexc_type、tstate->;curexc_value和tstate->;curexc_traceback。
NULL是错误的指示符。虚拟机看到它并转到评估循环末尾的错误标签。接下来会发生什么取决于我们是否设置了任何异常处理程序。如果没有,则VM到达Break语句,求值函数返回NULL,并在线程状态上设置异常。CPython打印异常的详细信息并退出。我们得到了预期的结果:
$python-c";1+';42';";回溯(最近一次调用):文件";<;字符串&>#34;,第1行,<;模块&>类型错误:+:';int';和';str';不支持的操作数类型。
但是假设我们将相同的代码放在try-Finally语句的try子句中。在这种情况下,Finally子句中的代码也会被执行:
$python-q>;>;尝试:...1+#39;41&39;...。最后:……。打印(';嘿!';)...。嘿!回溯(最新呼叫。
.