以太坊原始碼研究: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對全部操作做了隔離。這個函式的基本邏輯如下:
- 準備新區塊的時間屬性Header.Time,一般均等於系統當前時間,不過要確保父區塊的時間(parentBlock.Time())要早於新區塊的時間,父區塊當然來自當前區塊鏈的鏈頭了。
- 建立新區塊的Header物件,其各屬性中:Num可確定(父區塊Num +1);Time可確定;ParentHash可確定;其餘諸如Difficulty,GasLimit等,均留待之後共識演算法中確定。
- 呼叫Engine.Prepare()函式,完成Header物件的準備。
- 根據新區塊的位置(Number),檢視它是否處於DAO硬分叉的影響範圍內,如果是,則賦值予header.Extra。
- 根據已有的Header物件,建立一個新的Work物件,並用其更新worker.current成員變數。
- 如果配置資訊中支援硬分叉,在Work物件的StateDB裡應用硬分叉。
- 準備新區塊的交易列表,來源是TxPool中那些最近加入的tx,並執行這些交易。
- 準備新區塊的叔區塊uncles[],來源是worker.possibleUncles[],而possibleUncles[]中的每個區塊都從事件ChainSideEvent中搜集得到。注意叔區塊最多有兩個。
- 呼叫Engine.Finalize()函式,對新區塊“定型”,填充上Header.Root, TxHash, ReceiptHash, UncleHash等幾個屬性。
- 如果上一個區塊(即舊的鏈頭區塊)處於unconfirmedBlocks中,意味著它也是由本節點挖掘出來的,嘗試去驗證它已經被吸納進主幹鏈中。
- 把建立的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中的總核數作為開啟執行緒的個數。
- /* consensus/ethash/sealer.go */
- func (ethash *Ethash) mine(block *Block, id int, seed uint64, abort chan struct{}, found chan *Block) {
- var (
- header = block.Header()
- hash = header.HashNoNonce().Bytes()
- target = new(big.Int).Div(maxUint256, header.Difficulty)
- number = header.Number.Uint64()
- dataset = ethash.dataset(number)
- nonce = seed
- )
- for {
- select {
- case <-abort:
- ...; return
- default:
- digest, result := hashimotoFull(dataset, hash, nonce) // compute the PoW value of this nonce
- if new(big.Int).SetBytes(result).Cmp(target) <= 0 { // x.Cmp(y) <= 0 means x <= y
- header = types.CopyHeader(header)
- header.Nonce = types.EncodeNonce(nonce)
- header.MixDigest = common.BytesToHash(digest)
- found<- block.WithSeal(header)
- return
- }
- }
- nonce++
- }
- }
這裡hashimotoFull()函式通過呼叫hashimoto()函式完成運算,而同時還有另外一個類似的函式hashimoLight()函式。
- func hashimotoFull(dataset []uint32, hash []byte, nonce uint64) ([]byte, []byte) {
- lookup := func(index uint32) []uint32 {
- offset := index * hashWords
- return dataset[offset : offset+hashWords]
- }
- return hashimoto(hash, nonce, uint64(len(dataset))*4, lookup)
- }
- func hashimotoLight(size uint64, cache []uint32, hash []byte, nonce uint64) ([]byte, []byte) {
- lookup := func(index uint32) []uint32 {
- rawData := generateDatasetItem(cache, index, keccak512)
- data := make([]uint32, len(rawData)/4)
- for i := 0; i < len(data); i++ {
- data[i] = binary.LittleEndian.Uint32(rawData[i*4:])
- }
- return data
- }
- return hashimoto(hash, nonce, size, lookup)
- }
以上兩個函式,最終都呼叫了hashimoto()。它們的差別,在於各自呼叫hashimoto()函式的入參@size uint 和 @lookup func()不同。相比於Light(),Full()函式呼叫的size更大,以及一個從更大陣列中獲取資料的查詢函式lookup()。hashimotoFull()函式是被Seal()呼叫的,而hashimotoLight()是為VerifySeal()準備的。
這裡的lookup()函式其實很重要,它其實是一個以非線性表查詢方式進行的雜湊函式! 這種雜湊函式的效能不僅取決於查詢的邏輯,更多的依賴於所查詢的表格的資料規模大小。lookup()會以函式型引數的形式傳遞給hashimoto()
核心的運算函式hashimoto()
最終為Ethash共識演算法的Seal過程執行運算任務的是hashimoto()函式,它的函式型別如下:
- // consensus/ethash/algorithm.go
- func hashimoto(hash []byte, nonce uint64, size uint64, lookup(index uint32) []uint32) (digest []byte, result []byte)
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()中生成。
- size = cacheSize(epoch * epochLength +1)
- ...
- func cacheSize(block uint64) uint64 {
- epoch := int(block / epochLength)
- if epoch < len(cacheSizes) {
- return cacheSizes[epoch]
- }
- size := uint64(cacheInitBytes + cacheGrowthBytes * uint64(epoch) - hashBytes)
- for !(size/hashBytes).ProbablyPrime(1) {
- size -= 2 * hashBytes
- }
- return size
- }
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()函式中生成。
- seed := seedHash(epoch * epochLength +1)
- ...
- func seedHash(block uint64) []byte {
- seed := make([]byte, 32)
- if block < epochLength { // epochLength = 30000
- return seed
- }
- keccak256 := makeHasher(sha3.NewKeccak256())
- for i := 0; i < int(block/epochLength); i++ {
- keccak256(seed, seed)
- }
- return seed
- }
- type hasher func(dest []byte, data []byte)
- func makeHasher(h hash.Hash) hasher {
- return func(dest []byte, data []byte) {
- h.Write(data)
- h.Sum(dest[:0])
- h.Reset()
- }
- }
generateCache()函式
generateCache()函式在給定種子陣列seed[]的情況下,對固定容量的一塊buffer