4. 执行模型

4.1. 程序的结构

一个 Python 程序由多个代码块构成。 代码块 是一段 Python 程序文本,它作为一个单元来执行。 下列的都是代码块:一个模块,一个函数体,以及一个类的定义。 以交互方式输入的每条命令都是一个代码块。 一个脚本文件(作为标准输入给予解释器或是作为命令行参数给予解释器的文件)是一个代码块。 一个脚本命令(由解释器命令行上的 -c 选项指定的命令)是一个代码块。 使用 -m 参数从命令行作为顶层脚本(即模块 __main__)运行的模块也是一个代码块。 传递给内置函数 eval()exec() 的字符串参数也是代码块。

代码块在 执行帧 中被执行。 帧包含了一些管理信息(用于调试)并且决定了在该代码块执行结束后,执行将从何处以及如何继续。

4.2. 命名与绑定

4.2.1. 名称的绑定

名称 用于指代对象。 名称是通过名称绑定操作来引入的。

下列构造会绑定名称:

  • 函数的正式形参,

  • 类定义,

  • 函数定义,

  • 赋值表达式,

  • 在赋值语句中作为标识符出现的 目标

    • for 循环的头部,

    • with 语句、except 子句、except* 子句中,或者在结构化模式匹配的 as-pattern 中跟在 as 之后的名称,

    • 在结构化模式匹配中的捕获模式

  • import 语句。

  • type 语句。

  • 类型形参列表.

import 语句的 from ... import * 形式会绑定被导入模块中定义的所有名称,但以一个下划线开头的名称除外。 这种形式只能在模块层级上使用。

出现在 del 语句中的目标也被视为为此目的而绑定的(虽然其实际的语义是解绑名称)。

每个赋值或导入语句都发生于由一个类或函数定义所定义的代码块中,或是在模块层级(顶层代码块)。

如果一个名称在一个代码块中被绑定,它就是该代码块的一个局部变量,除非被声明为 nonlocalglobal。 如果一个名称在模块层级被绑定,它就是个全局变量。 (模块代码块的变量既是局部的也是全局的。) 如果一个变量在一个代码块中被使用但不是在那里定义的,它就是个 自由变量

程序文本中出现的每个名称都是指由以下名称解析规则所建立的对该名称的 绑定

4.2.2. 名称的解析

作用域 定义了一个名称在一个代码块中的可见性。 如果一个局部变量在一个代码块中被定义,其作用域就包括该代码块。 如果定义发生于一个函数代码块中,其作用域会延伸到该函数所包含的任何代码块,除非有某个被包含代码块为该名称引入了不同的绑定。

当一个名称在代码块中被使用时,它是通过最近的封闭作用域来解析的。 一个代码块可见的所有这种作用域的集合被称为该代码块的 环境

当一个名称完全找不到时,将会引发 NameError 异常。 如果当前作用域是函数作用域,且该名称指向一个局部变量,而该变量在名称被使用之处还未被绑定到值,则会引发 UnboundLocalError 异常。 UnboundLocalErrorNameError 的一个子类。

如果一个名称绑定操作发生在一个代码块中的任何位置,则该代码块中对该名称的所有使用都会被处理为对当前代码块的引用。 当一个名称在被绑定之前就在代码块中被使用时,这可能导致错误。 这个规则很微妙。 Python 缺少声明,并允许名称绑定操作发生在一个代码块的任何位置。 一个代码块的局部变量可通过扫描整个代码块文本中的名称绑定操作来确定。 示例请参阅 关于 UnboundLocalError 的常见问题条目

如果 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 语句会引入 *注解作用域*,其行为在大多数情况下类似于函数作用域,但有一些例外情况将在下面讨论。

注解作用域用于以下情况:

注解作用域与函数作用域的不同之处如下:

  • 注解作用域可以访问其外围的类命名空间。如果一个注解作用域直接位于一个类作用域之内,或者位于另一个直接位于类作用域之内的注解作用域之内,那么该注解作用域中的代码可以使用类作用域中定义的名称,就好像它直接在类主体中执行一样。这与在类中定义的常规函数不同,后者无法访问类作用域中定义的名称。

  • 注解作用域中的表达式不能包含 yieldyield fromawait:= 表达式。(这些表达式允许在注解作用域内包含的其他作用域中使用。)

  • 在注解作用域中定义的名称不能在内层作用域中通过 nonlocal 语句重新绑定。这只包括类型形参,因为在注解作用域中可能出现的其他语法元素都不能引入新名称。

  • 虽然注解作用域有一个内部名称,但该名称不会反映在作用域内定义的对象的 限定名称 中。相反,这些对象的 __qualname__ 属性就如同该对象是在外围作用域中定义的一样。

在 3.12 版本加入: 注解作用域是 Python 3.12 中作为 PEP 695 的一部分引入的。

在 3.13 版本发生变更: 根据 PEP 696 的引入,注解作用域也用于类型形参的默认值。

在 3.14 版本发生变更: 根据 PEP 649PEP 749 的规定,注解作用域现在也用于注解。

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]

延迟求值的值在 注解作用域 中进行求值,这意味着出现在延迟求值的值中的名称,其查找方式就如同它们在直接外围作用域中使用一样。

3.12 新版功能.

4.2.5. 内建函数与受限的执行

CPython 实现细节: 用户不应触碰 __builtins__,它完全是实现细节。希望重写内置命名空间中的值的用户应该 import builtins 模块并相应地修改其属性。

与代码块执行相关联的内置命名空间实际上是通过在其全局命名空间中查找名称 __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 版本之间发生变化而恕不另行通知,依赖其运行于多个解释器版本的代码不应依赖于这些消息。

另请参阅 try 语句 一节中对 try 语句的描述,以及 raise 语句 一节中对 raise 语句的描述。

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 模块创建的线程。

每个线程状态,在其生命周期内,总是与一个解释器和一个宿主线程绑定。它只会在那个线程和那个解释器中使用。

多个线程状态可能与同一个宿主线程绑定,无论是用于不同的解释器还是同一个解释器。然而,对于任何给定的宿主线程,在同一时间,线程只能使用其中一个与之绑定的线程状态。

线程状态是相互隔离和独立的,不共享任何数据,除了可能共享一个解释器以及属于该解释器的对象或其他资源。

一旦程序运行起来,就可以使用 threading 模块创建新的 Python 线程(在支持线程的平台和 Python 实现上)。可以使用 ossubprocessmultiprocessing 模块创建额外的进程。可以使用 interpreters 模块创建和使用解释器。协程(异步)可以在每个解释器中使用 asyncio 运行,通常只在一个线程中(通常是主线程)。

脚注