区块链入门文章--以太坊到底是如何运作的?

以太坊到底是如何运作的?

本文译自这里,原作者 preethi,译者博主本人。翻译前已获得原作者许可。

不管你知不知道以太坊区块链 (Ethereum),你都有可能听到过这个东西。最近以太坊经常出现在新闻里,有时还是一些主流杂志的封面新闻,但如果你对以太坊没有基本常识,那这些文章读起来就会觉得不知所云。所以,这东西到底是什么?本质上,它是一个公共数据库,用于永久性地记录数字交易。关键是,这个数据库不需要任何中央机构来维护或者保护它。相反地,它是一个“无需信任关系”的交易系统,也就是说在这个系统中,不必依靠一个可信的第三方或者其他类似机构,个人之间便可进行端到端的交易。

仍然感到很迷惑? 这就是写这篇文章的来由了。我的目标是在技术层面上解释以太坊是如何工作的,并避免使用复杂的数学或者让人劝退的公式。不管你是不是程序员,我都希望你在看完文章后能更好地理解这一技术。如果某些内容涉及到太深的技术,很难完全理解,那也不用在意!真的不需要去理解每一个细节。我建议只需在一个宽泛的层面上理解这些东西即可。

本文涵盖的许多议题,都是对以太坊黄皮书中所涉及概念的剖析。我添加了一些自己的见解和图示使你更容易理解以太坊。敢于挑战技术细节的读者也可以直接去阅读以太坊黄皮书。

现在让我们开始吧!

区块链定义

区块链是一台“密码学上安全的状态开放共享的用于交易的单实例计算机”。是不是很绕口? 我们把它分解一下。

  • “密码学上安全的”意味着在创建数字货币时,借助非常难以破解的复杂数学算法作为其保护措施。 概念上可以对比一下防火墙。这基本上就没法去欺骗系统(如创建虚假的交易、删除交易等)。
  • “用于交易的单实例计算机”意味着该计算机只有一个实例,其负责系统中所创建的所有交易。换而言之,每个人都信任同一个全局事实。
  • “状态开放共享的”意味着这台计算机的状态对每个人都是共享且开放的。

以太坊实现了这一区块链模式。

解说以太坊区块链模式

以太坊区块链实际上是一台基于交易的状态机。在计算机科学相关概念中,_状态机_能读取一系列的输入,基于这些输入其能转换到新的状态中。

image

以太坊状态机是从“创世状态”开始转换的。这时网络上还没有进行任何交易,其就像一张白纸一样。执行了若干交易后,就会从该创世状态转换到某种最终状态中。在任何时候,当前以太坊的状态都会是某种最终状态。

image

以太坊的状态由上百万的交易构成。这些交易按照“区块”分组。一个区块包含一系列的交易,并且每个区块与其前面一个区块链接在一起。

image

有效的交易才能使状态机从一个状态转换到下一个状态。必须通过一个被称为挖矿的流程来验证交易是否有效。挖矿就是一组节点(即计算机)花费其计算资源来创建一个包含有效交易的区块。

网络上任何宣布自己是矿机的节点都可以尝试去创建和验证区块。分布于全世界的大量矿机在同时做这件事。当将区块提交到区块链上时,每个矿机会提供一个数学上的“证明”,只要有这个证明,那对应的区块就是有效的。

为将自己生成的区块加入到主区块链,矿机必须比其他与其竞争的矿机更快地得出证明。矿机提供数学证明来验证每一个区块这一过程被称为“工作量证明”。

成功验证一块区块的矿机将会获得一定的奖励。那奖励是什么呢?以太坊区块链使用一种内在的被称为“Ether(以太)”的数字代币。每当矿机证明一个区块时,系统就生成一些 Ether 代币并奖励给它。

你可能会问是什么让每个人都始终认可同一条区块链?怎样才能确保不会有一群矿机决定建立自己的区块链?

在本文开头处,我们将区块链定义为一台状态共享的用于交易的单实例计算机。在该定义下,我们可以明确区块链的正确当前状态是整个系统中存在唯一一个全局事实,每个用户都必须认可这一点。当系统中存在多个状态(或者链)时,由于没法就哪一个才是正确的达成共识,所以会破坏掉整个系统。如果链出现分叉,你可能在这条链上有 10 代币,那条链上有 20 个,另外一条链上还有 40 个。这种情况下,没法确定哪条链是“最有效”的。

当产生多条路径时,就会出现“分叉”。我们通常想避免分叉,因为这会使系统分裂,强迫人们选择一条他们“信任”的链。

image

为确定哪条路径是最有效的,并防止产生多条链,以太坊使用一种称为“GHOST 协议”的机制。

“GHOST”= “Greedy Heaviest Observed Subtree”

简单来说,GHOST 协议要求我们必须选择那条已经完成了最多计算量的路径。找出那条路径的一种方式是使用最新区块(“叶子区块”)的区块号,它表示当前这条路径中的区块总数(不计入创世区块)。区块号越大,说明从创世区块到该叶子区块的路径越长,且用来挖矿的算力越多。我们可以利用这一推论就当前状态的权威形式达成共识。

image

现在你已经大概了解了区块链,让我们深入一点看看以太坊所包含的主要概念:

  • 帐户
  • 状态
  • gas 和费用
  • 交易
  • 区块
  • 交易执行
  • 挖矿
  • 工作量证明

开始探讨前请注意:当我提及 X 的“哈希”时,我是指用以太坊的 KECCAK-256 算法计算出的哈希值。

帐户

以太坊的全局“共享状态”由许多小对象(帐户)组成,它们能通过消息传递框架进行交互。每个帐户拥有一个关联的状态和一个 20 字节的地址。以太坊中的地址是一个 160 比特的标识符,可以用来标识任何帐户。

帐户有两种类型:

  • 外部帐户(或者说外部持有帐户),由私钥所控制并且没有关联的代码。
  • 合约帐户,由对应合约代码所控制并且有关联的代码。

image

外部帐户 vs 合约帐户

外部帐户和合约帐户之间有一个重要的本质区别需要了解。外部帐户通过其私钥创建并签署交易后,从而向其他外部帐户或者合约帐户发送消息。在外部帐户之间传递消息就是传递价值 (Ether)。但是当外部帐户发送消息到合约帐户时,就会激活合约帐户的代码进而执行各种操作(例如转移代币、写入内部存储、铸造新代币、做些计算以及创建新合约等等)。

合约帐户不同于外部帐户,它不能主动发起新的交易。只有在响应所接收到的交易(来自于外部帐户或者其他合约帐户)时,合约帐户才能发起交易。我们将在“交易与消息”一节中进一步了解在合约之间发生的调用。

image

因此,外部帐户发起的交易触发了以太坊区块链上的所有操作。

image

帐户状态

不管是哪种类型帐户,其帐户状态都包括四个部分:

  • nonce:若是外部帐户,其表示从该帐户地址已发起的交易数量。而若是合约帐户,则其表示该帐户已创建的合约数量。
  • balance:该地址的余额,以 Wei 为单位。每个 Ether 为 1e+18 Wei。
  • storageRoot:Merkle Patricia 树根节点的哈希(我们稍后解释什么是 Merkle 树)。这棵树编码了该帐户相关存储内容的哈希,默认情况下是空的。
  • codeHash:该帐户相关的 EVM 代码的哈希。EVM 指 Ethereum Virtual Machine,后文有进一步讲述。对于合约帐户,codeHash 就是该代码的哈希。对于外部帐户,codeHash 为空字符串的哈希。

image

全局状态(世界状态)

现在我们知道以太坊的全局状态包括了在帐户地址和帐户状态之间形成的映射关系。这一映射关系存储在 Merkle Patricia 树这种数据结构中。

Merkle 树是一种二叉树(译者注:1. 以太坊采用一种修改过的 Merkle Patricia 树,其结合了 Merkle 树和前缀树的优点;2. Merkle 树不一定要是二叉树,但二叉树最常见),包含这些节点:

  • 在树的底部有大量包含底层数据的叶节点
  • 一些中间节点,每个节点的值都是其两个子节点值的哈希
  • 一个根节点,位于树顶端,其值也是两个子节点值的哈希

image

将待存储的数据分解成一个个_块_,再将这些块分成若干_桶(bucket)_,计算出每桶的哈希,然后根据这些哈希算出上一层节点的哈希。不断重复这个过程,直到只剩下一个哈希:根哈希

image

在这棵树中存储的每一个值都需要有一个键。从根节点开始,通过键来找到下一个中间节点,重复这个过程直至找到目标叶节点,其保存有关联的数据。在表示以太坊全局状态的树结构中,键/值映射关系是指地址映射到其对应帐户状态(包括 balance,nonce,codeHash 以及 storageRoot,其中 storageRoot 本身也指向一棵树)。

image

来源:以太坊白皮书

在保存交易和回执时也用了同样的数据结构。具体来说,每一个区块都有一个“头部”,其中保存有三个根节点的哈希,对应于三棵不同的 Merkle 树,包括:

  1. 状态树
  2. 交易树
  3. 回执树

image

在以太坊中,对于“轻量级客户端”或者“轻量级节点”来说,能通过 Merkle 树高效存储所有这些信息是非常有帮助的。要记得区块链是由一堆节点共同维护的。宽泛地说,节点有两种类型:完整节点和轻量级节点。

建立完整归档节点时,需要下载从创世区块到当前头部区块这一整条链,并执行其中所有交易,以保持与区块链同步。通常,为进行挖矿,矿机需要存储完整归档节点。也可以在下载完整节点同时只执行部分交易。但不管怎样,只要是完整节点,都会包含整条链。

除非节点需要执行所有交易或者很轻松地查找历史数据,否则真的没有必要存储整条链。所以就提出了轻量级节点这一概念。创建轻量级节点时,只用下载从创世区块到当前头部区块这一条链上的所有区块头部,无需下载存储整条链、执行任何交易以及检索任何相关状态。由于轻量级节点能访问区块头部中的三棵树根节点的哈希值,所以为交易、事件和余额等相关操作接收和创建可验证的应答也还是很容易的。

这一点可行是因为 Merkle 树中的哈希会向上传播:如果恶意用户将 Merkle 树底部的一笔交易换为虚假交易,那么这一修改将导致上层节点哈希发生变化,从而又导致上层节点的上层节点哈希发生变化,最终将使树的根节点哈希发生变化。

image

任何节点都可以通过“Merkle proof”来验证一组数据。Merkle proof 包括以下几点:

  1. 一块待验证的数据以及其哈希
  2. 树的根节点哈希
  3. 相关“分支”(从这块数据到根节点这一路径上所经过的节点及相邻节点)

image

任何人在阅读证明时,都可以验证是否那一分支上从底层节点到根节点间的哈希始终与树上对应哈希保持一致,从而确定所验证的数据块在树上的位置是否正确。

总之,使用 Merkle Patricia 树的好处是,从密码学角度来说,这一结构的根节点依赖于树中存储的数据,因此根节点的哈希可以用于标识这一数据是否安全。由于区块头部包含了状态树、交易树、回执树的根节点哈希,那当每个节点验证以太坊中一小部分状态时,只需一小部分数据即可,而不用存储大小上可能无限增长的完整状态。

Gas 和支付

以太坊中一个非常重要的概念是费用。在以太坊网络上执行交易时产生的每一次计算都需要支付一笔费用(没有免费的午餐!)。这类费用是按照“gas”这种面额来支付的。

Gas 是用于衡量特定计算所需费用的单位。Gas 单价是你愿意为每单位 gas 支付的 Ether 数量,以“gwei”为单位。“Wei”是 Ether 的最小单位,10¹⁸ Wei 等同于 1 Ether。1 gwei 等同于 1,000,000,000 Wei。

在每笔交易中,发送方设定一个 gas 额度gas 单价。发送方为执行一笔交易,最多支付等同于 gas 单价gas 额度之积的费用,以 Wei 为单位。

例如,如果发送方设定 gas 额度为 50,000,gas 单价为 20 gwei。这表示发送方为执行这笔交易,最多愿意支付 50,000 x 20 gwei = 1,000,000,000,000,000 Wei = 0.001 Ether。

image

要注意,gas 额度表示发送方愿意为最多多少 gas 而付费。如果他们帐户余额中有足够的 Ether 来承担这一额度的开销,那就没有任何问题。当交易结束时,所有尚未用完的 gas 都将按照交易发起时的费率换算为 Wei,并返还给发送方。

image

如果在执行交易时,发送方没有 提供足够的 gas,那么该交易会“耗尽 gas”,导致系统判定该交易无效。在这种情况下,将终止交易执行,并且因这一交易而改变的所有以太坊状态都将恢复到执行该交易前的初始状态。此外,关于此次交易失败的记录将被存储下来,其中记录了该交易尝试做什么以及在哪里失败了。并且由于系统在用完 gas 之前已经消耗了一些资源来做计算,因此,不会将 gas 退还给发送方。

image

那在 gas 上花费的钱去了哪里呢?发送方购买 gas 时花的所有钱都转移到了“受益人”所在地址,通常就是矿机所在地址。因为矿机在执行计算和验证交易时消耗了资源,所以会获得对应 gas 费用以作为奖励。

image

通常,如果发送方愿意为 gas 支付更高单价,那么矿机就能从交易中获得更多利润。因此,矿机就越可能选择这笔交易。这样,矿机就能自由地选择验证或者忽略哪些交易。为了向发送方提供 gas 参考单价,矿机可以将执行交易时他们所接受的最低 gas 单价公布出来。

存储数据也需要费用

不仅要为计算支付 gas,存储数据也需要支付 gas。存储所需费用与所存储数据的大小呈正比,计算数据大小时以 32 字节为单位,不到 32 字节的部分忽略不计。

存储数据相关费用有一些微妙的特性。例如,由于存储数据会导致_所有_节点上的以太坊状态数据库同时增大,所以就需要将存储的数据量保持在一个尽可能小的规模上。因此,如果交易中包含了一项操作来清除存储空间中的条目,那么执行该操作就是免费的,所免除的费用将退还回来。

收费的目的是什么?

以太坊工作方式的一个重要特征是,网络上执行的每个操作同时作用于每个完整节点。然而,以太坊虚拟机所执行的计算性操作非常昂贵。因此,以太坊智能条约最好用于执行简单的任务,比如运行简单的商业逻辑或者验证签名和其他加密对象,而不是用在更加复杂的用途上,比如文件存储、电子邮件或者机器学习,这会对网络造成压力。收费能防止用户滥用网络资源。

以太坊的语言是图灵完备的。(简单地说,图灵机可以模拟任何计算机算法,如果你不熟悉图灵机,看看这里还有这里)。所以程序中可以执行循环操作,使以太坊容易受停机问题的影响,这个问题是说你没法确定一个程序会不会永远执行下去。如果不用付费,恶意用户就能通过在交易中执行一个无限循环来破坏网络的运作,却不会受到任何惩罚。因此,收费能防止网络被蓄意攻击。

你可能会想,“为什么我们存储数据时也需要付费?”唔,就如计算一样,在以太坊网络上存储数据时整个网络都需要承担对应的开销。

交易与消息

我们先前有提到以太坊是一台基于交易的状态机。换而言之,不同帐户之间进行的交易推动了以太坊全局状态的变化。

从最基本的角度来看,交易是一条由外部帐户所创建的加密签名过的指令,被序列化并提交到区块链上。

交易有两种类型:消息调用合约创建(即用于创建新的以太坊合约的交易)。

不管是什么类型的交易,都包含以下信息:

  • nonce:发送方已发送的交易数量。
  • gasPrice:为执行这一交易,发送方愿意为此支付的 gas 单价。
  • gasLimit:为执行这一交易,发送方愿意为此支付的最大 gas 额度。在进行相关计算前,需要提前设定并支付这一数量的 gas。
  • to:接收方的地址。在合约创建类型的交易中,合约帐户还不存在,所以使用一个空地址来代替。
  • value:发送方转给接收方的金额,以 Wei 为单位。在合约创建类型的交易中,该金额作为新建的合约帐户中的初始余额。
  • v, r, s:用于产生一个标识该交易发送方的签名。
  • init(只存在于合约创建类型的交易中):用于初始化新合约帐户的 EVM 代码片段。init 只运行一次,然后就被丢弃掉。当 init 第一次运行时,它返回与该合约帐户永久关联的代码。
  • data (可选字段,且仅存在于消息调用这一类型的交易中):消息调用所需要的输入数据(即参数)。例如,如果一个智能合约提供域名注册服务,为调用该合约就需要一些输入数据,比如域名和 IP 地址。

image

我们在“帐户”一节中了解到了交易(不管是消息调用还是合约创建)总是由外部帐户所创建并提交到区块链上。如果站在另一个角度来理解这一点,可以说交易是外部世界信息通往以太坊内部的桥梁。

image

但这不是说合约之间不能通信。在以太坊状态中全局作用域内的合约可以与在相同作用域内的其他合约通信。它们向其他合约传递“消息”或者“内部交易”来实现通信。我们可以认为消息或者内部交易类似于交易,主要的区别在于它们不是由外部帐户所创建的。反而它们是由合约所创建的。它们是虚拟对象,不同于交易的是它们不会被序列化并且只存在于以太坊执行环境中。

当一个合约向另一个合约发送一次内部交易时,将激活接收方合约帐户的关联代码。

image

有个重点需要注意,内部交易或消息中不包含 gasLimit 这一信息。这是因为 gas 额度是由最初那条交易的外部创建人(即某个外部帐户)所设定的。外部帐户设定的 gas 额度必须能足以执行此交易,以及由此交易衍生出来的任何操作,例如合约之间的消息传递。在连续有序地执行交易和消息时,如果某个消息执行时用完了 gas,那么将撤销执行该消息以及因执行该消息而触发的任何后续消息。但是,不需要撤销上一级的执行。

区块

所有交易都按照一个个“区块”组织起来。一条区块链包含了一系列这样的被链接起来的区块。

在以太坊中,一个区块包含了:

  • 区块头部
  • 这一区块中的交易集的相关信息
  • 当前区块的叔块 (ommer) 头部集。

叔块解释

叔块是什么鬼东西?如果一个区块的父块等同于当前区块的父块的父块,就说这个区块是当前区块的叔块(译者注:还可以将叔块概念再往上推一些,在 6 代以内都算叔块)。我们来快速了解一下叔块是做什么的,以及为什么区块中要包含叔块头部。

由于以太坊的构建方式有些不太一样,所以相比于其他类型区块链,其出块时间缩短至大概 15 秒,像比特币需要大概 10 分钟。因此以太坊处理交易起来就更快。然而,更短的出块时间带来的一种负面影响是,会有更多矿机找到有效的区块,但只有一个区块能够上链,因而也产生了更多上链失败的区块。这些上链失败的区块也被称为“孤块”(即开采出来的区块没法与主链连接在一起)。

叔块存在的目的是为了给那些引用了这些孤块的矿机一些回报。矿机引用的叔块必须是“有效”的,也就是说,叔块与当前区块间隔不能超过六代。超出六代以后,旧孤块就不能再被引用了(因为引入比其更早的交易会让事情变得有些复杂)。

引用叔块比采到新区块所获的奖励要少一些。尽管如此,某种程度上,仍然能鼓励矿机去引用这些孤块来获取奖励。

区块头部

我们返回来探讨下区块。我们先前提到每个区块都有一个区块“头部”,但它具体是什么呢?

区块头部是区块的一个部分,包括:

  • parentHash:父块头部的哈希(这一字段使这些区块构成了一条“链”)
  • ommersHash:该区块所引用的叔块列表的哈希
  • beneficiary:帐户地址,开采该区块所获得的回报会转入到该地址中
  • stateRoot:状态树根节点的哈希(回想下我们对状态树的理解,它是怎样存储在头部中的,以使得轻量级客户端能轻松验证与状态相关的所有信息)
  • transactionsRoot:交易树根节点的哈希,该树记录了这个区块中的所有交易
  • receiptsRoot:回执树根节点的哈希,该树记录了这个区块中所有交易的回执
  • logsBloom:一个记录了日志信息的布隆过滤器(数据结构)
  • difficulty:开采这一区块的难度
  • number:当前区块的编号(创世区块的区块号为 0,其后每个区块的编号按 1 递增)
  • gasLimit:当前每个区块的 gas 额度
  • gasUsed:该区块中的交易所使用的 gas 总额
  • timestamp:该区块创建时的 unix 时间戳
  • extraData:与该区块相关的其他数据
  • mixHash:哈希值,和 nonce 一起使用可以证明在这个区块上已经执行了足够多的计算
  • nonce:哈希值,和 mixHash 一起使用可以证明这个区块执行了足够多的计算

image

要注意每个区块头部是怎样为这些数据保存三个树形数据结构的:

  • 状态(stateRoot
  • 交易(transactionsRoot
  • 回执(receiptsRoot

这些树形数据结构就是我们之前讨论过的 Merkle Patricia 树。

此外,在上面的描述中,有一些术语值得去说明一下。我们去看一下。

日志

我们可以通过通过日志来跟踪以太坊中的各种交易和消息。合约可以通过定义它希望记录的“事件”,来显式地产生日志。

一项日志条目包括:

  • 日志记录器的帐户地址
  • 一组主题 (topic),表示该交易产生的各种事件,以及
  • 与这些事件相关的所有数据

日志由布隆过滤器记录下来,该结构能有效地管理源源不绝的日志数据。

交易回执

头部中的日志来自于交易回执中所包含的日志信息。就像你在商店购物时会收到一份回执单一样,以太坊每次交易都生成一份回执。如你所想的那样,回执中总会包含有关交易的一些信息。该回执包含的项目类似这样的:

  • 该交易所在区块的编号
  • 该交易所在区块的哈希
  • 该交易的哈希
  • 该交易所使用的 gas 数额
  • 该交易执行后,其所在区块累计消耗的 gas 数额
  • 该交易执行时产生的日志
  • ...等等

区块难度

区块的难度是用来保证我们以稳定的速率去验证区块。创世区块的难度为 131,072,对于在其后的区块,使用一个特殊的公式来计算对应难度。如果某个区块的验证速度比前面的区块更快,以太坊协议就会增加下一个区块的难度。

开采区块必须要计算出 nonce 这个哈希值,而区块的难度会通过工作量证明算法影响 nonce 的计算难度。

区块的难度nonce 的关系在数学上可以形式化为:

image

这里, Hd 表示难度。

寻找满足难度要求的 nonce 的唯一方法是,通过工作量证明算法来枚举所有可能的解。寻找答案时所需的期望时长与难度成正比:难度越高,越难找到 nonce,从而就越难去验证区块,结果就是耗费了更长时间去验证新的区块。因此,协议可以通过调整区块难度来调整区块的验证时长。

从另一方面来讲,如果验证时长变长了,协议就会降低难度。通过这种方式,系统就会自行调整验证时间,以保持恒定的速率:平均情况下每 15 秒出一个区块。

交易执行

现在我们来了解以太坊协议中的一个最复杂的部分:交易的执行。假设你将一个待处理的交易发送到以太坊网络中。为接收你的交易,以太坊的状态会发生怎样的变化?

image

首先,为能执行交易,交易本身必须满足一系列的初步要求。包括:

  • 这个交易必须按照 RLP 编码做了正确的格式化。“RLP”表示“Recursive Length Prefix(递归长度前缀编码)”,是一种用于格式化由二进制数据组成的嵌入数组的数据编码。以太坊使用 RLP 编码来序列化对象。
  • 有效的交易签名。
  • 有效的交易 nonce。回想一下,nonce 表示该帐户已发送的交易数量。交易中的 nonce 若要有效,其必须等同于发送方帐户的 nonce。
  • 交易的 gas 额度必须大于或等于其所消耗的固有的 gas 数额。固有的 gas 数额包含:
  • 一笔 21,000 gas 的手续费,用于执行该交易
  • 一笔用于在交易中传输额外信息的 gas 费用(不管是表示数据还是代码,对于值为 0x0 的字节,收取 4 gas 每字节,否则收取 68 gas 每字节)
  • 如果该交易是用于创建合约的,还需额外 32,000 gas

image

  • 发送方在帐户中要有充足余额来支付所必需的 gas“预付”费用。Gas 预付费用的计算很简单:首先,将交易的 gas 额度乘以该交易的 gas 单价得出最大 gas 成本。然后,将这个最大成本与发送方转给接收方的金额加总起来,这一总额就是 gas 预付费用。

image

如果这个交易满足上述所有要求,是一个有效交易,那我们就继续来看。

首先,我们从发送方的余额中减去预付的 gas 费用,并且发送方帐户的 nonce 需要加 1 以计入当前交易。这时,我们可以将总 gas 额度减去固有的 gas 数额,算出剩余的 gas 数额。

image

接下来,这个交易就开始执行了。在执行该交易的过程中,以太坊一直维护着一个“子状态”。通过子状态,以太坊可以记录在交易执行期间产生的信息,并且在交易执行完成后马上就需要用上这些信息。它具体包括这些内容:

  • 自毁帐户集:在交易执行完成后将被销毁的一组帐户(如果有的话)。
  • 日志集:EVM 虚拟机执行代码时的检查点,已归档并且可索引。
  • 返还余额:交易执行结束后,返还到发送方帐户的金额。还记得我们先前所说的,在以太坊中存储数据需要付费,而发送方清空数据时可以获得一些退款吗?以太坊使用退款计数器维护这一信息。退款计数器从零开始,每次合约删除一些存储内容时其值都会增加。

然后,执行该交易中的各类计算。

当顺利处理完该交易中的所有必需步骤后,明确需要返还给发送方的 gas 余额,让该交易的关联状态最终确定下来。除了返还未使用的 gas 外,还会从我们先前提到的“返还余额”中取出部分作为津贴,补偿给发送方。

当发送方获得返还后:

  • 向矿机支付 gas 费用
  • 将该交易消耗的 gas 数额加总到其所在区块的 gas 累计值上(该值记录了在该区块中所有交易所消耗的 gas 总额,用于验证区块)
  • 删除自毁账户集中的所有帐户(如果有的话)

最终,我们创建了一个新的状态,并保留了该交易生成的日志。

我们已经了解了交易执行的基础内容,现在我们去看看用于创建合约的交易与用于调用消息的交易之间有什么不同。

合约创建

回想一下,以太坊中有两种类型的帐户:合约帐户和外部帐户。当一个交易被称作是“用于创建合约的”,我们是指这个交易的目的是创建一个新的合约帐户。

为创建一个新的合约帐户,首先我们用一个特殊的计算公式算出新帐户的地址。然后我们按照下面这些步骤初始化新帐户相关数据:

  • 将 nonce 设置为零
  • 如果发送方通过交易中的 value 字段转移了一些 Ether 过去,则将新帐户的余额设定为该值
  • 从发送方帐户的余额中减去已转给新账户的数额
  • 清空新账户存储空间
  • 将该合约的 codeHash 字段设定为空字符串的哈希

当我们初始化完成后,就能通过交易中的 init 代码真正地建立帐户(回顾“交易与消息”一节,去复习一下 init 代码这一概念)。不同场景下,init 代码实现的功能并不相同。它可能会更新该帐户的存储内容,创建其他合约帐户,发起其他消息调用,等等,这取决于该合约的构造器是如何实现的。

初始化该帐户时会消耗 gas。不允许该交易超额使用 gas。如果超额了,执行时就会遇到 gas 耗尽异常(out-of-gas exception,OOG exception),然后就会退出执行。如果因遇到 gas 耗尽异常导致该交易终止,那么系统就会恢复到刚准备执行该交易时的状态。先前执行时已经消耗的 gas _不会_退还给发送方。

啊这!

但是,如果发送方在交易中传递了一些 Ether,即便没能成功创建这个合约,这些 Ether 还是会退还回来的。还好还好!

如果初始化代码执行成功,那最后还需要为合约创建付一笔费用。这笔费用用于存储,其与新建合约的代码大小成正比(再说一次,没有免费的午餐!)。如果 gas 余额不足以支付最后这笔费用,那该交易会再次遇到 gas 耗尽异常,并终止执行。

如果一切进展顺利,那些尚未用完的 gas 余额会退还给最初发起该交易的发送方,现在可以持久地保存更新后的状态了。

欢呼吧!

消息调用

除了一点点差别外,调用消息与创建合约是类似的。

因为不用创建新账户,所以消息调用中不会包含任何 init 代码。但是,它可以包含交易发送方提供的输入数据。在执行时,消息调用还有一个包含了输出数据的部分,后续执行中可以使用这一数据。

就如合约创建一样,如果执行消息调用时,gas 不足或者交易无效(例如栈溢出、无效的跳转目标或者无效的地址),导致执行中断,已经消耗掉的 gas 不会返还给最初的调用方。相反,所有未使用的 gas 余额都会被用掉,并且系统状态将回到刚准备转帐时的那一刻。

在目前最新的以太坊版本以前(译者注:在拜占庭升级以前),只有消耗掉所有你提供的 gas,系统才能停止或撤销交易的执行。假如说,你写了一个合约,在调用该合约时,如果调用者没有权限执行某一交易,那么它会抛出一个错误。在以前的以太坊版本中,如果合约抛出了错误,剩余的 gas 都会被用掉,而不会返还给发送方。但是拜占庭升级引入了一个新的操作码“revert”,使得合约可以停止运行并撤销对系统状态的更改,不需要消耗剩余的 gas,而且在交易执行失败时可以返回对应的原因。如果交易因撤销操作而结束,那么尚未使用的 gas 将返还给发送方。

执行模型

到目前为止,我们已经了解了交易从开始执行到结束所必需进行的一系列步骤。现在,我们来看看在虚拟机中是怎样实际执行交易的。

协议中实际执行交易的组件被称为以太坊虚拟机(EVM),是以太坊自建的。

就如先前定义的那样,EVM 是一种图灵完备的虚拟机。相比于典型的图灵完备的机器,EVM 的唯一限制是它实质上受 gas 的制约。因此,所提供的 gas 数额实质上限定了所能做的计算量。

image

来源:CMU

此外,EVM 的架构是基于栈的。基于堆栈的计算机使用先进、后出的栈来保存临时数据。

EVM 中,栈中的每个数据项为 256 bit,最多能容纳 1024 个数据项。

EVM 具备内存,其中数据内容存储为按字寻址的字节数组形式。内存中数据是易失的,也就是说它不是永久的。

EVM 也有存储设备。不同于内存,存储设备中的数据是非易失的,这些数据作为整个系统状态的一部分,在维护时都会考虑进来。EVM 将程序代码单独存储在一个虚拟的 ROM 中,只能通过特殊指令来访问。在这方面,EVM 不同于典型的冯诺依曼架构(在这种架构中,程序代码在内存或者存储设备中都可以保存)。

image

EVM 也有自己的语言:“EVM 字节码”。当像你我这样的程序员编写在以太坊上运行的智能合约时,我们通常会通过比如 Solidity 这种处于更高层次的语言来写程序。随后我们能将其编译为处于更低层次的 EVM 能够理解的字节码。

好了,现在来执行一下。

在执行特定计算前,处理器需要确保下面这些信息存在且有效:

  • 系统状态
  • 用于计算的 gas 余额
  • 帐户地址,这个帐户拥有当前正在执行的代码
  • 发送方地址,该发送方发起了一个交易,是导致此代码被执行的根源
  • 帐户地址,这个帐户触发了该代码的执行(可以不同于最初的那个发送方)
  • 交易的 gas 单价,该交易是导致此代码被执行的根源
  • 这次执行的输入数据
  • 在这次执行中,转给该帐户的金额(以 Wei 为单位)
  • 待执行的机器码
  • 当前区块的头部
  • 当前调用消息或创建合约时所使用的栈的深度

刚开始执行时,内存和栈都是空的,并且程序计数器为零。

PC: 0 STACK: [] MEM: [], STORAGE: {}

随后 EVM 循环地执行这个交易,每个循环都需要计算系统状态机器状态。系统状态就是指以太坊的全局状态。而机器状态包括:

  • 可用的 gas 额度
  • 程序计数器
  • 内存中的内容
  • 内存中当前活跃的字数
  • 栈中的内容

从最左边添加或者删除栈中的数据项。

每次循环都会从 gas 余额中减去适当数额,同时增加程序计数器的值。

在每个循环结束时,有三种可能:

  1. 机器进入了异常状态(比如 gas 不足、无效指令、栈中缺失项目、存储栈项目时超出了 1024 这一限制、无效的 JUMP/JUMPI 目标等等),因此必须停机,并撤销所有的状态更改。
  2. 机器继续执行下一个循环
  3. 机器进入受控停机状态(执行结束)

如果执行过程中没有遇到异常,进入了“受控”或正常停机状态,机器将生成最终的状态数据,算出此次执行后的 gas 余额,生成所积累的子状态数据以及最终的输出。

呼,我们了解完了以太坊最复杂的一个部分。即便你没有完全理解这个部分也是没有关系的。除非你在工作中需要接触很底层的内容,否则你_真的_不需要这么深入地理解其执行细节。

怎样确定一个区块

最后,我们来看看由大量交易组成的区块是怎样确定下来的。

我们所说的“确定”有两种含义,其理解取决于这个区块是新的还是现存的。如果是一个新区块,我们是指开采该区块的过程。如果是一个现存的区块,那我们就是指验证该区块的过程。不管哪种情况,为“确定”一个区块,都要满足四个要求:

1) 验证(对于开采新区块来说,是确定)叔块
该区块头部中的叔块字段必须有效,并且必须离该区块距离不超过六代。

2) 验证(对于开采新区块来说,是确定)交易
该区块中的 gasUsed 字段必须要等于该区块中包含的所有交易总共消耗的 gas 数额。(回想一下,执行交易时,我们会跟踪其所在区块的 gas 累计值,其记录了该区块中所有交易总共消耗的 gas 数额。)

3) 给予奖励(只针对开采新区块)
为奖励挖出这个区块,系统转 5 Ether 到该区块的受益人地址上。(根据以太坊提议 EIP-649,这 5 ETH 的奖励不久将缩减为 3 ETH。)此外,对于当前区块引用的每个叔块,受益人会获得当前区块奖励的 1/32 作为额外奖励。最后,叔块的受益人也会获得一些奖励(有一个特殊公式用来计算对应数额)。

4) 验证(对于开采新区块来说,是计算出一个有效的)状态和 nonce
确保应用了所有交易以及对应的状态更改,并将新区块的最终状态定义为,当其中最后一个交易完成了奖励颁发之后的状态。验证时,检查对比这一最终状态与头部中保存的状态树即可。

工作量的采矿证明

_“区块”_那一小节简要地解释了区块难度的概念。区块难度的实质是工作量证明算法(PoW)。

以太坊的工作量证明算法叫做“Ethash”(以前叫做 Dagger-Hashimoto 算法)。

形式化定义该算法为:

image

这里 m 表示 mixHashn 表示 nonceHn 表示新区块头部(除去必须要算出来的 nonce mixHash 字段),Hn 表示区块头部的 nonce,以及 d 表示 DAG(一个大型数据集)。

在“区块”一节中,我们讨论了区块头部中包含的各个字段。其中有两个字段分别为 mixHashnonce。就如你可能回想起的那样:

  • mixHash 是一个哈希值,和 nonce 一起使用可以证明在这个区块上已经执行了足够多的计算
  • nonce 是一个哈希值,和 mixHash 一起使用可以证明在这个区块上已经执行了足够多的计算

PoW 函数用于计算这两个字段。

通过 PoW 函数准确计算出 mixHashnonce 的过程有些复杂,深入解释的话足够我们开一篇新帖子了。但是如果站在一个较高的层面来看,它的方式差不多是这样的:

对于每个区块都会计算出一个“种子”。不同纪元(每个“纪元”跨度为 30,000 个区块)的种子值都不一样。对于第一个纪元,种子值为哈希计算一个长为 32 个字节且值均为零的字节串的结果。而后续每个纪元的种子都是从前一个纪元的种子哈希计算后得来。通过这类种子节点能计算出一个伪随机的“缓存”。

这个缓存对我们构建“轻量级节点”(在本帖前面有讨论过)非常有帮助。轻量级节点不用耗费资源来存储整个区块链数据集,就能有效地验证交易。因为通过这一缓存可以重新生成需要验证的那个区块,所以仅仅依靠这一缓存就足够了。

节点通过这一缓存可以生成 DAG“数据集”,在该数据集中,每个项目仅依赖于从这一缓存中伪随机选择的少量项目。要想搭建矿机,你必须生成这一完整的数据集;所有完整客户端和矿机都要存储这个完整的数据集,并且数据集规模会随着时间线性增长。

随后矿机可以从该数据集中随机选取一部分,并通过一个数学函数将它们一起进行哈希计算,得出“mixHash”这一值。矿机将会重复这一生成 mixHash 的过程,直到其小于目标值 nonce。当输出值满足要求时,该 nonce 值就是有效的,就可以将这个区块添加到链上。

挖矿是一种安全机制

整体上来说,PoW 的目标是,通过一种密码学上安全的方式,来证明矿机为得到一些输出内容(即 nonce),已经花费了某一数量的算力。这是因为除了枚举所有的可能情况外,没有更好的方法来寻找低于目标门限值的 nonce。重复应用哈希函数所获得的输出服从一个均匀分布,所以我们得以保证,在平均情况下寻找这样一个 nonce 所需的时间依赖于难度门槛值。难度越高,找到这种 nonce 就要越久。这样,PoW 算法使得难度这一概念有了实际意义,因而就能用来增强区块链的安全性。

我们所说的区块链安全性是什么意思?很简单:我们希望创建一条人人都信任的区块链。就如我们先前在帖子里提及地,如果存在有多条链,那么将失去用户的信任,因为他们没法合理地判定哪条链才是“有效”的。为让用户群体接受在链上保存的状态,我们需要且只要一个大家都信任的官方区块链。

这就是 PoW 算法的作用:它保证了不管在什么时候某条区块链一直是权威的,这样攻击者就很难创建新的区块去覆盖某部分历史记录(比如通过删除交易或者创建虚假交易)或者维持一条分叉。攻击者为抢先验证自己的区块,他们必须总是比网络中其他所有节点更快地找到 nonce,使得网络上所有节点都相信他们那条链是占最大权重的(基于我们先前提及的 GHOST 协议中的原理)。这是不可能的,除非攻击者占据了一半多的全网挖矿算力,这种场景被称为 51% 攻击

image

挖矿是一种财富分配机制

PoW 算法不仅为区块链提供了安全保障,还是一种财富分配方式,它将财富分配给了那些付出了自己的算力来保护区块链的人们。回忆下,矿机开采出区块时会获得如下奖励:

  • 当开采到“获胜”区块时(成功使区块连上主链),有 5 Ether 的_固定奖励_(很快就要改为 3 Ether 了)
  • 包含在该区块中的所有交易所消耗的 gas 费用
  • 让该区块引用叔块会获得额外的奖励

为确保用于保障安全和分配财富的 PoW 共识机制能长期坚持实行下去,以太坊努力发扬这两点:

  • 让尽可能多的人能够使用它。换而言之,人们不需要用特殊或者不常见的硬件来执行这个算法。其目的是让财富分配模型尽可能地开放,使得每个人在获得 Ether 的同时能提供些算力作为报答,不管是多少算力。
  • 使某单一节点(或者一小撮节点)难以获取不合理的利润份额。如果有节点能获取不合理的利润份额,就意味着在确定权威的区块链时,该节点有巨大的影响力。这令人讨厌,因为它破坏了网络的安全。

在比特币区块链网络中出现的一个与上面两个特性有关的问题是,其 PoW 算法是一个 SHA256 哈希函数。这种类型的函数其弱点是,通过特殊硬件(也称为 ASIC),相比于其他普通硬件,能更有效地计算出其函数值。

为了缓解这一问题,以太坊选择使其 PoW 算法(Ethhash)变的 sequentially memory-hard。这就是说这个算法经过了调整,使得在计算 nonce 时需要大量的内存和带宽。由于需要大内存,所以计算机就很难并行地使用内存去同时找出多个 nonce,同时由于还需要大带宽,就使得甚至是对于一台超快的计算机,都很难同时找出多个 nonce。这就减少了中心化的风险,同时为那些做验证工作的节点创建了一个更加公平公正的竞争环境。

有一点需要提醒一下,以太坊正在从 PoW 共识机制迁移到“权益证明 (proof-of-stake)”共识机制上。这个话题本身很麻烦了,希望我们能在将来某个帖子中探讨这个话题。☺️

总结

……呼!你把这篇帖子看完了。我想应该是的吧?

我知道,这篇帖子里有很多需要消化一下的内容。如果你为了能完全理解这里的内容,反复读了好几遍,那也没什么关系。在能够深刻理解这些东西前,我自己就读了好几次以太坊黄皮书、白皮书还有官方代码库的许多部分。

然而,我还是希望这篇概述能帮到你。如果你找到了任何不对的地方,我希望你能写到自己的笔记上或者直接在评论中发出来。所有发上来的评论我都会看的,我保证 😉

要记得,我是人类(对,你没看错),所以我也会犯错。我花时间写这篇帖子是因为这对社区有帮助,这是免费的。所以拜托在反馈时提些有用的建议,而不是在那里抬杠乱喷。

想发些评论?点这里,然后拉到页面底部就好。

posted @ 2021-12-26 12:33  PoolMan  阅读(914)  评论(4编辑  收藏  举报