继比特币减半以后,币圈又有一个大事件在酝酿,那就是以太坊要升级了。在开始聊这个以太坊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块。我们来看看该程序的运行结果:
但问题是B提了5000以后,银行已没有任何结余,所以应该显示0。但这里仍是5000。这就是问题所在!有趣的是,你如果重复运行这个程序,会发现每一次的结果可能是不一样的。有时候显示0,有时候显示5000。这种现象在计算机里有个典型的名词叫竞态条件(race condition)。指的就是有多个计算机线程在争夺同一个资源,造成数据更新的紊乱。像我们这个情况,A和B通过不同的ATM,开启了两个提款操作线程。这两个线程都要对银行余额进行修改。这种情况下,B相当于抢夺了A的数据更改权,导致刚更新的数据立马被B覆盖掉了。
那为什么会发生这种情况呢?这是由于计算机CPU的特殊架构所决定的。计算机的任何一条指令都需要知道它的操作对象是谁,值是多少,否则这条指令就没有意义。那这个操作对象去哪里找?CPU里有一个叫做寄存器的元件专门负责存储这个信息。任何一条指令都需要访问这个寄存器,获取它操作对象的值,指令才能完整地执行。比如下图中的AX就是CPU的一个寄存器。里面可存一个16位的二进制数。
就拿我们这个例子来说,withdraw就是提款的指令,而它的操作对象就是银行余额balance。那这个balance的值是多少呢?这就要到寄存器里去找。
获取了这个值以后,指令就开始执行了。在此过程中,它会对寄存器的内容进行修改,完成数据的更新。所以我们的银行余额就是这样被更新的。这时候如果有新的指令进来获取当前的余额,我们再回到那个寄存器里找答案就行了。
但问题是我们电脑在单位时间内不单单执行一条指令。在很多情况下,是多条指令同时运行的。不然你怎么可以做到边听音乐边上网呢?所以为了实现“并行操作”,我们的CPU引入了多线程管理的机制。就是把这些指令封装在不同的线程里,通过合理地调度,来并行地运作多个程序。就比如我们可以用一个线程来执行提款(withdraw)的指令,同时可以再分配一个线程来查询当前的余额(getBalance),如下图所示:
查询操作并不影响寄存器的状态,所以两条线程可以相安无事,但如果此时再引入第三个线程也来进行提款操作,那事情就会变得很棘手了。因为它可能会和线程1抢夺同一个寄存器的资源。如下图所示,线程1和3在同一时刻对寄存器的状态进行更新,那很有可能当线程3执行的时候,线程1还没有来得及更新balance的值,所以它读到的值还是更新之前的,即balance=10000。而当线程1运行完以后,虽然将balance更新至5000,但这已于事无补,因为线程3已经在操作了。所以线程3对寄存器的更新还是基于原来的旧值:10000,导致最终的余额仍旧是5000。 (10000-5000=5000)
所以为了避免这种情况,我们必须要保证这个寄存器的状态在多线程运行中是同步的。虽然它们都是共享同一块数据资源,但是必须要有一个先来后到。就拿上述情况来说,我们必须要保证线程1操作寄存器的时候,其他线程无法访问。只有当线程1结束以后,线程3才可以进行操作。这样一来,每条线程所读取的寄存器数据就是同步的。
为了实现多线程之间的同步,我们的CPU引入了一个“保护锁”(lock)的机制。就是针对这种共享的寄存器资源,标记一个“锁存”状态。任何一个线程在访问寄存器的时候,都可以给它“上锁”。这样一来其他线程就无法访问,只能乖乖地等待。只有当前线程执行完毕以后,这个“保护锁”才会被释放。然后其余的线程就会被自动唤醒,开始访问这个寄存器资源。像我们这个例子就可以让线程1在访问寄存器的时候“上锁”,那线程3就会被迫等待。等线程1执行完毕以后,balance就会更新为10000-5000=5000。然后寄存器释放保护锁,线程3被唤醒,开始访问balance这个变量。类似地,它再套上一个保护锁。这时候它获取的数值就是5000。等它执行完毕以后,balance就会更新至5000-5000=0。如下所示:
对应的,我们的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(); //释放保护锁 }}
运行结果如下:
根据这次的运行结果,你可以看到我们的银行余额在A和B两次提款之后,已经正确地更新至0。
所以我们可以看到,虽然每条线程都是独立的,但是整个线程的调度是中心化的。CPU就好比是一个大脑。它得给不同的线程进行合理的资源分配,安排执行的先后顺序,这样才能保证数据的同步。所以这个大脑必须得知道哪些寄存器上了锁,哪些线程在进行访问,哪些线程在等待。也就说它具备一个“上帝视角”可以实时监测每一条线程,以及每一个寄存器的状态。
单个电脑的程序运行是这样的,但如果我们往大了说,多台电脑的节点部署也是这样的。就拿淘宝网站来说,它在双11那天得处理几千万条交易请求,所以一个服务器肯定是不够的。它肯定得部署多个服务器节点,然后通过负载均衡器(Load Balancer),把这些请求均匀地分配至每个服务器上。如下图所示:
但无论有多少个服务器节点,无论有多大数量的请求,最终它们访问的是同一个数据库!这点非常重要。因为只有这样,你才可以引入“保护锁”的机制,在交易的过程中给对应数据库表单加锁,保证读写的同步。比如说现在一个天猫店有10个香奈儿的包,打8折,100个人抢购,当第一个人下单了已经开始交易了,那数据库就必须要把其他的购买请求放在等待队列里。这样才能保证下一个人看到的是9个包而不是开始的10个。
所以淘宝网的节点部署,虽是分布式架构,但是本质上是中心化的。这种中心化体现在节点线程的监控与调度,以及数据库的解决方案上。也就是说淘宝服务器的背后有一个控制中心,它可以实时地检测每个节点的状态,并且这些节点访问的是同一个数据库。正是这样的中心化架构,才可以让那么多节点并行处理这么多请求,同时还能保证数据的同步。
但是公有链的分布式架构就完全不同了,因为它本质上是去中心化的,每个节点各自为战。所以它没有一个控制面板,掌控所有节点的状态。其次它没有一个中心化的数据库让这些节点去访问,而是每个节点都独立配置一个自己的数据库。所以就会有多个账本同时存在,我们只能引入投票机制来确定一个最终账本,间接地实现节点间的同步。比特币是通过算力来投票,选出最长的那条区块链作为最终账本。虽然不同的节点会产生多个区块链账本,引发拜占庭将军问题,但比特币的算法是行得通的。因为它是单链结构,并且在单位时间内只能产生一个区块。虽然同时间可以有不同的节点播报区块,但是比特币的挖矿机制保证了这个区块的唯一性。所以比特币本质上是一个单线程的数据库读写操作。
以太坊本来也没有问题,因为它和比特币一样也是单链结构,使用POW共识。但是升级到2.0以后问题就很大了。因为以太坊2.0它引入一个叫“分片(sharding)”的机制。简单的来说就是借鉴淘宝网的这种负载均衡器(Load Balancer)的机制——设置多个节点,批量处理不同的请求。比如说现在有10000个交易请求,我让A节点处理5000个,B节点处理余下的5000个,那这样一来速度不就快了嘛。我承认这个初衷是好的,但是实际上是行不通的。根据以太坊2.0的介绍,它首先引入了一个主链叫(Beacon),这个主链负责记录所有交易的状态,相当于账本的核心。然后它把整个节点网络划分成不同区域,每个区域作为一个分片,相当于Load Balancer。每个分片都处理不同的交易请求,最终分别记录在主链上。如下图所示:
说到这里你可能会有点迷,但是我换一种方式来解释你就明白了。只要看了我前面的介绍,你就应该会对多线程同步有一个简单的认识。以太坊2.0也是类似的架构,你可以把Beacon链理解为中央数据库,每一个分片相当于一个独立的线程。每个线程播报的区块都是不一样的,比如说分片1的区块所包含的交易序列是1到3000,那分片2就是3000到6000。所以以太坊2.0相当于一个多线程的数据库读写操作。这是和比特币本质上的不同。
如果多个线程对同一个数据库进行操作,容易出现数据不同步的问题,所以正确的做法就是在每个线程执行的过程中,给这个数据库加上一个保护锁,从而避免其他线程同时访问。所以对于我们这个情况也是一样的,就是当每个分片对Beacon链进行更新的时候,必须要给这条主链加上一个“保护锁”,从而迫使其他的分片进入等待队列。V神确实也考虑到了这点,准备引入这个“保护锁”的机制。但错就错在,这个Beacon链不是唯一的中央数据库。
我们要知道以太坊是公链,公链是去中心化的!所以每一个挖矿节点都有自己的一条Beacon链。所以这里的“上锁”,是加在自己那条Beacon链上的锁。这个锁存的状态显然没有和其他节点同步,所以其余的分片节点仍旧会继续访问主链。这个时候,不同的分片之间就会产生我之前说的竞争状态(race condition)。分片1的更新有可能就会被分片2给覆盖掉。如下图所示:
由此可见,去中心化的架构中的保护锁,无疑是形同虚设。而且以太坊2.0还允许分片与分片之间的读写操作,那又会暴露同样的多线程同步问题。
那可能有人会问,我们能不能把这个Beacon链的锁存状态,同步到其他分片中呢?这里又涉及到一个投票问题了。因为每个分片节点如果从自身角度出发,它所看到的主链状态是不一样。比如上图的节点1它给它自己记录的主链上了锁,但是节点2却不这么认为。因为它并没有看到这个锁。所以节点1认为有锁,节点2认为没有锁。拜占庭将军问题再次出现,所以只能投票决定。但是以太坊2.0使用的是POS,已经不是POW了,所以你选择最长链的共识没有意义。因为区块的生产没有成本,只要拿到记账权一次性就能播报多个区块,所以最长的那条链无法代表最多的共识。这时候票数的统计就会变得更加复杂。即使共识算法可以进行正确的票数统计,认定节点1获胜,那与此同时就意味着分片2的区块就被舍弃掉了。此时分片的意义又何在?你如果想保留分片2的区块,那就必须把这条线程放在等待队列里。可问题是你没有一个控制面板一样的东西,能够全局调配不同线程的资源,你连每个线程状态都不知道啊。所以这是不是又要回到中心化的老路?
根据以上分析,我可以断定当以太坊2.0上线以后,势必会出现大量的数据不同步问题。不仅分片之间不同步,各个节点的Beacon链也会不同步。公链和淘宝不一样,淘宝如果出现了数据不同步问题,我顶多修改下数据库,或者重启一下服务器就好了。但是公链上的不同步就会引起矿工阵营的撕裂,会引起分叉,这就是一个很严重的问题了。