黄皮书第 2 节把以太坊描述成一台由交易驱动的状态机。那套形式化里的符号约定是:σ 表示世界状态,T 表示一笔交易,Υ 是交易级状态转移函数,把一笔交易作用在状态上;B 表示区块,Π 是区块级状态转移函数,区块内的交易按顺序记为 T₀、T₁……。基于这套记号,黄皮书给出两条式子:单笔交易的状态转移是 σ_{t+1} ≡ Υ(σ_t, T),区块级转移是 Π(σ, B) ≡ Υ(Υ(σ, T_0), T_1)…。第二条式子值得多看一会,它把整个区块的执行写成函数的嵌套调用,第二笔交易的输入是执行完第一笔之后的状态。
求值顺序因此属于规范的一部分,客户端没有自行选择的余地。要追问的是:为什么只能这样定义,如果允许两笔交易同时求值会失去什么,以及这个选择如何把 EVM 的吞吐锁在单线程上。这里只处理这一层,也就是设计原点。
顺序写在规范里,不是留给客户端的调度自由
黄皮书的写法把执行层要做的事限定了:给定父状态与一串交易,反复应用状态转移函数,得到一个状态。区块头的有效性条件之一,是这个状态经 trie 折叠后得到的根,必须等于区块头里的 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 为例:交易签名恢复交给线程池并行执行;状态更新的哈希与状态根构造交给并行的稀疏 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:第 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:稀疏 trie 任务超时或失败时回落到串行状态根计算。
- reth payload_validator 源码 与 payload_processor::prewarm 源码:顺序 EVM 执行搭配并行预执行预热与并行状态根任务,以及携带 BAL 时的并行执行分支。