Bitroot部落格
返回官網 ↗
© 2026 Bitroot · 本站內容僅供一般參考,不構成財務、投資、法律或稅務建議。
編輯標準返回官網
← 全部文章
EVM 基礎·2026/09/29·約 11 分鐘

單執行緒全域狀態:EVM 效能瓶頸的設計原點

以太坊黃皮書把區塊級狀態轉移寫成巢狀呼叫,求值順序因此屬於規範的一部分。逐筆串列的語意來源、讀寫集衝突為何無法預先判定、確定性與驗證簡單性換來了什麼,以及這個設計原點正在被怎樣的方案正面回應,是接下來的主線。

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

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

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

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

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

# 區塊級狀態轉移:第二筆交易的輸入狀態,就是第一筆交易的輸出狀態
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 允許一筆交易呼叫任意位址、讀寫任意儲存槽,而呼叫目標與槽號都可以在執行時才計算出來:

// 呼叫目標與參數在執行時才確定,靜態分析給不出讀寫集
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:第 2 節的狀態轉移函式(式 1)與區塊級巢狀定義(式 2)、第 4 章的整體有效性、第 6 節的交易初始有效性(nonce、餘額與 EIP-3607)、區塊收據的累計 Gas 定義(式 186);附錄 D.1 的證明空間說明。
  • EIP-7928: Block-Level Access Lists(Review,建立於 2025-03-31):讀寫集未知導致無法平行的動機、block_access_list_hash 欄位、順序與確定性要求、EIP-2930 存取清單不被強制的問題。
  • EIP-3607:拒絕來自已部署程式碼帳戶的交易。
  • ethereum.org: Glamsterdam:BAL 已列入 Glamsterdam 開發網測試範圍的進度口徑,主網時間未定。
  • reth: stages 文件:SenderRecoveryStage 與 ExecutionStage 的職責劃分。
  • reth sender_recovery.rs:簽章復原在 rayon 執行緒池中平行執行。
  • reth_trie_parallel::state_root_task 原始碼:狀態根任務接收執行鉤子(對應串列執行)或雜湊化更新串流。
  • reth_engine_tree::tree::state_root_strategy:稀疏字典樹任務逾時或失敗時回落到串列狀態根計算。
  • reth payload_validator 原始碼 與 payload_processor::prewarm 原始碼:順序 EVM 執行搭配平行預執行預熱與平行狀態根任務,以及攜帶 BAL 時的平行執行分支。

延伸閱讀

為什麼單執行緒 EVM 會卡住 TPS:從歷史壅塞到執行模型下一篇平行執行的三條路線:確定性排程、樂觀 OCC 與物件模型相關閱讀樂觀並行控制(OCC)入門:從資料庫到鏈上執行相關閱讀
← 上一篇世界狀態與 Merkle Patricia Trie:stateRoot 如何承諾一切
本文目錄
順序寫在規範裡,不是留給客戶端的排程自由順序的硬約束:nonce、餘額與累計 Gas衝突從哪裡來:讀寫集相交,而且只能事後知道單執行緒買到了什麼:確定性與驗證即重放客戶端確實用滿了多核,但都在語意之外代價:平行度被讓渡給了協定之外正面回應:把讀寫集提前宣告出來反例與邊界:順序存在,衝突未必出現分工:原點在本文,天花板與歷史壅塞在另一篇資料來源延伸閱讀
閱讀設定