运行机制
 2024-01-01
 6357字
 Python

在序章中已经提到过,Python是先将源代码编译成代码对象,然后虚拟机执行代码对象。代码对象类似可执行文件,而虚拟机则模拟了CPU和内存,用于执行“可执行文件”。

进一步地,CPU可以分为控制器运算器。对于一个模拟的CPU,显然不需要事无巨细的模拟全部细节。对于控制器,Python虚拟机只模拟了程序计数器(指向将要执行的那条指令),至于运算过程需要的各种寄存器,则直接省略,中间结果放在了模拟的内存中。在CPython中,由帧对象负责模拟程序计数器和内存,而运算器则是由C语言编写的各种函数。

物理机中之所以在内存之上增加了缓存和寄存器两级存储,是因为内存和CPU的运行速度差异过大,需要更快的缓存和寄存器来弥补。在模拟的CPU中,所有的模拟操作都是在内存中进行的,没有办法模拟出更快的“缓存”和“寄存器”,因此也就没有必要进行模拟,这也是Python运行速度慢的原因之一。

作用域对每个语言都是非常重要的概念,它定义了一个变量的作用范围,或者说规定了哪些代码可以访问到这个变量。虚拟机的许多操作都与变量作用域有关,因此在剖析Python运行机制之前,需要先理解作用域及其对应的名字空间

作用域与名字空间

作用域

作用域是一个静态的语法概念,可以直接从代码中看出。

在大多数情况下,程序中都不止一个作用域,而是多个作用域之间并列或嵌套。并列的作用域之间无法互相访问变量,但是嵌套的作用域中,子作用域可以访问父作用域。举例如下:

# 作用域A
a = 1

def f1():
    # 作用域B,如果函数有形参,形参也在作用域B中
    b = 1
    print(a, b)
    # 作用域B结束

def f2():
    # 作用域C,如果函数有形参,形参也在作用域C中
    a = 2
    print(a)
    try:
    	print(b)
    except Exception as e:
        print(type(e), e)
    # 作用域C结束
f1()
f2()
print(a)
# 作用域A结束

# 运行结果:
# 1 1
# 2
# <class 'NameError'> name 'b' is not defined
# 1

在上面的代码中,作用域B和C并列,且都嵌套在A中。所以在作用域B中访问变量a的时候可以正常访问到;在作用域C中访问变量b时,由于B和C是并列,所以无法正常访问,引发异常NameError

此外,在作用域C中访问a时,由于作用域C中重新定义了一个变量a,会优先访问本作用域的变量a,因此输出结果为2。此时作用域C中的a2,作用域A中的a1,因此最后在作用域A中输出a的时候,结果仍为1

名字空间

在程序运行时,需要一定的实体(指内存中的数据)将作用域具象化,我们一般把这种实体叫做名字空间。名字空间是一种关联型数据结构,负责把名字和名字对应的对象关联起来,dict就是一种非常适合作为名字空间的容器。

在上述例子中,作用域A对应全局名字空间,作用域B和C各自应一个局部名字空间,可以分别用globals()locals()查看。如下:

# 作用域A
a = 1

def f1():
    # 作用域B,如果函数有形参,形参也在作用域B中
    b = 1
    print('namespace B:', locals())
    # 作用域B结束

def f2(para):
    # 作用域C,如果函数有形参,形参也在作用域C中
    a = 2
    print('namespace C:', locals())
    # 作用域C结束
f1()
f2('instance para')
print('namespace A:', globals())
# 作用域A结束

# 运行结果:
# namespace B: {'b': 1}
# namespace C: {'para': 'instance para', 'a': 2}
# namespace A: {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x00000123F4CF1CD0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'd:\\Projects\\PythonManual\\04-execucation_mechanism\\namespace.py', '__cached__': None, 'a': 1, 'f1': <function f1 at 0x00000123F4D0C900>, 'f2': <function f2 at 0x00000123F4D463E0>}

两个局部名字空间的结果符合预期,但是全局名字空间除了af1f2之外还有其它内容,包括经常在if __name__ == '__main__'中用到的__name__

一个局部名字空间对应一个函数内的作用域。

全局名字空间

Python中的全局名字空间并不是真正的在整个虚拟机内全局通用,更准确的说法应该是模块属性空间。每个模块都有一个全局名字空间(本手册中依然延续这种叫法),这个名字空间对于模块内的代码是“全局通用”的。

每个Python文件都会被虚拟机构造成一个模块。一般而言,模块的名字,也就是其全局名字空间中的__name__,格式为<parent_module>.<file_name_without_suffix>。但是对于虚拟机第一个执行的模块,其名字总为'__main__'。以python <source_file>形式执行Python文件时,<source_file>就会被构造成__main__模块。

sys.modules保存了虚拟机中目前导入的所有模块,我们可以用以下代码验证全局名字空间就是模块的属性空间

>>> __name__
'__main__'
>>> import sys
>>> globals() is sys.modules['__main__'].__dict__
True

__name__的值表示当前的全局名字空间是相对于__main__模块而言,那么globas()得到的就是__main__的全局名字空间,它和模块属性空间是同一个字典对象。

也可以把以下代码保存为a.py,观察单独执行和在其它文件中导入的不同执行结果。

# a.py
import sys

print('__name__:', __name__)
print(globals() is sys.modules['__main__'].__dict__)

if 'a' in sys.modules:
    print(globals() is sys.modules['a'].__dict__)
else:
    print("'a' has not been imported yet.")

除了__name__之外,全局名字空间中还有一个常用的__file__变量,它的值是当前模块对应的Python文件的绝对路径。代码中涉及相对路径时,最好从模块的__file__来确定,因为在代码运行过程中,相对路径是相对**Python虚拟机的CWD(Current Work Directory)**而言,而虚拟机的CWD是命令行程序执行python <source_file>这条语句时所在的目录,这个目录和模块所在的目录很可能不是同一个。

交互式模式下也有__main__模块,只不过它不是由用户编写的源文件构造的,也没有__file__变量。

内建名字空间

除了全局名字空间之外,还有一个重要的名字空间叫做内建名字空间printlistint等内建名字就藏身于此。内建名字空间本质上是模块builtins的属性空间,虚拟机会自动导入这个模块,所以我们可以直接使用这些内建名字。

在上面的例子中,全局名字空间内的名字'__builtins__'所绑定的就是builtins这个模块对象。

print(type(__builtins__.print))
print(type(__builtins__.list))
print(type(__builtins__.int))
print(str is __builtins__.str)

# 运行结果:
# <class 'builtin_function_or_method'>
# <class 'type'>
# <class 'type'>
# True

代码运行结果也比较好理解,print是一个函数实例对象,所以它的类型是一个特殊的函数类型对象builtin_function_or_method,而listint都是类型对象,所以它们的类型都是元类type

注意:内建名字空间不会和普通的名字空间发生嵌套,它的优先级最低,当虚拟机按照局部名字空间闭包名字空间全局名字空间的顺序查找均查找不到后,才会在内建名字空间查找。

这里出现了一个新的名词,闭包。闭包是Python中非常重要的概念,它与Python非常重要的特性——装饰器息息相关。

闭包名字空间

所谓闭包(closure),就是把函数外层的局部名字空间内的变量打包带走,可以让函数脱离外层的局部名字空间之后仍然使用这些变量。

举例如下:

def f(set_a):
    a = set_a
    def g():
        print(a)
    return g

gg_1 = f(1)
gg_2 = f(2)
print(type(gg_1), type(gg_2))
print(gg_1 is gg_2)
gg_1()
gg_2()

f(100)()

# 运行结果:
# <class 'function'> <class 'function'>
# False
# 1
# 2
# 100

函数f的执行结果是返回函数对象g,因此gg_1gg_2都是函数对象,从is比较结果上看,它们是两个不同的对象。在执行gg_1gg_2的时候它们打印了不同的结果,说明它们打包带走的是不同的变量值。

这就是装饰器实现的思路:把被装饰函数当作实参传入set_a,返回的函数g会携带被装饰函数,我们在函数g内先执行一些“装饰行为”,再执行被装饰函数,就对被装饰函数实现了“装饰”。

下面的代码可以实现一个计时装饰器:

import time

# func是被装饰函数,会被proxy函数作为闭包变量携带
def perf(func):
    def proxy(*args, **kwargs):
        start = time.perf_counter_ns()
        res = func(*args, **kwargs)
        end = time.perf_counter_ns()
        print(f'time cost: {(end - start) // 10 ** 6}ms.')
        return res
    return proxy


def loop(n):
    cnt = 0
    for i in range(1, n + 1):
        # 每次循环i都会被重新赋值,所以可以在循环内直接修改i,不会对循环流程产生影响
        while i != 1:
            cnt += 1
            if i % 2:
                i = 3 * i + 1
            else:
                i //= 2
    return cnt

# 新的loop其实是perf中的proxy函数对象,旧的loop被其作为闭包变量携带
loop = perf(loop)

print(loop(3 * 10 ** 4))

# 运行结果:
# time cost: 181ms.
# 2864311

这里为了直观展示装饰器的原理,没有使用@语法糖。任意一个函数都可以使用f = perf(f)的形式得到一个带计时装饰器的版本,@语法糖不过是自动帮我们完成了这个过程。

关于闭包名字空间的具体实现和其它装饰器用法可以参考函数机制一章。

代码对象与字节码

代码对象是深入学习Python运行机制的核心之一。获取一个代码对象的方式很简单,可以用内置的compile()函数编译得到,有了一个代码对象之后我们就可以对它进行各种操作来研究它。但是这种方法不够直接,代码对象是一种内建对象,更直接的方法是研究它对象的结构体类型。和PyTypeObject一样,在Include/code.h中只有typedef struct PyCodeObject PyCodeObject,真正了结构体类型定义在Include/cpython/code.h中。关键字段如下:

struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
};

可以看到很多字段是和函数运行相关的,比如几个argcount字段,就是记录函数的参数个数;vars相关的是函数的局部变量和闭包变量。因为执行函数就是执行函数内的代码块,这个代码块会被编译为一个代码对象,所以要在代码对象中创建相应字段以记录代码块执行时需要的各种参数,具体细节见函数机制,这里先讨论不在函数内的裸代码块。

需要关注以下几个字段:

  • co_stacksize:执行这个代码对象需要的栈空间,虚拟机依据此字段为帧对象分配内存。
  • co_codebytes对象,字节码。
  • co_conststuple对象,执行过程中用到的常量。注释是 list,其实是tuple
  • co_namestuple[str],执行过程中用到的名字。同上。

代码对象的字段大都可以在Python层面获取到,且属性名和C中的一致,例如<code_obj>.co_code就可以获取字节码。

下面开始研究字节码。以下面的代码为例(compile函数的用法参见内建对象>函数>编译运行>compile):

>>> code = compile('pi = float("3.14")', '', 'exec')
>>> type(code)
<class 'code'>
>>> type(code.co_code)
<class 'bytes'>
>>> code.co_code
b'e\x00d\x00\x83\x01Z\x01d\x01S\x00'

好吧,字节码果然是纯纯的二进制,不太像是人类看的东西。所以我们要借助dis模块的dis()函数来反编译一下:

>>> from dis import dis
>>> dis(code.co_code)
          0 LOAD_NAME                0 (0)
          2 LOAD_CONST               0 (0)
          4 CALL_FUNCTION            1
          6 STORE_NAME               1 (1)
          8 LOAD_CONST               1 (1)
         10 RETURN_VALUE

跟汇编语言不能说类似,只能说一模一样。第一列是该条字节码在这个bytes对象中的字节序号,每条字节码占两个字节,第一个字节是操作码,第二个字节是操作数。反编译之后的字节码虽然勉强知道意思,但是代码对象除了字节码之外还有常量表和名字表信息,如果直接反编译代码对象,会得到更清晰的结果:

>>> dis(code)
  1           0 LOAD_NAME                0 (float)
              2 LOAD_CONST               0 ('3.14')
              4 CALL_FUNCTION            1
              6 STORE_NAME               1 (pi)
              8 LOAD_CONST               1 (None)
             10 RETURN_VALUE

最前面多了一个1,表示这是第一行Python语句编译的结果。可以打印出常量表和名字表进行对照:

>>> code.co_consts
('3.14', None)
>>> code.co_names
('float', 'pi')

帧对象

我们先抛开物理计算机的体系结构,把我们想成Python虚拟机,现在要执行一个代码对象,除了代码对象本身提供的信息我们还需要什么呢?全局名字空间和内建名字空间肯定是需要的,代码对象中并不含有这些信息;需要记录字节码执行到了哪一条;还需要一个临时空间储存计算过程中产生的中间变量。

Python用帧对象记录这些信息。每次发起函数调用或执行新的模块内的代码时,虚拟机都会创建一个新的帧对象用来执行对应的代码对象

函数调用大多数发生在代码执行的过程中:

hello.py
a = 1
print(a)
a = 2

假设执行这段代码(其实执行的是编译后的代码对象,这里不关注这个细节,因此用源代码说明原理)的帧对象是frame_1,在执行完a = 1之后发生了函数调用,虚拟机会创建一个新的帧对象frame_2用于执行print函数。在执行完print函数之后,还需要回到frame_1继续执行a = 2

所以需要在帧对象中记录被打断的那个帧对象,以便在当前帧对象执行完之后回到被打断的帧对象上继续执行。

有了这些铺垫,帧对象的结构体定义也就不难理解:

//Include/pyframe.h
typedef struct _frame PyFrameObject;

//Include/cpython/frameobject.h
struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */
    PyObject *f_trace;          /* Trace function */
    int f_stackdepth;           /* Depth of value stack */
    char f_trace_lines;         /* Emit per-line trace events? */
    char f_trace_opcodes;       /* Emit per-opcode trace events? */

    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    int f_lineno;               /* Current line number. Only valid if non-zero */
    int f_iblock;               /* index in f_blockstack */
    PyFrameState f_state;       /* What state the frame is in */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
};

其中f_back指向被打断的帧对象;f_code是这个帧对象要执行的代码对象;f_builtinsf_globals分别指向内建名字空间和全局名字空间;f_lasti记录将要执行的下一条字节码;f_localsplus是临时变量存储栈。

f_localsplus是一个指针数组,使用了和int对象相同的技巧,只不过int中数组内的元素是unsigned int,这里数组内的元素是PyObject *。注释表明这个数组还存储了局部变量,我们留在函数机制中研究,这里暂且认为它只存储临时变量。

sys._getframe()可以获取调用_gerframe函数的前一刻正在运行的帧对象(因为调用函数会产生一个新的帧对象)。我们可以用以下代码验证帧对象之间的关系:

a.py
import sys

main_frame = sys._getframe()
frame_exec_f = None
frame_exec_g = None

def g():
    global frame_exec_g
    frame_exec_g = sys._getframe()

def f():
    global frame_exec_f
    frame_exec_f = sys._getframe()
    g()

f()

print(frame_exec_g.f_back is frame_exec_f)
print(frame_exec_f.f_back is main_frame)

# 运行结果:
# True
# True

main_framef()时被打断,产生一个新的帧对象frame_exec_f用来执行函数f内的代码块,然后又被g()打断,产生一个新的帧对象frame_exec_g用来执行函数g内的代码块。所以它们之间f_back的指向关系就是:main_frameframe_exec_fframe_exec_g。这个调用链条会随着运行过程动态地变长或缩短,当代码全部执行完毕时,整个链条只剩下一个main_frame,之后它也会被析构回收,整个执行过程也就结束了。

正常情况下的函数嵌套调用不会超过百层这个量级,但是如果递归函数没有设置正确出口,可能会无限递归调用,调用链也会无限长,这显然是要避免的。CPython设置了一个递归深度限制,超过这个限制的递归会被强制结束。3.10中这个值是1000,可以用sys.getrecursionlimit()获取,也可以用sys.setrecursionlimit函数重新设置深度限制。但是如果一个问题用1000层递归还没解决,那么说明这个问题大概率是不适合用递归解决的,这个时候首先应该考虑的是更换其它方法而不是设置更深的递归限制。

执行

了解作用域与名字空间和两个基本对象这些必备条件之后,就可以研究虚拟机执行代码对象的过程了。我们把pi = float('3.14')这句代码写到code_run.py,然后以python code_run.py为例探究Python的执行过程。

第一个帧对象

当我们用python code_run.py执行这个Python文件时,就是以code_run.py为参数运行Python解释器,解释器发现这是一个文件名,于是读取文件,把文件内容编译成一个代码对象,然后送入虚拟机执行这个代码对象。

虚拟机在执行这个代码对象之前会做一些准备工作:

  1. 加载必要的依赖库,如sysbuiltins
  2. 创建__main__模块,把__name____file____builtins__等字段填充到它的属性空间。其中__file__就是code_run.py的绝对路径,这样__main__模块就和code_run.py关联了起来。随着代码对象的执行,对应内容填充会被填充到模块的属性空间中。这里要理清源文件代码对象模块之间的关系。
  3. 创建第一个帧对象,其中f_back是空指针,f_code指向该代码对象,f_globals指向__main__的属性空间,f_builtins指向builtins模块的属性空间。

此时虚拟机的状态如图: