区块链技术的核心特性之一是其去中心化和不可篡改性,而以太坊作为最智能的区块链平台,更是通过智能合约实现了可编程的价值转移和逻辑执行,与许多传统中心化数据库或应用不同,以太坊的“并发”模型有着其独特的内涵和挑战,理解以太坊合约中的并发机制,对于开发者构建高效、安全且无冲突的去中心化应用(DApp)至关重要。
以太坊“并发”的独特性:并非传统意义上的并行
在传统计算机科学中,并发(Concurrency)指的是多个任务同时执行的能力,通常依赖于多核处理器、时间片轮转等机制,而在以太坊中,由于其单线程执行模型和顺序区块确认机制,智能合约的并发并非指多个合约实例真正“运行。
更准确地说,以太坊的并发体现在以下几个方面:
- 独立交易的处理:多个用户可以同时提交多个交易到以太坊网络,这些交易会被矿工收集到待处理交易池(mempool)中。
- 区块内的顺序执行:矿工会选择一定数量的交易打包进一个区块,在一个区块内,交易是按照特定的顺序(通常是基于gas价格和交易发起时间的某种优先级排序)串行执行的。
- 全局状态的单一修改源:所有交易都作用于同一个共享的全球状态(global state),一笔交易的执行结果会作为下一笔交易的初始状态,这意味着,交易的执行顺序直接影响到最终的状态结果,从而可能影响其他交易的执行。
以太坊的“并发”更接近于一种有序的串行处理,或者说是伪并发,多个交易“看起来”像是同时在进行,但实际上它们是在一个虚拟的、单线程的“以太坊虚拟机(EVM)”上依次执行。
并发带来的核心挑战:竞态条件(Race Conditions)
由于交易的执行顺序会影响最终状态,这就引入了以太坊合约并发中最核心的挑战——竞态条件,竞态条件是指当多个交易以不同的顺序执行时,可能会导致合约状态出现不可预测或非预期的结果。
典型的竞态场景包括:
-
余额竞态(重入攻击):
- 场景:合约A允许用户提取代币,攻击者构造了一个恶意合约B。
- 步骤:
- 用户调用合约A的
withdraw()函数,合约A检查用户余额足够,然后调用合约B的receiveEther()(或fallback函数)将代币转移给合约B。 - 在合约B的
receiveEther()函数中,再次调用合约A的withdraw()函数。 - 如果合约A的状态变量(如
balances[user])在第一次调用后尚未更新,或者更新逻辑有漏洞,攻击者可能成功多次提取代币。
- 用户调用合约A的
- 本质:外部合约在状态更新完成前“趁虚而入”,再次修改状态。
-
余额竞态(双重支付):
- 场景:一个简单的代币合约,允许用户转移代币。
- 步骤:
- 用户A有100代币,同时向用户B和用户C各发起一笔50代币的转移交易T1和T2。
- 如果T1和T2都被打包进同一个区块,且执行顺序不当(例如T2先于T1执行),用户A的余额可能在T2执行时已经被扣减50,导致T1执行时余额不足而失败,或者T1和T2都成功导致“双重支付”(如果合约逻辑不严谨)。</li>

- 本质:多个交易基于过时的状态(交易前的余额)进行操作,导致状态冲突。
-
拍卖中的出价竞态:
- 场景:一个简单的拍卖合约,出价最高的用户获胜。
- 步骤:
- 用户A出价X。
- 用户B看到A的出价后,发起一笔出价X+1的交易T1。
- 用户A也发起了另一笔出价X+2的交易T2。
- 如果T1和T2被打包进同一个区块,且T1先于T2执行,那么T2会覆盖T1的出价,A获胜,但如果T2先于T1执行,且B在看到T2后立即发起T3(X+3),则结果又可能改变。
- 本质:后续的交易依赖于之前交易执行后的状态,但最终状态取决于交易的最终顺序,而该顺序在交易被打包前是不确定的。
以太坊应对并发的机制与最佳实践
为了应对并发带来的挑战,以太坊提供了一些内置机制,开发者也需要遵循最佳实践来编写安全的合约。
-
交易执行顺序(区块Gas Limit与矿工选择):
- 以太坊的区块有Gas Limit,限制了单个区块可以执行的总计算量,矿工通常会优先选择Gas价格高、能给他们带来更多收益的交易。
- 开发者注意:不能依赖交易提交的先后顺序或时间戳来保证执行顺序,唯一可靠的是交易在区块中的实际顺序。
-
防止重入攻击:检查-效果-交互(Checks-Effects-Interactions)模式:
-
这是抵御重入攻击最著名和最有效的模式。
-
Checks:首先执行所有必要的检查(如余额是否充足、权限是否足够)。
-
Effects:然后更新合约的状态变量(如扣减余额、标记已处理)。
-
Interactions:最后才与外部合约或地址进行交互(如调用其他合约的函数、发送ETH)。
-
示例:
function withdraw() public { uint256 amount = balances[msg.sender]; // Check require(amount > 0, "Insufficient balance"); (bool success, ) = msg.sender.call{value: amount}(""); // Interaction require(success, "Transfer failed"); balances[msg.sender] = 0; // Effect (should be before interaction, but this is simplified) // 更安全的做法: // balances[msg.sender] = 0; // Effect (先更新状态) // (bool success, ) = msg.sender.call{value: amount}(""); // Interaction }修正版:更严格遵循Checks-Effects-Interactions:
function withdraw() public { uint256 amount = balances[msg.sender]; // Check require(amount > 0, "Insufficient balance"); balances[msg.sender] = 0; // Effect: 先更新本地状态 (bool success, ) = msg.sender.call{value: amount}(""); // Interaction: 再进行外部交互 require(success, "Transfer failed"); }
-
-
使用
reentrancy guard:- 除了遵循设计模式,还可以使用OpenZeppelin等库提供的
ReentrancyGuard合约,它通过一个互斥锁(mutex)机制,确保合约的关键函数在执行期间不会被再次调用。
- 除了遵循设计模式,还可以使用OpenZeppelin等库提供的
-
原子性操作与乐观并发控制:
- 以太坊的交易本身是原子性的:要么全部执行成功,要么全部回滚,不会出现部分执行的情况。
- 对于需要确保多个状态更新一致性的场景,开发者需要仔细设计逻辑,避免中间状态被其他交易依赖,在拍卖中,可以设计为“最高出价者”的更新是原子的,或者使用“提交-揭示”(Commit-Reveal)机制来隐藏出价,减少竞态窗口。
-
事件(Events)与链下计算:
对于复杂的业务逻辑,可以考虑将部分计算逻辑放到链下(如服务器或客户端),通过事件(Events)与链上合约交互,链下处理可以更灵活地实现传统并发控制,然后将确定的结果提交到链上执行。
未来的展望:Layer 2与更高级的并发模型
以太坊主网的单线程和顺序执行模型虽然保证了安全性和确定性,但也限制了其吞吐量(TPS),为了解决可扩展性问题,Layer 2扩容方案(如Optimistic Rollups、ZK-Rollups)应运而生。
许多Layer 2方案引入了更复杂的并发执行模型:
- 并行交易执行:某些Rollup方案(如Arbitrum Nova、Optimism的后续版本)可以在Rollup内部并行执行多个交易,只要它们不访问相同的存储槽(storage slots)或不会相互影响,这显著提高了吞吐量。
- 排序服务(Sequencer):Layer 2通常有一个排序服务来决定交易的执行顺序,这可能与以太坊主网的顺序不同,但仍需保证确定性。
这些Layer 2的并发模型对开发者提出了新的要求,需要理解其排序机制和潜在的并发冲突场景,但同时也为构建高性能的DApp提供了更广阔的空间。
以太坊智能合约







