---
id: 26
title: "單執行緒全域狀態：EVM 效能瓶頸的設計原點"
slug: 0-12-single-thread-global-state
date: 2026/09/29
summary: 以太坊黃皮書把區塊級狀態轉移寫成巢狀呼叫，求值順序因此屬於規範的一部分。逐筆串列的語意來源、讀寫集衝突為何無法預先判定、確定性與驗證簡單性換來了什麼，以及這個設計原點正在被怎樣的方案正面回應，是接下來的主線。
keywords: 單執行緒EVM,全域狀態,讀寫集衝突,確定性,區塊級存取清單
heroImage: /images/articles/photos/0-12-single-thread-global-state.jpg
---

黃皮書第 2 節把以太坊描述成一台由交易驅動的狀態機。那套形式化裡的符號約定是：σ 表示世界狀態，T 表示一筆交易，Υ 是交易級狀態轉移函式，把一筆交易作用在狀態上；B 表示區塊，Π 是區塊級狀態轉移函式，區塊內的交易按順序記為 T₀、T₁……。基於這套記號，黃皮書給出兩條式子：單筆交易的狀態轉移是 `σ_{t+1} ≡ Υ(σ_t, T)`，區塊級轉移是 `Π(σ, B) ≡ Υ(Υ(σ, T_0), T_1)…`。第二條式子值得多看一會，它把整個區塊的執行寫成函式的巢狀呼叫，第二筆交易的輸入是執行完第一筆之後的狀態。

求值順序因此屬於規範的一部分，客戶端沒有自行選擇的餘地。要追問的是：為什麼只能這樣定義，如果允許兩筆交易同時求值會失去什麼，以及這個選擇如何把 EVM 的吞吐鎖在單執行緒上。這裡只處理這一層，也就是設計原點。

## 順序寫在規範裡，不是留給客戶端的排程自由

黃皮書的寫法把執行層要做的事限定了：給定父狀態與一串交易，反覆套用狀態轉移函式，得到一個狀態。區塊頭的有效性條件之一，是這個狀態經字典樹折疊後得到的根，必須等於區塊頭裡的 stateRoot。一筆交易執行完畢、狀態完全寫回，下一筆才開始讀狀態，這個先後關係屬於定義本身，沒有留給最佳化的空間。

執行層也沒有排序權。交易的順序由共識層產出的交易列表給出，執行層只按給定的順序求值。這個分工讓共識層不必與執行層就「交錯執行後的結果」達成一致，只需就「打包了哪些交易、順序如何」達成一致。任何節點只要換一個順序重放同一個區塊，得到的根就對不上，它的結果直接被判為錯誤。

```python
# 區塊級狀態轉移：第二筆交易的輸入狀態，就是第一筆交易的輸出狀態
state = parent_state
for tx in block.transactions:
    state = apply_transaction(state, tx)      # Υ(σ, T)
state = apply_withdrawals(state, block.withdrawals)  # 提款在全部交易之後執行
assert trie_root(state) == block.header.state_root
```

## 順序的硬約束：nonce、餘額與累計 Gas

在合約程式碼被解譯執行之前，協定層已經要求了順序。黃皮書第 6 節列出的交易初始有效性檢查裡，有一條是交易 nonce 必須等於發送方帳戶的當前 nonce。同一發送方發出的兩筆交易因此存在協定強制的全序，把後者提前，交易直接判定無效，而不是得到一個不同的結果。另一條檢查要求發送方帳戶沒有部署程式碼（EIP-3607），這同樣是在讀取一筆交易執行後的狀態。

餘額與 Gas 也在同一層鎖定順序。每筆交易在執行前都要檢查發送方餘額足以支付預付款，而這份餘額是前一筆交易執行後的結果；區塊的 Gas 上限是區塊級約束，收據裡的累計 Gas 用量由前序交易累加而來（黃皮書在區塊收據部分把第 n 筆的累計值寫成前一筆累計值與本筆用量之和）。後一筆交易能用多少 Gas，取決於前面已經用掉多少。

也就是說，即使完全不考慮合約儲存，「先後的狀態」這個前提在協定規則裡已經成立。合約執行只是在這個已經串好的序列上繼續疊加依賴。

## 衝突從哪裡來：讀寫集相交，而且只能事後知道

判斷兩筆交易能否同時執行，標準做法是比較它們的讀寫集（Read/Write Set）：一筆交易讀取或寫入的狀態位置集合。只要有一方寫入的位置被另一方讀取或寫入，執行順序就會影響結果，這類交易被稱為衝突。

衝突在真實負載裡很常見。一個自動做市商（Automated Market Maker，AMM）池的兌換交易會改寫儲備量與價格累計值，緊隨其後的借貸清算要讀同一個池的價格，兩筆交易的順序直接決定清算是否觸發、由誰承擔損失。這類依賴不必有人刻意製造，只要合約之間共享狀態，它就會出現。

更麻煩的是執行器看不到讀寫集。EVM 允許一筆交易呼叫任意位址、讀寫任意儲存槽，而呼叫目標與槽號都可以在執行時才計算出來：

```solidity
// 呼叫目標與參數在執行時才確定，靜態分析給不出讀寫集
function dispatch(bytes32 poolId, bytes calldata payload) external {
    address pool = pools[poolId];        // 目標來自儲存，可能是任意已註冊合約
    (bool ok, ) = pool.call(payload);    // 觸碰哪些槽取決於 pool 與 payload
    require(ok, "call failed");
}
```

映射型別的槽號由鍵與槽位置拼接後取 keccak256 得到，鍵可以是執行時輸入，槽位置在編譯期也未必固定。於是「這筆交易會碰哪些槽」在交易執行前無法從位元組碼讀出來，只能靠實際執行觀測。EIP-7928 在動機部分把這件事寫得很直白：在事先不知道會存取哪些位址與儲存槽的前提下，交易執行無法平行。

## 單執行緒買到了什麼：確定性與驗證即重放

這套設計的收益是具體的。所有節點按同一順序求值，得到同一個狀態根，驗證者不需要信任一個排程器，也不需要推理交錯執行的各種可能，只需用同樣的規則重放同一個區塊，再比對區塊頭裡的 stateRoot。這條設計之所以能讓多個獨立實作、不同語言、不同硬體的客戶端對同一個值達成一致，靠的就是把不確定性壓縮成「輸入加順序」。

失敗語意也依賴順序。REVERT 與異常回退依靠執行過程中記錄的狀態快照逐層撤銷，回退的粒度是呼叫堆疊與交易；Gas 退款、`cumulativeGasUsed`、收據裡的狀態位，都只有在交易順序確定時才有唯一含義。若允許多筆交易交錯推進，「先執行的部分回退、後執行的部分保留」就需要一套全新的語意來定義。

所以逐筆串列確實是一種取捨，但它換到的東西（全網可驗證的確定性、不需要額外正確性證明的驗證方式）是公鏈的立身之本，用「實作偷懶」來解釋它並不成立。

## 客戶端確實用滿了多核，但都在語意之外

從工程現場看，主流客戶端並沒有浪費機器。以 Reth 為例：交易簽章復原交給執行緒池平行執行；狀態更新的雜湊與狀態根建構交給平行的稀疏字典樹任務，由多個 worker 分擔證明生成與字典樹節點讀取，任務逾時或失敗時回落到串列計算；區塊處理路徑上還會在獨立的執行緒池裡提前執行交易，為後續正式執行預熱快取。

正式執行本身仍是順序的。Reth 的區塊處理流程裡，不攜帶區塊級存取清單的預設路徑按區塊順序做順序 EVM 執行，並把狀態更新以資料流的形式交給狀態根任務；攜帶 BAL 的區塊另有一條平行執行分支，但它依賴 Amsterdam 分叉與 BAL 的存在（即後文討論的區塊級存取清單路徑）。預熱執行只填充快取，結果不會被直接提交。這條界線是設計原點留下的：多核可以加速簽章驗證、雜湊、證明生成與快取填充，卻不能縮短「語意上唯一的那條求值鏈」。

需要順手澄清一個說法：單執行緒並不等於機器只有一個核在工作，它指的是語意上只有一個求值順序。這也是為什麼擴容的關鍵問題不在核數，而在「這條求值鏈能不能變寬」。

## 代價：平行度被讓渡給了協定之外

規範固定了順序，執行層若想用多核推進交易，唯一合法的方向是找到某個與規範順序等價的交錯，並把結果收斂回同一個狀態。這要求先知道或先發現讀寫集：要麼由交易的發起方或區塊的建構者提前宣告，要麼在執行時動態追蹤並處理衝突。前者把負擔推給協定與工具鏈，後者把負擔推給執行時與回退。

全域狀態這個前提讓代價更明顯。狀態是一棵所有節點共享的樹，每筆交易的更新都要沿著從葉子到根的路徑改寫節點，狀態存取本身成為關鍵路徑上的一環。磁碟 I/O 與網路頻寬可以橫向擴充，這條「取狀態、算結果、寫回狀態、再取下一筆的狀態」的鏈路不能。

## 正面回應：把讀寫集提前宣告出來

對這個原點的直接回應，是把缺失的資訊補上。EIP-7928 提出的區塊級存取清單（Block-Level Access List，BAL）在區塊頭新增一個 `block_access_list_hash` 欄位，記錄區塊執行期間存取過的全部帳戶與儲存位置，以及它們的執行後取值。有了這份宣告，客戶端可以平行讀盤、平行驗證交易、平行計算狀態根，甚至可以不做執行直接更新狀態。

代價同樣寫在提案裡。存取清單必須被生成出來，通常由區塊建構者在執行後產出，再由其他節點驗證其正確性；交易順序、唯一性與確定性需要重新規定，因為平行的前提是每筆交易依賴的位置已經被宣告過。EIP-7928 正文標註的狀態是同行評審中，尚未定稿；Glamsterdam 升級已把 BAL 列入開發網測試範圍，主網啟用時間未定，相關進度見 ethereum.org 的 Glamsterdam 路線圖頁面，開發網細節由第三方追蹤頁維護。它沒有取消設計原點，只是為平行執行補上了一份需要被驗證的前置宣告。

順帶一提，EIP-2930 引入的交易級存取清單是選用的，規範並不強制交易宣告它要存取哪些帳戶與槽，因此它沒有形成可依賴的平行前提。這也是 EIP-7928 要把它提升到區塊級並強制記錄的原因。

## 反例與邊界：順序存在，衝突未必出現

把設計原點講清楚，也需要說明它的邊界。順序是強制的，衝突卻是負載相關的。批次支付、結算、空投分發這類交易大多只觸碰各自接收方的餘額，讀寫集幾乎不重疊，理論上可以高度平行；而熱門 AMM 池、借貸市場的清算、NFT 搶購這類負載讀寫集高度重疊，平行空間被衝突本身吃掉。同一套執行模型在不同負載下的表現差異很大，討論平行收益時必須說明負載畫像與衝突率。

另一條邊界在確定性的落點。平行執行並不取消確定性要求，它把必須保持的對象從一條真實的求值順序，放寬為與該順序可串列化等價的一類執行。衝突偵測、驗證與選擇性重放的代價會重新回到吞吐上，平行因此更像把串列段縮小到衝突路徑上，而不是把串列徹底移除。

還有一層前提容易被忽略：無論執行階段如何平行，最終都必須收斂到同一棵全域狀態樹與同一個根。衝突路徑上的交易必須按規範順序重放，跨分片的狀態存取也需要額外協調。這也是後續討論狀態分片時繞不開的約束。

## 分工：原點在本文，天花板與歷史壅塞在另一篇

這裡要回答的只有「為什麼必須逐筆串列、代價是什麼」。這條邊界造成的效能天花板有多高，2017 年以來歷次壅塞如何反覆暴露它，Gas 上限調整與 EIP-1559 為什麼改不動執行模型，屬於已發布的《為什麼單執行緒 EVM 會卡住 TPS：從歷史壅塞到執行模型》討論的範圍，不在這裡重複其資料與改革過程。兩篇合起來構成一個完整鏈條：先說清原點，再量出天花板。

## 資料來源

- [Ethereum Yellow Paper](https://ethereum.github.io/yellowpaper/paper.pdf)：第 2 節的狀態轉移函式（式 1）與區塊級巢狀定義（式 2）、第 4 章的整體有效性、第 6 節的交易初始有效性（nonce、餘額與 EIP-3607）、區塊收據的累計 Gas 定義（式 186）；附錄 D.1 的證明空間說明。
- [EIP-7928: Block-Level Access Lists](https://eips.ethereum.org/EIPS/eip-7928)（Review，建立於 2025-03-31）：讀寫集未知導致無法平行的動機、`block_access_list_hash` 欄位、順序與確定性要求、EIP-2930 存取清單不被強制的問題。
- [EIP-3607](https://eips.ethereum.org/EIPS/eip-3607)：拒絕來自已部署程式碼帳戶的交易。
- [ethereum.org: Glamsterdam](https://ethereum.org/roadmap/glamsterdam/)：BAL 已列入 Glamsterdam 開發網測試範圍的進度口徑，主網時間未定。
- [reth: stages 文件](https://github.com/paradigmxyz/reth/blob/main/docs/crates/stages.md)：SenderRecoveryStage 與 ExecutionStage 的職責劃分。
- [reth sender_recovery.rs](https://github.com/paradigmxyz/reth/blob/main/crates/stages/stages/src/stages/sender_recovery.rs)：簽章復原在 rayon 執行緒池中平行執行。
- [reth_trie_parallel::state_root_task 原始碼](https://github.com/paradigmxyz/reth/blob/main/crates/trie/parallel/src/state_root_task.rs)：狀態根任務接收執行鉤子（對應串列執行）或雜湊化更新串流。
- [reth_engine_tree::tree::state_root_strategy](https://github.com/paradigmxyz/reth/blob/main/crates/engine/tree/src/tree/state_root_strategy/mod.rs)：稀疏字典樹任務逾時或失敗時回落到串列狀態根計算。
- [reth payload_validator 原始碼](https://github.com/paradigmxyz/reth/blob/main/crates/engine/tree/src/tree/payload_validator.rs) 與 [payload_processor::prewarm 原始碼](https://github.com/paradigmxyz/reth/blob/main/crates/engine/tree/src/tree/payload_processor/prewarm.rs)：順序 EVM 執行搭配平行預執行預熱與平行狀態根任務，以及攜帶 BAL 時的平行執行分支。

## 延伸閱讀

- 下一篇：[《為什麼單執行緒 EVM 會卡住 TPS：從歷史壅塞到執行模型》](/zh-Hant/blog/evm-single-thread-bottleneck)
- 相關閱讀：[《平行執行的三條路線：確定性排程、樂觀 OCC 與物件模型》](/zh-Hant/blog/parallel-execution-approaches)
- 相關閱讀：[《樂觀並行控制（OCC）入門：從資料庫到鏈上執行》](/zh-Hant/blog/optimistic-concurrency-control-intro)
