4. 执行模型¶
4.1. 程序的结构¶
Python 程序是由代码块构成的。 代码块 是被作为一个单元来执行的一段 Python 程序文本。 以下几个都属于代码块:模块、函数体和类定义。 交互式输入的每条命令都是代码块。 一个脚本文件(作为标准输入发送给解释器或是作为命令行参数发送给解释器的文件)也是代码块。 一条脚本命令(通过 -c 选项在解释器命令行中指定的命令)也是代码块。 通过在命令行中使用 -m 参数作为最高层级脚本(即 __main__ 模块)运行的模块也是代码块。 传递给内置函数 eval() 和 exec() 的字符串参数也是代码块。
代码块在 执行帧 中被执行。 一个帧会包含某些管理信息(用于调试)并决定代码块执行完成后应前往何处以及如何继续执行。
4.2. 命名与绑定¶
4.2.1. 名称的绑定¶
名称 用于指代对象。 名称是通过名称绑定操作来引入的。
下面的结构将名字绑定:
形式为 from ... import * 的 import 语句绑定所有在导入的模块中定义的名字,除了那些以下划线开头的名字。这种形式只能在模块级别上使用。
del 语句的目标也被视作一种绑定(虽然其实际语义为解除名称绑定)。
每条赋值或导入语句均发生于类或函数内部定义的代码块中,或是发生于模块层级(即最高层级的代码块)。
如果某个名称绑定在一个代码块中,则它就是该代码块的局部变量,除非声明为 nonlocal 或 global。 如果某个名称绑定在模块层级,则它就是全局变量。 (模块代码块的变量既是局部变量又是全局变量。) 如果某个变量在一个代码块中被使用但不是在其中定义的,则它是 free variable。
每个在程序文本中出现的名称是指由以下名称解析规则所建立的对该名称的 绑定。
4.2.2. 名称的解析¶
作用域 定义了一个代码块中名称的可见性。 如果代码块中定义了一个局部变量,则其作用域包含该代码块。 如果定义发生于函数代码块中,则其作用域会扩展到该函数所包含的任何代码块,除非有某个被包含代码块引入了对该名称的不同绑定。
当一个名称在代码块中被使用时,会由包含它的最近作用域来解析。 对一个代码块可见的所有这种作用域的集合称为该代码块的 环境。
当一个名称完全找不到时,将会引发 NameError 异常。 如果当前作用域为函数作用域,且该名称指向一个局部变量,而此变量在该名称被使用的时候尚未绑定到特定值,将会引发 UnboundLocalError 异常。 UnboundLocalError 为 NameError 的一个子类。
如果一个代码块内的任何位置发生名称绑定操作,则代码块内所有对该名称的使用都会被视为对当前代码块的引用。 当一个名称在其被绑定前就在代码块内被使用时将会导致错误。 这个规则是很微妙的。 Python 缺少声明语法并且允许名称绑定操作发生于代码块内的任何位置。 一个代码块的局部变量可通过在整个代码块文本中扫描名称绑定操作来确定。 请参阅 UnboundLocalError 的 FAQ 条目 来获取示例。
如果 global 语句出现在一个代码块中,则所有对该语句所指定名称的使用都是在最高层级命名空间内对该名称绑定的引用。 名称在最高层级命名空间内的解析是通过搜索全局命名空间,也就是包含该代码块的模块的命名空间,以及内置命名空间即 builtins 模块的命名空间。 全局命名空间会先被搜索。 如果未在其中找到相应名称,将再搜索内置命名空间。 如果未在内置命名空间中找到相应名称,将在全局命名空间中创建新变量。 global 语句必须位于所有对其所列名称的使用之前。
global 语句与同一代码块中名称绑定具有相同的作用域。 如果一个自由变量的最近包含作用域中有一条 global 语句,则该自由变量也会被当作是全局变量。
nonlocal 语句会使得相应的名称指向之前在最近包含函数作用域中绑定的变量。 如果指定的名称不存在于任何包含函数作用域中则将在编译时引发 SyntaxError。 类型形参 不能使用 nonlocal 语句来重新绑定。
模块的作用域会在模块第一次被导入时自动创建。 一个脚本的主模块总是被命名为 __main__。
类定义代码块以及传给 exec() 和 eval() 的参数是名称解析的上下文中的特殊情况。 类定义是可能使用并定义名称的可执行语句。 这些引用遵循正常的名称解析规则,例外之处在于未绑定的局部变量会在全局命名空间中查找。 类定义的命名空间会成为该类的属性字典。 在类代码块中定义的名称的作用域会被限制在类代码块中;它不会扩展到方法的代码块中。 这包括推导式和生成器表达式,但不包括 标注作用域,因为它可以访问所包含的类作用域。 这意味着以下代码将会失败:
class A:
a = 42
b = list(a + i for i in range(10))
但是,下面的代码将会成功:
class A:
type Alias = Nested
class Nested: pass
print(A.Alias.__value__) # <type 'A.Nested'>
4.2.3. 标注作用域¶
标注, 类型形参列表 和 type 语句引入了 标注作用域,其行为基本类似于函数作用域,但具有下文讨论的一些差异。
标注作用域将在下列情况中使用:
函数标注。
变量标注。
针对 泛型类型别名 的类型形参列表。
针对 泛型函数 的类型形参列表。 泛型函数的标注会在标注作用域内执行,但其默认值和装饰器则不会。
针对 泛型类 的类型形参列表。 泛型类的基类和关键字参数会在标注作用域内执行,但其装饰器则不会。
针对类型形参的绑定、约束和默认值 (惰性求值)。
类型别名的值 (惰性求值)。
标注作用域在以下几个方面不同于函数作用域:
标注作用域能够访问其所包含的类命名空间。 如果某个标注作用域紧接在一个类作用域之内,或是位于紧接一个类作用域的另一个标注作用域之内,则该标注作用域中的代码将能使用在该类作用域中定义的名称,就像它是在该类内部直接执行一样。 这不同于在类中定义的常规函数,后者无法访问在类作用域中定义的名称。
标注作用域中的表达式不能包含
yield,yield from,await或:=表达式。 (这些表达式在包含于标注作用域之内的其他作用域中则是允许的。)在标注作用域中定义的名称不能在内部作用域中通过
nonlocal语句来重新绑定。 这只包括类型形参,因为没有其他可以在标注作用域内部出现的语法元素能够引入新的名称。虽然标注作用域具有一个内部名称,但该名称不会反映在作用域内定义的对象的 qualified name 中。 相反,这些对象的
__qualname__就像它们是定义在包含作用域中的对象一样。
Added in version 3.12: 标注作用域是在 Python 3.12 中作为 PEP 695 的一部分引入的。
在 3.13 版本发生变更: 标注作用域也被用于类型形参默认值,这是由 PEP 696 引入的。
4.2.4. 惰性求值¶
大多数标注作用域采用 惰性求值。 这包括标注、通过 type 语句创建的类型别名的值,以及通过 类型形参语法 创建的类型变量的绑定、约束和默认值。 这意味着它们在类型别名或类型变量被创建时、或带有标注的对象被创建时不会被求值。 作为替代行为,它们只有在需要时才会被求值,例如在一个类型别名上的 __value__ 属性被访问的时候。
示例:
>>> type Alias = 1/0
>>> Alias.__value__
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
>>> def func[T: 1/0](): pass
>>> T = func.__type_params__[0]
>>> T.__bound__
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
此处的异常只有在类型别名的 __value__ 属性或类型变量的 __bound__ 属性被访问时才会被引发。
此行为主要适用于当创建类型别名或类型变量时对尚未被定义的类型进行引用。 例如,惰性求值将允许创建相互递归的类型别名:
from typing import Literal
type SimpleExpr = int | Parenthesized
type Parenthesized = tuple[Literal["("], Expr, Literal[")"]]
type Expr = SimpleExpr | tuple[SimpleExpr, Literal["+", "-"], Expr]
被惰性求值的值是在 标记作用域 内进行求值的,这意味着出现在被惰性求值的值内部的名称的查找范围就相当于它们是在紧邻的作用域中被使用。
Added in version 3.12.
4.2.5. 内置命名空间和受限的执行¶
与一个代码块的执行相关联的内置命名空间实际上是通过在其全局命名空间中搜索名称 __builtins__ 来找到的;这应该是一个字典或一个模块(在后一种情况下会使用该模块的字典)。 默认情况下,当在 __main__ 模块中时,__builtins__ 就是内置模块 builtins;当在任何其他模块中时,__builtins__ 则是 builtins 模块自身的字典的一个别名。
4.2.6. 与动态特性的交互¶
自由变量的名称解析发生于运行时而不是编译时。 这意味着以下代码将打印出 42:
i = 10
def f():
print(i)
i = 42
f()
eval() 和 exec() 函数没有对完整环境的访问权限来解析名称。 名称可以在调用者的局部和全局命名空间中被解析。 自由变量的解析不是在最近包含命名空间中,而是在全局命名空间中。 [1] exec() 和 eval() 函数有可选参数用来重载全局和局部命名空间。 如果只指定一个命名空间,则它会同时作用于两者。
4.3. 异常¶
异常是中断代码块的正常控制流程以便处理错误或其他异常条件的一种方式。 异常会在错误被检测到的位置 引发,它可以被当前包围代码块或是任何直接或间接唤起发生错误的代码块的其他代码块所 处理。
Python 解析器会在检测到运行时错误(例如零作为被除数)的时候引发异常。 Python 程序也可以通过 raise 语句显式地引发异常。 异常处理是通过 try ... except 语句来指定的。 该语句的 finally 子句可被用来指定清理代码,它并不处理异常,而是无论之前的代码是否发生异常都会被执行。
Python 的错误处理采用的是“终止”模型:异常处理器可以找出发生了什么问题,并在外层继续执行,但它不能修复错误的根源并重试失败的操作(除非通过从顶层重新进入出错的代码片段)。
当一个异常完全未被处理时,解释器会终止程序的执行,或者返回交互模式的主循环。 无论是哪种情况,它都会打印栈回溯信息,除非是当异常为 SystemExit 的时候。
异常是通过类实例来标识的。 except 子句会依据实例的类来选择:它必须引用实例的类或是其所属的 非虚基类 。 实例可通过处理器被接收,并可携带有关异常条件的附加信息。
备注
异常消息不是 Python API 的组成部分。 其内容可能在 Python 升级到新版本时不经警告地发生改变,不应该被需要在多版本解释器中运行的代码所依赖。
4.4. 运行时组件¶
4.4.1. 通用计算模型¶
Python 的执行模型并非是在真空中运作的。 它存在于一台主机并通过该主机的运行时环境运行,包括其中的操作系统 (OS),如果有具体操作系统的话。 当一个程序运行时,决定它如何在主机上运行的各个概念层大致是这样的:
主机进程 (全局资源)线程 (运行机器码)
每个进程代表一个在主机上运行的程序。 可以将每个进程本身视作其程序的数据部分。 可以将进程的各个线程视为程序的执行部分。 这一区别对于理解 Python 运行时的概念是很重要的。
进程作为数据部分,是程序运行所在的执行上下文。 它主要由主机分配给程序的资源集合组成,包括内存、信号、文件句柄、套接字以及环境变量等。
进程之间是相互隔离与独立的。 (对于主机来说也是如此。) 主机管理进程对其所分配资源的访问,并在进程之间进行协调。
每个线程代表程序的机器码的实际执行,它们相对于分配给程序的进程的资源来运行。 实际执行是如何以及何时发生完全取决于具体主机。
从 Python 的角度来看,程序开始时总是只有一个线程。 但是,程序可能会发展为在多个线程中同时运行。 并非所有主机都支持在每个进程中有多个线程,但多数确实如此。 与进程不同,进程中的线程并非相互隔离和独立的。 具体来说,一个进程中的所有线程都共享进程的所有资源。
线程的基本要点是每一个线程都独立 运行,并与其他线程同时运行。 这种同时可能只是概念上的(“并发”)也可能是物理上的(“并行”)。 无论哪种方式,线程实际上都是以非同步的速率运行的。
备注
所谓非同步的速率意味着进程的任何内存都不保证对在任何给定线程中运行的代码是固定的。 因此多线程的程序必须小心协调对有意共享的资源的访问。 类似地,它们也必须非常小心注意不要访问多线程中的任何 其它 资源;否则同时运行的两个线程可能会意外地干涉彼此对某些共享数据的使用。 这一点对于 Python 程序和 Python 运行时来说都是如此。
这种宽泛、非结构化要求的代价就是对线程所提供的初级并发特性的权衡。 违反所要求的纪律通常就意味着需要处理非确定性的程序错误和数据损坏。
4.4.2. Python 运行时模型¶
相同的概念层级将应用于每个 Python 程序,并带有一些 Python 专属的数据层级:
主机进程 (全局资源)Python 全局运行时 (状态)Python 解释器 (状态)线程 (运行 Python 字节码和 "C-API")Python 线程 状态
在概念层级上:当一个 Python 程序启动时,它会完全符合上图的样子,每层各有一个。 运行时可能发展为包括多个解释器,而每个解释器可能发展为包括多个线程状态。
备注
一个 Python 具体实现不一定需要实现单独甚至实体的层级。 唯一的例外是将单独的层级直接指明或暴露给用户,例如通过 threading 模块的情况。
备注
初始的解释器通常称为“主”解释器。 某些 Python 实现,像是 CPython,会给主解释器分配特殊角色。
类似地,运行时初始化所在的主机线程被称为“主”线程。 它可能不同于进程的初始线程,不过往往是同一个。 在某些情况下“主线程”概念甚至可能更严格地限定为初始线程状态。 Python 运行时可能会赋予主线程一些专属任务,例如处理信号等。
作为一个整体,Python 运行时是由全局运行时状态、解释器和线程状态组成的。 运行时会确保所有状态在其生命期内保持一致,特别是在使用多个主机线程的情况下。
在概念层级上,全局运行时只是多个解释器的集合。 虽然这些解释器基本上是相互隔离彼此独立的,但它们可能会共享某些数据或其他资源。 运行时要负责安全地管理这些资源。 这些资源的实际性质和管理方式取决于具体实现。 最终,全局运行时的对外功能仅限于管理解释器。
作为对比,“解释器”在概念层级上就是我们通常认为是(具备完整功能的)“Python 运行时”。 当在一个主机线程中执行的机器码与 Python 运行时交互时,它将在特定的解释器上下文中对 Python 发起调用。
备注
这里使用的“解释器”一词不同于“字节码解释器”,后者通常运行于线程之中,执行已编译的 Python 代码。
在一个理想世界中,“Python 运行时”应该是指现在我们称为“解释器”的东西。 但是,它至少从 1997 年被引入开始就一直称为“解释器”了 (CPython:a027efa5b)。
每个解释器都完整地包裹了 Python 运行时工作所需的所有非进程全局、非线程专属的状态。 它包括一些基础数据如 sys.modules。 运行时会确保使用同一解释器的多个线程在它们之间安全地共享这些数据。
Python 的具体实现可能会支持在同一进程中同时使用多个解释器。 它们是彼此独立并且相互隔离的。 例如,每个解释器都有自己的 sys.modules。
对于线程专属的运行时状态,每个解释器都具有一组线程状态,由它进行管理,其方式与全局运行时包含一组解释器的方式相同。 它可以根据需要包含任意多个主机线程的线程状态。 它甚至可以包含同一主机线程的多个线程状态,不过这种情况并不常见。
在概念层级上,每个线程状态都拥有解释器在一个主机线程中动作所需的所有线程专属运行时数据。 线程状态包括当前引发的异常和线程的 Python 调用栈。 它还可能包括其他线程专属的资源。
备注
“Python 线程”一词有时是指一个线程状态,但通常它都表示使用 threading 模块创建的线程。
在其生命期内,每个线程状态总是关联到一个特定的解释器和一个特定的主机线程。 它将仅在这个线程的这个解释器中被使用。
多个线程状态可以关联到同一个主机线程,无论它们是属于不同的解释器还是相同的解释器。 不过,对于任意给定的主机线程,同一时刻只有一个与其关联的线程状态可以被该线程使用。
线程状态是彼此隔离并相互独立且不会共享任何数据,除了可能会共享一个解释器以及归属于该解释器的对象或其他资源。
在一个程序运行的时候,可以(在支持线程的平台和 Python 实现上)使用 threading 模块创建新的 Python 线程。 额外的进程可以使用 os, subprocess 和 multiprocessing 模块来创建。 解释器可以通过 interpreters 模块来创建和使用。 (异步)协程可以在每个解释器中使用 asyncio 来运行,通常仅限于单个线程(往往是主线程)之中。
备注