イエローペーパーの第 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 の存在に依存します(後述するブロックレベル・アクセスリストの経路です)。事前実行(prewarm)はキャッシュを埋めるだけで、結果がそのままコミットされることはありません。この境界線は設計の原点が残したものです。マルチコアは署名検証、ハッシュ、証明生成、キャッシュの充填を加速できますが、「セマンティクス上唯一のあの評価チェーン」を短くすることはできません。
ここで一つの言い方もついでに正しておきます。単一スレッドとは、マシンで一つのコアしか働いていないという意味ではなく、セマンティクス上、評価順序が一つしかないという意味です。だからこそスケーリングの鍵となる問いはコア数ではなく、「この評価チェーンを広げられるかどうか」にあります。
代価:並列度はプロトコルの外側に譲り渡された
仕様が順序を固定しているため、実行レイヤーがマルチコアでトランザクションを進めたいなら、唯一合法な方向は、仕様の順序と等価なインターリーブを見つけ、結果を同じ状態に収束させることです。そのためには読み書き集合を先に知るか、先に見つける必要があります。トランザクションの送信者やブロックの構築者が事前に宣言するか、実行時に動的に追跡してコンフリクトを処理するかです。前者は負担をプロトコルとツールチェーンに押し付け、後者は負担をランタイムとロールバックに押し付けます。
グローバル状態という前提が、代価をよりはっきりさせます。状態はすべてのノードが共有する一つの木であり、各トランザクションの更新は葉から根までのパスに沿ってノードを書き換える必要があり、状態アクセスそのものがクリティカルパスの一環になります。ディスク 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 を持つ場合は並列実行の分岐も使う)