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 執行,通常只在一個執行緒中(通常是主執行緒)。

腳註