智能合约的理想环境思考

编者按:本文来自“小蚁区块链”,作者 张铮文、达鸿飞;
1、智能合约与区块链

自从比特币、以太坊的相继诞生,以及区块链技术的逐步升温,智能合约一词便开始频繁的出现在金融和科技媒体之中。智能合约是1994年由密码学家尼克萨博(Nick Szabo)最先提出的理念,几乎与互联网同龄。根据Nick Szabo的定义:智能合约是指能够自动执行合约条款的计算机程序。

传统意义上的合约的生命周期一般包含:各方协商、签名记录、条款执行三个阶段,智能合约也类似。但是在区块链技术出现之前,单台计算机难以提供安全可靠的签名记录和条款执行服务,所以智能合约也一直仅仅停留在理论阶段。比特币及区块链技术出现后,其多方存储、多方计算、规则透明、不可篡改等特性,恰好为智能合约提供了安全可靠的记录载体和执行环境。

与普通的计算机程序不同,区块链上的智能合约必须同时具备两种性质:确定性和可终止性。

区块链是一个通过多方存储、多方计算的方式来实现数据不可篡改、计算结果可信的分布式系统。智能合约会在区块链网络的多个节点中运行。如果一个智能合约是非确定性的,那么不同节点运行的结果就可能不一致,从而导致共识无法达成,网络陷入停滞;如果智能合约是永不停止的,那么节点将耗费无穷多的时间和资源去运行合约,同样导致网络进入停滞状态。这就涉及到了两个有趣的话题,下面我们分别来探讨一下确定性和可终止性问题(停机问题)。

2、智能合约的确定性

如果一个程序在不同的计算机、或者在同一台计算机上的不同时刻多次运行,对于相同的输入能够保证产生相同的输出,则称该程序的行为是确定性的,反之则称该程序的行为是非确定性的。使程序产生非确定性的因素有很多,总结起来有以下几种:

1) 调用了非确定性的系统函数

一般在编写程序的时候,开发者或多或少会调用一些系统提供的函数和功能以减少开发的工作量。这些系统函数中可能会存在一些非确定性的函数,比如生成随机数、获取系统时间等。一旦程序调用了另一个非确定性的程序并使用了它们输出的内容,那么该程序自身的行为也可能会变为非确定性的。

2) 使用了非确定性的数据来源

如果一个程序在运行时获取数据,而数据源提供的是非确定性的数据,那么该程序也可能会变成非确定性的程序。例如,通过搜索引擎来获取某个关键词的前10条搜索结果——搜索引擎针对不同的IP地址来源可能会返回不同的排序结果。

3) 动态调用

动态调用是指,一个程序在调用另一个程序时,如果必须在运行时才能确定被调用的目标,则称该调用为动态调用;反之,如果在运行前即可确定被调用的目标,且在运行时无法变更该目标,则称该调用为静态调用。由于动态调用的目标在运行时决定,因此其行为是非确定的。

对于区块链上的智能合约,我们一般要求它的行为必须是确定性的,因为非确定性的合约可能会破坏系统的一致性。区块链的作者必须考虑到这个问题,并在设计智能合约系统的时候,就想办法把非确定性因素排除在外。那么我们来看看现有的各个区块链是如何解决这个问题的。

1) 比特币

比特币内置了一套脚本引擎,用于执行鉴权脚本,它是区块链智能合约的雏形。开发者可以基于这套脚本系统来开发一些简单的应用,但由于其指令集非常简单且非图灵完备,能够实现功能相当有限。这套系统既没有提供任何系统函数,也没有提供任何访问数据的能力,更没有动态调用的功能,甚至连静态调用也没有提供,因此比特币的智能合约一定是确定性的。

2) 以太坊

以太坊的主要设计思想,就是提供一个图灵完备的智能合约平台,让用户可以编写任意逻辑的程序。它专门开发了一个用于执行合约代码的虚拟机EVM,并设计了一种类似于JavaScript的高级语言Solidity,以方便用户进行开发。以太坊智能合约没有提供任何非确定性的系统函数,可访问的数据也仅限于链内数据,外部数据需要通过交易来发送到合约。但是,以太坊的CALL和CALLCODE指令的目标地址通过栈来传递,使得合约可以在运行时动态调用其它的合约代码,使合约的调用路径变为非确定性。好在合约可以访问到的数据都是确定性的,使得所有节点在动态调用目标代码时一定会获得相同的目标地址,保证了系统的一致性。但是调用路径的非确定性,会导致一个可扩展性上的重要性能损失

3) Fabric

Fabric是超级账本中的一个子项目,它的智能合约采用了重量级的Docker作为执行环境。这可能跟大家的印象有点矛盾——“Docker不是一直被认为是一种轻量级的容器技术吗?”。实际上,Docker的“轻”是相对于模拟物理机架构的重量级虚拟化技术而言。在区块链应用场景下,Docker是一个比较“重”的执行环境,这也是Fabric的性能瓶颈所在,目前只能达到每秒几百TPS。由于Docker的特性,智能合约几乎可以使用物理计算机上的所有功能,因此具有极高的非确定性。所以Fabric要求智能合约的开发者在编写代码的时候尽量避免使用到具有非确定性的功能,并计划提供一套专门开发的确定性系统函数库供开发者使用。然而,由于无法从底层机制上避免非确定性的产生,寄托于开发者遵守良好的开发规范难免有些一厢情愿。非确定性就像幽灵一般,平时似乎并不存在,在一些边缘案例(corner case)上就可能会突然冒出来造成难以判断的故障。

3、停机问题与资源控制

停机问题(halting problem)是逻辑数学中可计算性理论的一个问题。通俗地说,停机问题就是判断任意程序是否能在有限的时间之内结束运行的问题。该问题等价于如下的判定问题:是否存在一个程序P,对于任意输入的程序w,能够判断w会在有限时间内结束或者死循环。

艾伦·图灵在1936年用对角论证法证明了我们无法编写出程序P——即不存在解决停机问题的通用算法。这个证明的关键在于对计算机和程序的数学定义,这被称为图灵机。停机问题在图灵机上是不可判定问题。这是最早提出的决定性问题之一。

区块链上的智能合约必须是可终止的,否则将会消耗无限的时间和资源。从上面的论证已经可以看出,停机问题是不可解的,我们无法在不运行一个程序的情况下,提前判定该程序是否会停机。因此,区块链的设计者不得不假设所有的智能合约都可能会进入死循环,并对于可能已经进入死循环的合约采用异常终止的方式来结束它。通常会有以下几种策略:

1) 非图灵完备

如果一个区块链的智能合约系统,只提供了有限的指令集,而不提供诸如跳转、循环等指令,以及可以等效实现类似功能的指令,那么基于这个系统的智能合约就不可能进入死循环,因此它们总是可停机的,比特币就是其中的一个例子。值得一提的是,比特币改进计划BIP12的提案中,有过在比特币指令集中增加一条OP_EVAL指令的计划,这条指令可以加载计算栈中的脚本并动态执行,从而解决多重签名算法的问题。但由于该指令会间接地使得比特币的脚本系统变为图灵完备,因此最终被废弃了。

2) 计价器

既然无法在智能合约运行之前就判定其是否会停机,那么也可以在合约运行中进行计步——每执行一条指令就将计步器加一。当计步器的数值超过一定的限制之后,就认为合约已经进入死循环,从而强行终止它的运行。这种方法需要区块链对智能合约的执行有较大的控制能力,能够精确的计算出合约执行的步数且在各个节点间保持一致。

计价器的方案和计步器基本上是一致的,但是它采用经济手段来解决停机问题:对合约执行的每一个指令进行收费,当合约的手续费全部用完之后,如果合约还没有终止,那么就强行终止退出。以太坊采用的就是这个模式,它通过消耗一种被称为燃料的代币来对智能合约进行计价。一旦燃料耗尽,合约就会执行失败,并且不会退回消耗掉的费用。

3) 计时器

计时器和计价器的方案比较类似,但它使用时间作为标准来衡量一个合约是否已经进入死循环:如果合约在超时时间到达之前还没有正常终止,那么就认为它已进入死循环并强行终止它。Fabric采用了计时器的方案,原因是Fabric使用Docker作为其智能合约的执行环境,而Docker中运行的程序是无法计步或计价的。虽然计时器可以部分地解决停机问题,但是在分布式系统中,每个节点的执行时长未必能够保证一致,且每个节点的性能和负载都不一样,导致对合约运行是否超时这一判断会出现不一致的情况,从而使得共识算法的失败率大大的增加,这是计时器方案的重要缺点。

上述的非图灵完备、计价器、计时器方案,实际上都是一种资源控制手段,即通过对代码量设定上限,对运算资源计价,或对执行时间设定上限等方法来将智能合约占用的资源控制在合理的范围。

4、资源隔离

虚拟化的执行环境除了实现资源控制外这一目的外,更重要的是通过资源隔离来保障系统的安全。在开放的区块链上,任何参与者都可以编写并上传智能合约,图灵完备的智能合约就意味着可以编写并执行任意的逻辑——包括病毒或故障合约如果智能合约直接在区块链节点的宿主系统上运行,病毒就能够自我复制,故障合约就可能破坏宿主系统的自身数据。因此智能合约必须放在一个隔离的沙盒环境中运行——虚拟机或者容器。

通过沙盒执行环境,合约和合约之间,合约和宿主系统之间进行了有效的资源隔离,也就控制住了恶意或故障合约的影响范围。但在资源隔离度上,Docker所依赖的基于命名空间的隔离要弱于虚拟机的隔离。正因为此,在公有云上两个不同用户的Docker镜像一般不会被放在一个宿主操作系统中运行。因此,基于Docker的方案在资源隔离度的安全性上要弱于虚拟机方案。

5、执行环境的性能

智能合约的执行环境会对合约的性能起到非常重要的作用。目前主流的区块链架构对智能合约执行环境的设计主要分为两种:虚拟机和容器(Docker)。无论是虚拟机还是容器,它们的作用都是在一个沙盒中执行合约代码,并对合约所使用的资源进行隔离和限制。

1) 虚拟机

虚拟机通常是指能够像真实机器一样执行程序的计算机的软件实现。有些虚拟机会模拟出一个完整的物理计算机,比如VMware、Hyper-V等,可以在这些虚拟机上安装操作系统和应用程序;另一些虚拟机则只提供了硬件的抽象层,而与具体的底层硬件无关,例如Java虚拟机。

区块链智能合约系统的设计中,很少会采用模拟完整物理计算机的模式,因为这种方式会消耗大量的资源并严重影响性能,且很难兼容不同的硬件架构。所以绝大多数的区块链会采用更加轻量级的虚拟机架构,例如以太坊开发了EVM,R3 Corda则直接采用了JVM,还有一些区块链采用了V8引擎——Google的JavaScript引擎(虚拟机)。

当我们分析执行环境的性能时,有两个指标是非常关键的:(1)指令的执行速度(2)执行环境本身的启动速度。对于智能合约而言,执行环境的启动速度往往要比指令的执行速度更为重要。智能合约中较多是一些甚少涉及IO操作的逻辑判断指令,这些指令的执行速度很容易得到优化。上一篇中,我们提到了出于安全性考虑,智能合约必须在相互隔离的沙盒执行环境中运行。每个智能合约每次被调用,都必须启动一个新的虚拟机/容器。因此执行环境本身的启动速度(启动一个虚拟机/容器)对智能合约系统的性能影响更大。

上述的EVM、JVM、V8引擎这些轻量级的虚拟机架构对智能合约的性能提升有显著的优势。它们的启动速度非常快,占用资源也很小,适合像智能合约这样短小的程序。缺点是,这类虚拟机的执行效率会相对略低,好在智能合约一般都比较短小,会更加注重环境加载的速度而非代码执行的速度。另外,通过JIT(即时编译器)技术对热点智能合约进行静态编译和缓存可以显著提升虚拟机的执行效率。

2) 容器(Docker)

与其它主流区块链设计不同的是,超级帐本中的子项目Fabric独树一帜地采用Docker作为其智能合约的执行环境。与虚拟机的作用一样,Docker也进行了资源的隔离,但不如虚拟机那么隔离充分。Docker本身没有采用虚拟化技术,而是让程序直接运行在底层操作系统上,因此代码执行的效率很高。但由于其相对于轻量级虚拟机而言过于庞大的体型,部署和启动Docker本身需要消耗大量的时间和资源。当使用在智能合约系统时,Docker的启动时间成为了制约整体效率的瓶颈。Fabric在性能测试时,即便用上了IBM的大型机LinuxONE这样的强悍硬件,性能依然不高。

执行环境的代码执行速度就好比汽车的最高时速,而执行环境的启动速度则好比汽车的0-100km/h加速度。和一般程序相比,智能合约短小精悍,总是处在“启动—停止—启动—停止”的状态,很少能够跑到极速。执行环境的启动速度才是影响智能合约性能的关键因素。

6、并发、分片与无限扩展

当谈及一个系统的扩展性时,总会涉及到两个词Scale Up(垂直扩展)和Scale Out(水平扩展)。最典型的垂直扩展案例是单核时代的CPU——主要靠提高主频达到性能的提升。垂直扩展很容易就碰上天花板,当CPU制程工艺的提升越来越困难后,通过多核实现水平扩展,对指令进行并行处理,成为了提升CPU性能的重要手段。

正因为垂直扩展会很快触及造价、技术的极限,一个不可拆分业务的串行系统的扩展性(或曰性能提升能力)就会很弱——它取决于单台设备的最大处理能力。当我们需要对系统进行扩展时,如果有办法将串行系统改造成并行系统,那么理论上我们将可以获得近乎无限的扩展性。我们在考虑对区块链系统进行扩展时,是否有无限扩展的可能?换言之,区块链能否并行地对业务进行处理?

区块链是一个分布式的大账本,里面记录了各式各样的状态数据,同时也记录了这些状态如何变化的规则,智能合约正是用来记录这些规则的载体。区块链能否并行地对业务进行处理,就取决于多个智能合约能否并发执行——即合约的执行是否是顺序无关的。

举个例子,某账户中有10元的余额,现有两个合约对该账户进行修改,第一个合约在账户中增加5元,第二个合约在账户中扣除11元。如果先执行前者,则最终账户的余额为4元;如果先执行后者,由于余额不足,第二个合约将会执行失败,而第一笔合约会执行成功,最终账户余额为15元。像这样的两个合约由于执行的顺序不同而导致不同的结果,那么它们是不可以并发执行的,只能串行处理。

反过来,如果两个合约分别对两个不同的账户进行修改,那么它们无论哪一个先执行,结果都不会不同,所以它们是可以并发执行的。

从上面的例子可以看出,两个合约是否可以并行处理,取决于这两个合约是否是顺序无关的;而是否顺序无关,则取决于他们是否能够对同一条状态记录进行修改。

基于上面的分析,我们可以很容易设计出一个具备“无限扩展”能力的智能合约系统。只需要简单地规定:(1)一个智能合约只能修改属于该合约自己的状态记录;(2)同一个事务批次(区块)中,一个合约只能被运行一次。这样一来,所有的智能合约之间都是顺序无关可以平行处理了。干的漂亮!

但是,等等……如果“一个智能合约只能修改属于该合约自己的状态记录”,就意味着合约间无法相互调用,每个合约都是一个孤岛;如果“一个区块中,一个合约只能被运行一次”,就意味着用智能合约发行的某种数字资产在一个区块里只能处理一笔交易。这显然和“智能”二字的设计初衷大相径庭。毕竟合约间的相互调用,同一区块中多次调用同一个合约,都是我们想要的设计目标。

这样一来情况就变得复杂多了,特别是像以太坊这种支持动态调用(通过CALL指令)的智能合约系统,不可能在运行前就判断出合约的行为和调用路径,也就无法判断合约会修改哪些状态记录。因此,以太坊的扩展性一直是其设计上的一大弊病,其目前的架构设计难以支撑以太坊成为“全球计算平台”的远大愿景。为了解决扩展性问题,以太坊提出了分片(Sharding)方案:

打个比方,分片就类似于户籍制度。计算一个合约的散列值再对256取模,就可以把合约分配到256个片区中去,这相当于给每个合约分配了一个该片区的“户口”。江苏户口的合约只能调用江苏的合约,上海户口的合约就只能调用上海的,不能直接彼此调用。

这样一来,江苏、上海等256个片区的合约就可以按片区进行并行处理了,看起来执行效率可以得到256倍的提升。但是在这种设计下,想要跨片区调用,就必须向一个全局账本(区块链)写入调用请求,另一片区的合约收到请求后再执行操作,并再次写入全局账本来返回调用结果。这导致了跨片区调用无法在同一个业务批次(区块)中完成,效率显著降低。在真实的应用场景中,分片的结果很可能是大家都挤到一个“繁华片区”中去,因为这样才能最高效的进行相互调用,避免跨区操作。在城市郊区修建再多的干道,也无法解决市中心的拥堵问题。

另外,智能合约代码的加载方式也会影响到扩展性。目前主流的区块链智能合约系统都会要求将智能合约代码发布到链上,然后再从链上加载代码执行。有些合约代码可能只被使用一次就废弃了,但在区块链中永久性地存在,占用节点的存储资源,久而久之这些废弃代码会成为区块链的巨大负担,影响扩展性。

另一种方案,是将智能合约的散列值记录在链上,用IPFS等以散列值为索引的新型分布式存储网络来存储完整合约代码。在执行合约的时候,再从链外加载代码。由于合约的散列值已经在链上记录,即使从链外加载代码也不用担心合约的内容被篡改,这样可以为节点节省大量的存储空间。同时也能对智能合约的内容进行一定程度的隐私保护。

7、耦合度

耦合是指两个或两个以上的实体相互依赖于对方的一个量度。在区块链与智能合约系统的设计中,对于耦合度的控制有两个非常极端的例子:

1) 以太坊

以太坊在智能合约系统的设计中是高耦合的典型,区块链与EVM之间到处充斥着相互依赖的关系,例如:

  • 费用的计算混杂在虚拟机的实现逻辑中;

  • 虚拟机指令集中包含大量用于访问账本数据的指令;

  • 虚拟机直接提供以区块链账本作为载体的持久化存储指令。

将区块链的业务逻辑与虚拟机混在一起,并不是一个良好的设计。这会造成一系列的问题,一旦区块链的功能需要改进或者升级,势必就要对EVM也进行相应的修改,这种修改多数情况都会体现在增加新的指令上;而EVM几乎没有办法移植到其它区块链系统中,除非另一个链的底层架构与以太坊高度一致,或者专门针对EVM开发一个对接层。这种模式会对以太坊的生态应用造成很大的局限性,关于这一点我们将会在《重构智能合约(下):兼容性与生态》中详述。

2) Fabric

与以太坊的设计模式相反,Fabric的智能合约系统采用了低耦合的设计,区块链账本与Docker之间几乎没有任何依赖关系,因为Docker本身就被广泛应用于区块链以外的大量场景之中。在Docker中运行的智能合约程序只能通过gRPC协议与节点进行通信,协议中包含了访问账本和持久化存储的功能。当区块链的功能需要改进或者升级时,只需要对gRPC协议进行改动即可。这种超低耦合度的设计模式值得其它区块链的开发者学习参考。

高内聚、低耦合是设计系统架构时所常常追求的目标。Fabric的设计目标是打造通用的许可型区块链的技术框架,因此一开始就采用了高度模块化的设计思想;而以太坊最初的设计目标是一个具体的公有链实例,而非技术框架。因此以太坊中存在着系统耦合度过高的问题。这将会妨碍以太坊作为一种通用技术被使用在联盟链、私有链上。

8、小结

在本篇中,我们分析了智能合约系统理想的执行环境性能、并发处理能力、耦合度和代码加载方式,发现了以太坊的一些高度抽象,无灰度的设计带来的扩展性问题,提出了可以做到理论上“无限扩展”的高度并行化智能合约系统的设计思路。我们认为一个可以良好进行并发执行的智能合约系统,应该具有以下特征:

  • 轻量级的执行环境:快速的启动时间和较高的执行效率。

  • 可插拔的执行环境架构:默认的执行环境应该不提供持久化存储,从而让合约默认是一种类似于微服务的无状态函数,从而可以直接并发处理。仅在需要存储状态时,才提供可插拔的持久化存储模块。这样的虚拟机默认只有一个CPU和栈,仅在需要时才提供“硬盘”和其他IO设备。

  • 明示化的调用关系:即只提供静态调用的功能,从而使得程序的调用关系可以在运行它之前就整理清楚。一旦调用路径明确了,那么合约可能会修改到的状态数据也就明确了,依据这些明示的调用路径就可以进行即时的动态分片提高合约执行的并行能力。

  • 可链外存储的合约代码:通过链上存储散列值,链外存储合约代码实现存储空间的扩展性。

  • 低耦合度的设计:合约语言、执行环境、区块链之间的低耦合度,提高智能合约系统的通用性。


猜你喜欢

转载自blog.csdn.net/sinat_34070003/article/details/80075968