10

以太坊2.0的致命缺陷

 4 years ago
source link: https://news.huoxing24.com/20200605234008934355.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

A7biiem.jpg!web

原创 北美区块君

来源:区块链中那些事儿 

继比特币减半以后,币圈又有一个大事件在酝酿,那就是以太坊要升级了。在开始聊这个以太坊2.0之前,我先来问一个问题。

假如一家银行一共有10000元的储备金,在A和B城市分别有一个独立的ATM机。一个人在A城市用ATM取5000元,但同时另外一个人在B城市也取5000元。请问现在银行里还剩下多少钱?

这是一道很简单的数学题。我相信所有人都能给出正确的答案。银行总共就10000块钱,A和B分别取了5000元,所以加起来一共是取走了1万元整。10000-10000=0。所以银行还剩下0元。

这道题对我们来说很简单,但是对计算机来说却没有那么容易。比如我们要编写一个应用程序来实现上述功能。它的难点就在于,我们如何保证用户在提款过程中,银行的数据是实时同步的。因为如果不同步的话,那当B在操作ATM的时候,他读取到的储备金额可能还是10000,而没有扣掉A提走的5000。这样一来显然就乱套了!我用一段Java程序来模拟一下,你就明白了。

我们先定义一个简单的类,名字就叫做Bank。其中自定义变量只有一个就是balance,用来指代账户的余额。操作函数只有两个,分别是提款withdraw,和查询余额getBalance。

public class Bank{   //银行余额   private int balance;
   public Bank(int balance){       this.balance = balance;   }
   //用户提款   public void withdraw (int value)  {       try {           Thread.sleep(300); //0.3秒的模拟时延       } catch (InterruptedException e) {           e.printStackTrace();       }       this.balance -= value;   }
   //查询当前余额   public int getBalance(){       return this.balance;   }
}

接下来就是用来演示的主程序:

public class Demo {
   public static void main(String args[]) throws InterruptedException {       Bank bank = new Bank(10000); //银行的初始余额       Runnable Atm1 = () -> {           bank.withdraw(5000);           System.out.println("A 提款 5000");       };
       Runnable Atm2 = () -> {           bank.withdraw(5000);           System.out.println("B 提款 5000");       };
       Thread A = new Thread(Atm1); //提款人A的操作线程       Thread B = new Thread(Atm2);//提款人B的操作线程       A.start();//A开始提款       B.start();//B开始提款       A.join();//等待A操作结束       B.join();//等待B操作结束
       //显示余额       System.out.println("银行余额:"+bank.getBalance());   }}

在这段程序里,我们设定了初始余额是10000。然后我们模拟了A和B两个操作线程。两者几乎在同一时间进行提款操作,都取了5000块。我们来看看该程序的运行结果:

MjuENrz.jpg!web

但问题是B提了5000以后,银行已没有任何结余,所以应该显示0。但这里仍是5000。这就是问题所在!有趣的是,你如果重复运行这个程序,会发现每一次的结果可能是不一样的。有时候显示0,有时候显示5000。这种现象在计算机里有个典型的名词叫 竞态条件(race condition) 。指的就是有多个计算机线程在争夺同一个资源,造成数据更新的紊乱。像我们这个情况,A和B通过不同的ATM,开启了两个提款操作线程。这两个线程都要对银行余额进行修改。这种情况下,B相当于抢夺了A的数据更改权,导致刚更新的数据立马被B覆盖掉了。

那为什么会发生这种情况呢?这是由于计算机CPU的特殊架构所决定的。计算机的任何一条指令都需要知道它的操作对象是谁,值是多少,否则这条指令就没有意义。那这个操作对象去哪里找?CPU里有一个叫做寄存器的元件专门负责存储这个信息。任何一条指令都需要访问这个寄存器,获取它操作对象的值,指令才能完整地执行。比如下图中的AX就是CPU的一个寄存器。里面可存一个16位的二进制数。

YvMBBvy.jpg!web

就拿我们这个例子来说,withdraw就是提款的指令,而它的操作对象就是银行余额balance。那这个balance的值是多少呢?这就要到寄存器里去找。

获取了这个值以后,指令就开始执行了。在此过程中,它会对寄存器的内容进行修改,完成数据的更新。所以我们的银行余额就是这样被更新的。这时候如果有新的指令进来获取当前的余额,我们再回到那个寄存器里找答案就行了。

但问题是我们电脑在单位时间内不单单执行一条指令。在很多情况下,是多条指令同时运行的。不然你怎么可以做到边听音乐边上网呢?所以为了实现“并行操作”,我们的CPU引入了多线程管理的机制。就是把这些指令封装在不同的线程里,通过合理地调度,来并行地运作多个程序。就比如我们可以用一个线程来执行提款(withdraw)的指令,同时可以再分配一个线程来查询当前的余额(getBalance),如下图所示:

6VJfai6.jpg!web

查询操作并不影响寄存器的状态,所以两条线程可以相安无事,但如果此时再引入第三个线程也来进行提款操作,那事情就会变得很棘手了。因为它可能会和线程1抢夺同一个寄存器的资源。如下图所示,线程1和3在同一时刻对寄存器的状态进行更新,那很有可能当线程3执行的时候,线程1还没有来得及更新balance的值,所以它读到的值还是更新之前的,即balance=10000。而当线程1运行完以后,虽然将balance更新至5000,但这已于事无补,因为线程3已经在操作了。所以线程3对寄存器的更新还是基于原来的旧值:10000,导致最终的余额仍旧是5000。 (10000-5000=5000)

VBJbyyi.jpg!web

所以为了避免这种情况,我们必须要保证这个寄存器的状态在多线程运行中是同步的。虽然它们都是共享同一块数据资源,但是必须要有一个先来后到。就拿上述情况来说,我们必须要保证线程1操作寄存器的时候,其他线程无法访问。只有当线程1结束以后,线程3才可以进行操作。这样一来,每条线程所读取的寄存器数据就是同步的。

为了实现多线程之间的同步,我们的CPU引入了一个“保护锁”(lock)的机制。就是针对这种共享的寄存器资源,标记一个“锁存”状态。任何一个线程在访问寄存器的时候,都可以给它“上锁”。这样一来其他线程就无法访问,只能乖乖地等待。只有当前线程执行完毕以后,这个“保护锁”才会被释放。然后其余的线程就会被自动唤醒,开始访问这个寄存器资源。像我们这个例子就可以让线程1在访问寄存器的时候“上锁”,那线程3就会被迫等待。等线程1执行完毕以后,balance就会更新为10000-5000=5000。然后寄存器释放保护锁,线程3被唤醒,开始访问balance这个变量。类似地,它再套上一个保护锁。这时候它获取的数值就是5000。等它执行完毕以后,balance就会更新至5000-5000=0。如下所示:

3ayyEr2.jpg!web

对应的,我们的Java源码只要做如下修改,就能实现这套“保护锁”的机制:

public class Bank{   //银行余额  private int balance;  private final ReentrantLock lock = new ReentrantLock();  …….   //用户提款   public void withdraw (int value)  {       lock.lock(); //加上保护锁       try {           Thread.sleep(300); //0.3秒的模拟时延       } catch (InterruptedException e) {           e.printStackTrace();       }       this.balance -= value;       lock.unlock(); //释放保护锁   }}

运行结果如下:

VzqEZz6.jpg!web

根据这次的运行结果,你可以看到我们的银行余额在A和B两次提款之后,已经正确地更新至0。

所以我们可以看到,虽然每条线程都是独立的,但是整个线程的调度是中心化的。CPU就好比是一个大脑。它得给不同的线程进行合理的资源分配,安排执行的先后顺序,这样才能保证数据的同步。所以这个大脑必须得知道哪些寄存器上了锁,哪些线程在进行访问,哪些线程在等待。也就说它具备一个“上帝视角”可以实时监测每一条线程,以及每一个寄存器的状态。

单个电脑的程序运行是这样的,但如果我们往大了说,多台电脑的节点部署也是这样的。就拿淘宝网站来说,它在双11那天得处理几千万条交易请求,所以一个服务器肯定是不够的。它肯定得部署多个服务器节点,然后通过负载均衡器(Load Balancer),把这些请求均匀地分配至每个服务器上。如下图所示:

UvimYvf.jpg!web

但无论有多少个服务器节点,无论有多大数量的请求,最终它们访问的是同一个数据库!这点非常重要。因为只有这样,你才可以引入“保护锁”的机制,在交易的过程中给对应数据库表单加锁,保证读写的同步。比如说现在一个天猫店有10个香奈儿的包,打8折,100个人抢购,当第一个人下单了已经开始交易了,那数据库就必须要把其他的购买请求放在等待队列里。这样才能保证下一个人看到的是9个包而不是开始的10个。

所以淘宝网的节点部署,虽是分布式架构,但是本质上是中心化的。这种中心化体现在节点线程的监控与调度,以及数据库的解决方案上。也就是说淘宝服务器的背后有一个控制中心,它可以实时地检测每个节点的状态,并且这些节点访问的是同一个数据库。正是这样的中心化架构,才可以让那么多节点并行处理这么多请求,同时还能保证数据的同步。

但是公有链的分布式架构就完全不同了,因为它本质上是去中心化的,每个节点各自为战。所以它没有一个控制面板,掌控所有节点的状态。其次它没有一个中心化的数据库让这些节点去访问,而是每个节点都独立配置一个自己的数据库。所以就会有多个账本同时存在,我们只能引入投票机制来确定一个最终账本,间接地实现节点间的同步。比特币是通过算力来投票,选出最长的那条区块链作为最终账本。虽然不同的节点会产生多个区块链账本,引发拜占庭将军问题,但比特币的算法是行得通的。因为它是单链结构,并且在单位时间内只能产生一个区块。虽然同时间可以有不同的节点播报区块,但是比特币的挖矿机制保证了这个区块的唯一性。所以比特币本质上是一个 单线程 的数据库读写操作。

以太坊本来也没有问题,因为它和比特币一样也是单链结构,使用POW共识。但是升级到2.0以后问题就很大了。因为以太坊2.0它引入一个叫“分片(sharding)”的机制。简单的来说就是借鉴淘宝网的这种负载均衡器(Load Balancer)的机制——设置多个节点,批量处理不同的请求。比如说现在有10000个交易请求,我让A节点处理5000个,B节点处理余下的5000个,那这样一来速度不就快了嘛。我承认这个初衷是好的,但是实际上是行不通的。根据以太坊2.0的介绍,它首先引入了一个主链叫(Beacon),这个主链负责记录所有交易的状态,相当于账本的核心。然后它把整个节点网络划分成不同区域,每个区域作为一个分片,相当于Load Balancer。每个分片都处理不同的交易请求,最终分别记录在主链上。如下图所示:

ZVZVRvR.jpg!web

说到这里你可能会有点迷,但是我换一种方式来解释你就明白了。只要看了我前面的介绍,你就应该会对多线程同步有一个简单的认识。以太坊2.0也是类似的架构,你可以把Beacon链理解为中央数据库,每一个分片相当于一个独立的线程。每个线程播报的区块都是不一样的,比如说分片1的区块所包含的交易序列是1到3000,那分片2就是3000到6000。所以以太坊2.0相当于一个 多线程 的数据库读写操作。这是和比特币本质上的不同。

如果多个线程对同一个数据库进行操作,容易出现数据不同步的问题,所以正确的做法就是在每个线程执行的过程中,给这个数据库加上一个保护锁,从而避免其他线程同时访问。所以对于我们这个情况也是一样的,就是当每个分片对Beacon链进行更新的时候,必须要给这条主链加上一个“保护锁”,从而迫使其他的分片进入等待队列。V神确实也考虑到了这点,准备引入这个“保护锁”的机制。但错就错在,这个Beacon链 不是唯一的中央数据库

我们要知道以太坊是公链,公链是去中心化的!所以每一个挖矿节点都有自己的一条Beacon链。所以这里的“上锁”,是加在自己那条Beacon链上的锁。这个锁存的状态显然没有和其他节点同步,所以其余的分片节点仍旧会继续访问主链。这个时候,不同的分片之间就会产生我之前说的竞争状态(race condition)。分片1的更新有可能就会被分片2给覆盖掉。如下图所示:

7VNrQvM.jpg!web

由此可见,去中心化的架构中的保护锁,无疑是形同虚设。而且以太坊2.0还允许分片与分片之间的读写操作,那又会暴露同样的多线程同步问题。

那可能有人会问,我们能不能把这个Beacon链的锁存状态,同步到其他分片中呢?这里又涉及到一个投票问题了。因为每个分片节点如果从自身角度出发,它所看到的主链状态是不一样。比如上图的节点1它给它自己记录的主链上了锁,但是节点2却不这么认为。因为它并没有看到这个锁。所以节点1认为有锁,节点2认为没有锁。拜占庭将军问题再次出现,所以只能投票决定。但是以太坊2.0使用的是POS,已经不是POW了,所以你选择最长链的共识没有意义。因为区块的生产没有成本,只要拿到记账权一次性就能播报多个区块,所以最长的那条链无法代表最多的共识。这时候票数的统计就会变得更加复杂。即使共识算法可以进行正确的票数统计,认定节点1获胜,那与此同时就意味着分片2的区块就被舍弃掉了。此时分片的意义又何在?你如果想保留分片2的区块,那就必须把这条线程放在等待队列里。可问题是你没有一个控制面板一样的东西,能够全局调配不同线程的资源,你连每个线程状态都不知道啊。所以这是不是又要回到中心化的老路?

根据以上分析,我可以断定当以太坊2.0上线以后,势必会出现大量的数据不同步问题。不仅分片之间不同步,各个节点的Beacon链也会不同步。公链和淘宝不一样,淘宝如果出现了数据不同步问题,我顶多修改下数据库,或者重启一下服务器就好了。但是公链上的不同步就会引起矿工阵营的撕裂,会引起分叉,这就是一个很严重的问题了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK