1. 程式人生 > >以太坊原始碼研究:PoW及共識演算法深究

以太坊原始碼研究:PoW及共識演算法深究

本系列的前兩篇分別介紹了以太坊的基本概念,基本環節-交易,區塊、區塊鏈的儲存方式等,這篇打算介紹一下“挖礦“得到新區塊的整個過程,以及不同共識演算法的實現細節。

1.待挖掘區塊需要組裝

在Ethereum 程式碼中,名為miner的包(package)負責向外提供一個“挖礦”得到的新區塊,其主要結構體的UML關係圖如下圖所示:


處於入口的類是Miner,它作為公共型別,向外暴露mine功能;它有一個worker型別的成員變數,負責管理mine過程;worker內部有一組Agent介面型別物件,每個Agent都可以完成單個區塊的mine,目測這些Agent之間應該是競爭關係;Work結構體主要用以攜帶資料,被視為挖掘一個區塊時所需的資料環境。

主要的資料傳輸發生在worker和它的Agent(們)之間:在合適的時候,worker把一個Work物件傳送給每個Agent,然後任何一個Agent完成mine時,將一個經過授權確認的Block加上那個更新過的Work,組成一個Result物件傳送回worker。

有意思的是<<Agent>>介面,儘管呼叫方worker內部聲明瞭一個Agent陣列,但目前只有一個實現類CpuAgent的物件會被加到該陣列,可能這個Agent陣列是為將來的擴充套件作的預留吧。CpuAgent通過全域性的<<Engine>>物件,藉助共識演算法完成最終的區塊授權。

另外,unconfirmedBlocks 也挺特別,它會以unconfirmedBlock的形式儲存最近一些本地挖掘出的區塊。在一段時間之後,根據區塊的Number和Hash,再確定這些區塊是否已經被收納進主幹鏈(canonical chain)裡,以輸出Log的方式來告知使用者。

對於一個新區塊被挖掘出的過程,程式碼實現上基本分為兩個環節:一是組裝出一個新區塊,這個區塊的資料基本完整,包括成員Header的部分屬性,以及交易列表txs,和叔區塊組uncles[],並且所有交易已經執行完畢,所有收據(Receipt)也已收集完畢,這部分主要由worker完成;二是填補該區塊剩餘的成員屬性,比如Header.Difficulty等,並完成授權,這些工作是由Agent呼叫<Engine>介面實現體,利用共識演算法來完成的。

新區塊的組裝流程

挖掘新區塊的流程入口在Miner裡,略顯奇葩的是,具體入口在Miner結構體的建立函式(避免稱之為‘建構函式’)裡。


Miner的函式

New()

在New()裡,針對新物件miner的各個成員變數初始化完成後,會緊跟著建立worker物件,然後將Agent物件登記給worker,最後用一個單獨執行緒去執行miner.Update()函式;而worker的建立函式裡也如法炮製,分別用單獨執行緒去啟動worker.updater()和wait();最後worker.CommitNewWork()會開始準備一個新區塊所需的基本資料,如Header,Txs, Uncles等。注意此時Agent尚未啟動。

Update()

這個update()會訂閱(監聽)幾種事件,均跟Downloader相關。當收到Downloader的StartEvent時,意味者此時本節點正在從其他節點下載新區塊,這時miner會立即停止進行中的挖掘工作,並繼續監聽;如果收到DoneEvent或FailEvent時,意味本節點的下載任務已結束-無論下載成功或失敗-此時都可以開始挖掘新區塊,並且此時會退出Downloader事件的監聽。

從miner.Update()的邏輯可以看出,對於任何一個Ethereum網路中的節點來說,挖掘一個新區塊和從其他節點下載、同步一個新區塊,根本是相互衝突的。這樣的規定,保證了在某個節點上,一個新區塊只可能有一種來源,這可以大大降低可能出現的區塊衝突,並避免全網中計算資源的浪費。

worker的函式

這裡我們主要關注worker.updater()和wait()


update()

worker.update()分別監聽ChainHeadEvent,ChainSideEvent,TxPreEvent幾個事件,每個事件會觸發worker不同的反應。ChainHeadEvent是指區塊鏈中已經加入了一個新的區塊作為整個鏈的鏈頭,這時worker的迴應是立即開始準備挖掘下一個新區塊(也是夠忙的);ChainSideEvent指區塊鏈中加入了一個新區塊作為當前鏈頭的旁支,worker會把這個區塊收納進possibleUncles[]陣列,作為下一個挖掘新區塊可能的Uncle之一;TxPreEvent是TxPool物件發出的,指的是一個新的交易tx被加入了TxPool,這時如果worker沒有處於挖掘中,那麼就去執行這個tx,並把它收納進Work.txs陣列,為下次挖掘新區塊備用。

需要稍稍注意的是,ChainHeadEvent並不一定是外部源發出。由於worker物件有個成員變數chain(eth.BlockChain),所以當worker自己完成挖掘一個新區塊,並把它寫入資料庫,加進區塊鏈裡成為新的鏈頭時,worker自己也可以呼叫chain發出一個ChainHeadEvent,從而被worker.update()函式監聽到,進入下一次區塊挖掘。

wait()

worker.wait()會在一個channel處一直等待Agent完成挖掘傳送回來的新Block和Work物件。這個Block會被寫入資料庫,加入本地的區塊鏈試圖成為最新的鏈頭。注意,此時區塊中的所有交易,假設都已經被執行過了,所以這裡的操作,不會再去執行這些交易物件。

當這一切都完成,worker就會發送一條事件(NewMinedBlockEvent{}),等於通告天下:我挖出了一個新區塊!這樣監聽到該事件的其他節點,就會根據自身的狀況,來決定是否接受這個新區塊成為全網中公認的區塊鏈新的鏈頭。至於這個公認過程如何實現,就屬於共識演算法的範疇了。

commitNewWork()

commitNewWork()會在worker內部多處被呼叫,注意它每次都是被直接呼叫,並沒有以goroutine的方式啟動。commitNewWork()內部使用sync.Mutex對全部操作做了隔離。這個函式的基本邏輯如下:

  1. 準備新區塊的時間屬性Header.Time,一般均等於系統當前時間,不過要確保父區塊的時間(parentBlock.Time())要早於新區塊的時間,父區塊當然來自當前區塊鏈的鏈頭了。
  2. 建立新區塊的Header物件,其各屬性中:Num可確定(父區塊Num +1);Time可確定;ParentHash可確定;其餘諸如Difficulty,GasLimit等,均留待之後共識演算法中確定。
  3. 呼叫Engine.Prepare()函式,完成Header物件的準備。
  4. 根據新區塊的位置(Number),檢視它是否處於DAO硬分叉的影響範圍內,如果是,則賦值予header.Extra。
  5. 根據已有的Header物件,建立一個新的Work物件,並用其更新worker.current成員變數。
  6. 如果配置資訊中支援硬分叉,在Work物件的StateDB裡應用硬分叉。
  7. 準備新區塊的交易列表,來源是TxPool中那些最近加入的tx,並執行這些交易。
  8. 準備新區塊的叔區塊uncles[],來源是worker.possibleUncles[],而possibleUncles[]中的每個區塊都從事件ChainSideEvent中搜集得到。注意叔區塊最多有兩個。
  9. 呼叫Engine.Finalize()函式,對新區塊“定型”,填充上Header.Root, TxHash, ReceiptHash, UncleHash等幾個屬性。
  10. 如果上一個區塊(即舊的鏈頭區塊)處於unconfirmedBlocks中,意味著它也是由本節點挖掘出來的,嘗試去驗證它已經被吸納進主幹鏈中。
  11. 把建立的Work物件,通過channel傳送給每一個登記過的Agent,進行後續的挖掘。

以上步驟中,4和6都是僅僅在該區塊配置中支援DAO硬分叉,並且該區塊的位置正好處於DAO硬分叉影響範圍內時才會發生;其他步驟是普遍性的。commitNewWork()完成了待挖掘區塊的組裝,block.Header建立完畢,交易陣列txs,叔區塊Uncles[]都已取得,並且由於所有交易被執行完畢,相應的Receipt[]也已獲得。萬事俱備,可以交給Agent進行‘挖掘’了。

CpuAgent的函式

CpuAgent中與mine相關的函式,主要是update()和mine():


CpuAgent.update()就是worker.commitNewWork()結束後發出Work物件的會一直監聽相關channel,如果收到Work物件(顯然由worker.commitNewWork()結束後發出),就啟動mine()函式;如果收到停止(mine)的訊息,就退出一切相關操作。

CpuAgent.mine()會直接呼叫Engine.Seal()函式,利用Engine實現體的共識演算法對傳入的Block進行最終的授權,如果成功,就將Block同Work一起通過channel發還給worker,那邊worker.wait()會接收並處理。

顯然,這兩個函式都沒做什麼實質性工作,它們只是負責呼叫<Engine>介面實現體,待授權完成後將區塊資料傳送回worker。挖掘出一個區塊的真正奧妙全在Engine實現體所代表的共識演算法裡。

2.共識演算法完成挖掘

共識演算法族對外暴露的是Engine介面,其有兩種實現體,分別是基於運算能力的Ethash演算法和基於“同行”認證的的Clique演算法。


在Engine介面的宣告函式中,VerifyHeader(),VerifyHeaders(),VerifyUncles()用來驗證區塊相應資料成員是否合理合規,可否放入區塊;Prepare()函式往往在Header建立時呼叫,用來對Header.Difficulty等屬性賦值;Finalize()函式在區塊區塊的資料成員都已具備時被呼叫,比如叔區塊(uncles)已經具備,全部交易Transactions已經執行完畢,全部收據(Receipt[])也已收集完畢,此時Finalize()會最終生成Root,TxHash,UncleHash,ReceiptHash等成員。

而Seal()和VerifySeal()是Engine介面所有函式中最重要的。Seal()函式可對一個呼叫過Finalize()的區塊進行授權或封印,並將封印過程產生的一些值賦予區塊中剩餘尚未賦值的成員(Header.Nonce, Header.MixDigest)。Seal()成功時返回的區塊全部成員齊整,可視為一個正常區塊,可被廣播到整個網路中,也可以被插入區塊鏈等。所以,對於挖掘一個新區塊來說,所有相關程式碼裡Engine.Seal()是其中最重要,也是最複雜的一步。VerifySeal()函式基於跟Seal()完全一樣的演算法原理,通過驗證區塊的某些屬性(Header.Nonce,Header.MixDigest等)是否正確,來確定該區塊是否已經經過Seal操作。

在兩種共識演算法的實現中,Ethash是產品環境下以太坊真正使用的共識演算法,Clique主要針對以太坊的測試網路運作,兩種共識演算法的差異,主要體現在Seal()的實現上。

Ethash共識演算法

Ethash演算法又被稱為Proof-of-Work(PoW),是基於運算能力的授權/封印過程。Ethash實現的Seal()函式,其基本原理可簡單表示成以下公式:

RAND(h, n)  <=  M / d

這裡M表示一個極大的數,比如2^256-1;d表示Header成員Difficulty。RAND()是一個概念函式,它代表了一系列複雜的運算,並最終產生一個類似隨機的數。這個函式包括兩個基本入參:h是Header的雜湊值(Header.HashNoNonce()),n表示Header成員Nonce。整個關係式可以大致理解為,在最大不超過M的範圍內,以某個方式試圖找到一個數,如果這個數符合條件(<=M/d),那麼就認為Seal()成功。

我們可以先定性的驗證一個推論:d的大小對整個關係式的影響。假設h,n均不變,如果d變大,則M/d變小,那麼對於RAND()生成一個滿足該條件的數值,顯然其概率是下降的,即意味著難度將加大。考慮到以上變數的含義,當Header.Difficulty逐漸變大時,這個對應區塊被挖掘出的難度(恰為Difficulty本義)也在緩慢增大,挖掘所需時間也在增長,所以上述推論是合理的。

mine()函式

Ethash.Seal()函式實現中,會以多執行緒(goroutine)的方式並行呼叫mine()函式,執行緒個數等於Ethash.threads;如果Ethash.threads被設為0,則Ethash選擇以本地CPU中的總核數作為開啟執行緒的個數。

  1. /* consensus/ethash/sealer.go */  
  2. func (ethash *Ethash) mine(block *Block, id int, seed uint64, abort chan struct{}, found chan *Block) {  
  3.     var (  
  4.         header = block.Header()  
  5.         hash   = header.HashNoNonce().Bytes()  
  6.         target = new(big.Int).Div(maxUint256, header.Difficulty)  
  7.         number = header.Number.Uint64()  
  8.         dataset = ethash.dataset(number)  
  9.         nonce  = seed  
  10.     )  
  11.     for {  
  12.         select {  
  13.         case <-abort:  
  14.             ...; return  
  15.         default:  
  16.             digest, result := hashimotoFull(dataset, hash, nonce) // compute the PoW value of this nonce  
  17.             if new(big.Int).SetBytes(result).Cmp(target) <= 0 { // x.Cmp(y) <= 0 means x <= y  
  18.                 header = types.CopyHeader(header)  
  19.                 header.Nonce = types.EncodeNonce(nonce)  
  20.                 header.MixDigest = common.BytesToHash(digest)  
  21.                 found<- block.WithSeal(header)  
  22.                 return  
  23.             }  
  24.         }  
  25.         nonce++  
  26.     }  
  27. }  
以上程式碼就是mine()函式的主要業務邏輯。入參@id是執行緒編號,用來發送log告知上層;函式內部首先定義一組區域性變數,包括之後呼叫hashimotoFull()時傳入的hash、nonce、巨大的輔助陣列dataset,以及結果比較的target;然後是一個無限迴圈,每次呼叫hashimotoFull()進行一系列複雜運算,一旦它的返回值符合條件,就複製Header物件(深度拷貝),並賦值Nonce、MixDigest屬性,返回經過授權的區塊。注意到在每次迴圈運算時,nonce還會自增+1,使得每次迴圈中的計算都各不相同。

這裡hashimotoFull()函式通過呼叫hashimoto()函式完成運算,而同時還有另外一個類似的函式hashimoLight()函式。

  1. func hashimotoFull(dataset []uint32, hash []byte, nonce uint64) ([]byte, []byte) {  
  2.     lookup := func(index uint32) []uint32 {  
  3.         offset := index * hashWords  
  4.         return dataset[offset : offset+hashWords]  
  5.     }  
  6.     return hashimoto(hash, nonce, uint64(len(dataset))*4, lookup)  
  7. }  
  8. func hashimotoLight(size uint64, cache []uint32, hash []byte, nonce uint64) ([]byte, []byte) {  
  9.     lookup := func(index uint32) []uint32 {  
  10.         rawData := generateDatasetItem(cache, index, keccak512)  
  11.         data := make([]uint32, len(rawData)/4)  
  12.         for i := 0; i < len(data); i++ {  
  13.             data[i] = binary.LittleEndian.Uint32(rawData[i*4:])  
  14.         }  
  15.         return data  
  16.     }  
  17.     return hashimoto(hash, nonce, size, lookup)  
  18. }  

以上兩個函式,最終都呼叫了hashimoto()。它們的差別,在於各自呼叫hashimoto()函式的入參@size uint 和 @lookup func()不同。相比於Light(),Full()函式呼叫的size更大,以及一個從更大陣列中獲取資料的查詢函式lookup()。hashimotoFull()函式是被Seal()呼叫的,而hashimotoLight()是為VerifySeal()準備的。

這裡的lookup()函式其實很重要,它其實是一個以非線性表查詢方式進行的雜湊函式! 這種雜湊函式的效能不僅取決於查詢的邏輯,更多的依賴於所查詢的表格的資料規模大小。lookup()會以函式型引數的形式傳遞給hashimoto()

核心的運算函式hashimoto()

最終為Ethash共識演算法的Seal過程執行運算任務的是hashimoto()函式,它的函式型別如下:

  1. // consensus/ethash/algorithm.go  
  2. func hashimoto(hash []byte, nonce uint64, size uint64, lookup(index uint32) []uint32) (digest []byte, result []byte)  
hashimoto()函式的入參包括:區塊雜湊值@hash, 區塊nonce成員@nonce,和非線性表查詢的雜湊函式lookup(),及其所查詢的非線性表格的容量@size。返回值digest和result,都是32 bytes長的位元組串。

hashimoto()的邏輯比較複雜,包含了多次、多種雜湊運算。下面嘗試從其中資料結構變化的角度來簡單描述之:


簡單介紹一下上圖所代表的程式碼流程:

  • 首先,hashimoto()函式將入參@hash和@nonce合併成一個40 bytes長的陣列,取它的SHA-512雜湊值取名seed,長度為64 bytes。
  • 然後,將seed[]轉化成以uint32為元素的陣列mix[],注意一個uint32數等於4 bytes,故而seed[]只能轉化成16個uint32數,而mix[]陣列長度32,所以此時mix[]陣列前後各半是等值的。
  • 接著,lookup()函式登場。用一個迴圈,不斷呼叫lookup()從外部資料集中取出uint32元素型別陣列,向mix[]陣列中混入未知的資料。迴圈的次數可用引數調節,目前設為64次。每次迴圈中,變化生成引數index,從而使得每次呼叫lookup()函式取出的陣列都各不相同。這裡混入資料的方式是一種類似向量“異或”的操作,來自於FNV演算法。
  • 待混淆資料完成後,得到一個基本上面目全非的mix[],長度為32的uint32陣列。這時,將其摺疊(壓縮)成一個長度縮小成原長1/4的uint32陣列,摺疊的操作方法還是來自FNV演算法。
  • 最後,將摺疊後的mix[]由長度為8的uint32型陣列直接轉化成一個長度32的byte陣列,這就是返回值@digest;同時將之前的seed[]陣列與digest合併再取一次SHA-256雜湊值,得到的長度32的byte陣列,即返回值@result。

最終經過一系列多次、多種的雜湊運算,hashimoto()返回兩個長度均為32的byte陣列 - digest[]和result[]。回憶一下ethash.mine()函式中,對於hashimotoFull()的兩個返回值,會直接以big.int整型數形式比較result和target;如果符合要求,則將digest取SHA3-256的雜湊值(256 bits),並存於Header.MixDigest中,待以後Ethash.VerifySeal()可以加以驗證。

以非線性表查詢方式進行的雜湊運算

上述hashimoto()函式中,函式型入參lookup()其實表示的是一次以非線性表查詢方式進行的雜湊運算,lookup()以入參為key,從所關聯的資料集中按照定義好的一段邏輯取出64 bytes長的資料作為hash value並返回,注意返回值以uint32的形式則相應變成16個uint32長。返回的資料會在hashimoto()函式被其他的雜湊運算所使用。

以非線性表的查詢方式進行的雜湊運算(hashing by nonlinear table lookup),屬於眾多雜湊函式中的一種實現,在Ethash演算法的核心環節有大量使用,所使用到的非線性表被定義成兩種結構體,分別叫cache{}和dataset{}。二者的差異主要是表格的規模和呼叫場景:dataset{}中的資料規模更加巨大,從而會被hashimotoFull()呼叫從而服務於Ethash.Seal();cache{}內含資料規模相對較小,會被hashimotoLight()呼叫並服務於Ethash.VerifySeal()。


以cache{}的結構體宣告為例,成員cache就是實際使用的一塊記憶體Buffer,mmap是記憶體對映物件,dump是該記憶體buffer儲存於磁碟空間的檔案物件,epoch是共享這個cache{}物件的一組區塊的序號。從上述UML圖來看,cache和dataset的結構體宣告基本一樣,這也暗示了它們無論是原理還是行為都極為相似。

cache{}物件的生成

dataset{}和cache{}的生成過程很類似,都是通過記憶體對映的方式讀/寫磁碟檔案。


以cache{}為例,Ethash.cache(uint64)會確保該區塊所用到的cache{}物件已經建立,它的入參是區塊的Number,用Number/epochLength可以得到該區塊所對應的epoch號。epochLength被定義成常量30000,意味著每連續30000個區塊共享一個cache{}物件。

有意思的是記憶體對映相關的函式,memoryMapAndGenerate()會首先呼叫memoryMapFile()生成一個檔案並對映到記憶體中的一個數組,並呼叫傳入的函式型引數generator() 進行資料的填入,於是這個記憶體陣列以及所對映的磁碟檔案就同時變得十分巨大,注意此時傳入memoryMapFile()的檔案操作許可權是可寫的。然後再關閉所有檔案操作符,呼叫memoryMapFile()重新開啟這個磁碟檔案並對映到記憶體的一個數組,注意此時的檔案操作許可權是隻讀的。可見這組函式的coding很精細。

Ethash中分別用一個map結構來存放不同epoch對應的cache物件和dataset物件,快取成員fcache和fdataset,用以提前建立cache{}和dataset{}物件以避免下次使用時再花費時間。

我們以cache{}為例,看看cache.generate()方法的具體邏輯:


上圖是cache.generate()方法的基本流程:如果是測試用途,則不必考慮磁碟檔案,直接呼叫generateCache()建立buffer;如果資料夾為空,那也沒法拼接出檔案路徑,同樣直接呼叫generateCache()建立buffer;然後根據拼接出的檔案路徑,先嚐試讀取磁碟上已有檔案,如果成功,說明檔案已存在並可使用;如果檔案不存在,那隻好建立一個新檔案,定義檔案容量,然後利用記憶體對映將其匯入記憶體。很明顯,直接為cache{]建立buffer的generateCache()函式是這裡的核心操作,包括memoryMapAndGenerate()方法,都將generateCache()作為一個函式型引數引入操作的。

引數size的生成

引數size是生成buffer的容量,它在上述cache.generate()中生成。

  1. size = cacheSize(epoch * epochLength +1)  
  2. ...  
  3. func cacheSize(block uint64) uint64 {  
  4.     epoch := int(block / epochLength)  
  5.     if epoch < len(cacheSizes) {  
  6.         return cacheSizes[epoch]  
  7.     }  
  8.     size := uint64(cacheInitBytes + cacheGrowthBytes * uint64(epoch) - hashBytes)  
  9.     for !(size/hashBytes).ProbablyPrime(1) {  
  10.         size -= 2 * hashBytes  
  11.     }  
  12.     return size  
  13. }  
上述就是生成size的程式碼,cacheSize()的入參雖然是跟區塊Number相關,但實際上對於處於同一epoch組的區塊來說效果是一樣的,每組個數epochLength。Ethash在程式碼裡預先定義了一個數組cacheSizes[],存放了前2048個epoch組所用到的cache size。如果當前區塊的epoch處於這個範圍內,則取用之;若沒有,則以下列公式賦初始值。

size = cacheInitBytes + cacheGrowthBytes * epoch - hashBytes

這裡cacheInitBytes = 2^24,cacheGrowthBytes = 2^17,hashBytes = 64,可見size的取值有多麼巨大了。注意到cacheSize()中在對size賦值後還要不斷調整,保證最終size是個質數,這是出於密碼學的需要。

粗略計算一下size的取值範圍,size = 2^24 + 2^17 * epoch,由於epoch > 2048 = 2^11,所以size  > 2^28,生成的buffer至少有256MB,而這還僅僅是VerifySeal()使用的cache{},Seal()使用的dataset{}還要大的多,可見這些資料集有多麼龐大了。

引數seed[]的生成

引數seed是generateCache()中對buffer進行雜湊運算的種子資料,它也在cache.generate()函式中生成。

  1. seed := seedHash(epoch * epochLength +1)  
  2. ...  
  3. func seedHash(block uint64) []byte {  
  4.     seed := make([]byte, 32)  
  5.     if block < epochLength { // epochLength = 30000  
  6.         return seed  
  7.     }  
  8.     keccak256 := makeHasher(sha3.NewKeccak256())  
  9.     for i := 0; i < int(block/epochLength); i++ {  
  10.         keccak256(seed, seed)  
  11.     }  
  12.     return seed  
  13. }  
  14. type hasher func(dest []byte, data []byte)  
  15. func makeHasher(h hash.Hash) hasher {  
  16.     return func(dest []byte, data []byte) {  
  17.         h.Write(data)  
  18.         h.Sum(dest[:0])  
  19.         h.Reset()  
  20.     }  
  21. }  
上述seedHash()函式用來生成所需的seed[]陣列,它的長度32bytes,與common.Address型別長度相同。makeHasher()函式利用入參的雜湊函式介面生成一個雜湊函式,這裡用了SHA3-256雜湊函式。注意seedHash()中利用生成的雜湊函式keccak256()對seed[]做的原地雜湊,而原地雜湊運算的次數跟當前區塊所處的epoch序號有關,所以每個不同的cache{}所用到的seed[]也是完全不同的,這個不同通過更多次的雜湊運算來實現。

generateCache()函式

generateCache()函式在給定種子陣列seed[]的情況下,對固定容量的一塊buffer