---
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 的吞吐锁在单线程上。这里只处理这一层，也就是设计原点。

## 顺序写在规范里，不是留给客户端的调度自由

黄皮书的写法把执行层要做的事限定了：给定父状态与一串交易，反复应用状态转移函数，得到一个状态。区块头的有效性条件之一，是这个状态经 trie 折叠后得到的根，必须等于区块头里的 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 为例：交易签名恢复交给线程池并行执行；状态更新的哈希与状态根构造交给并行的稀疏 trie 任务，由多个 worker 分担证明生成与 trie 节点读取，任务超时或失败时回落到串行计算；区块处理路径上还会在独立的线程池里提前执行交易，为后续正式执行预热缓存。

正式执行本身仍是顺序的。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)：稀疏 trie 任务超时或失败时回落到串行状态根计算。
- [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/blog/evm-single-thread-bottleneck)
- 相关阅读：[《并行执行的三条路线：确定性调度、乐观 OCC 与对象模型》](/zh/blog/parallel-execution-approaches)
- 相关阅读：[《乐观并发控制（OCC）入门：从数据库到链上执行》](/zh/blog/optimistic-concurrency-control-intro)
