原創:北美區塊君
來源:區塊鏈中那些事兒
繼比特幣減半以後,幣圈又有一個大事件在醞釀,那就是以太坊要升級了。在開始聊這個以太坊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鏈也會不同步。公鏈和淘寶不一樣,淘寶如果出現了數據不同步問題,我頂多修改下資料庫,或者重啟一下伺服器就好了。但是公鏈上的不同步就會引起礦工陣營的撕裂,會引起分叉,這就是一個很嚴重的問題了。
冷萃財經原創,作者:Awing,轉載請註明出處:https://www.lccjd.top/2020/06/06/%e4%bb%a5%e5%a4%aa%e5%9d%8a2-0%e7%9a%84%e8%87%b4%e5%91%bd%e7%bc%ba%e9%99%b7/?variant=zh-tw
文章評論