[以太坊源代碼分析]III. 挖礦和共識算法的奧祕

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

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中的總核數作爲開啓線程的個數。

/* 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++
    }
}
以上代碼就是mine()函數的主要業務邏輯。入參@id是線程編號,用來發送log告知上層;函數內部首先定義一組局部變量,包括之後調用hashimotoFull()時傳入的hash、nonce、巨大的輔助數組dataset,以及結果比較的target;然後是一個無限循環,每次調用hashimotoFull()進行一系列複雜運算,一旦它的返回值符合條件,就複製Header對象(深度拷貝),並賦值Nonce、MixDigest屬性,返回經過授權的區塊。注意到在每次循環運算時,nonce還會自增+1,使得每次循環中的計算都各不相同。

這裏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()函數的入參包括:區塊哈希值@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()中生成。

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的代碼,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()函數中生成。

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()
    }
}
上述seedHash()函數用來生成所需的seed[]數組,它的長度32bytes,與common.Address類型長度相同。makeHasher()函數利用入參的哈希函數接口生成一個哈希函數,這裏用了SHA3-256哈希函數。注意seedHash()中利用生成的哈希函數keccak256()對seed[]做的原地哈希,而原地哈希運算的次數跟當前區塊所處的epoch序號有關,所以每個不同的cache{}所用到的seed[]也是完全不同的,這個不同通過更多次的哈希運算來實現。

generateCache()函數

generateCache()函數在給定種子數組seed[]的情況下,對固定容量的一塊buffer進行一系列操作,使得buffer的數值分佈變得隨機、無規律可循,最終buffer作爲cache{}中的數組(非線性表)返回,還可作爲數據源幫助生成dataset{}。generateCache()函數主體分兩部分,首先用SHA3-512哈希函數作爲哈希生成器(hasher),對buffer數組分段(每次64bytes)進行哈希化,然後利用StrictMemoryHardFunction中的RandMemoHash算法對buffer再進行處理。這個RandMemoHash算法來自2014年密碼學方向的一篇學術論文,有興趣的朋友可以搜搜看。

內存映射

由於Ethash(PoW)算法中用到的隨機數據集cache{}和dataset{}過於龐大,將其以文件形式存儲在磁盤上就顯得很有必要。同樣由於這些文件過於龐大,使用時又需要一次性整體讀入內存(因爲對其的使用是隨意截取其中的一段數據),使用內存映射可以大大減輕I/O負擔。cache{}和dataset{}結構體中,均有一個mmap對象用以操作內存映射,以及一個系統文件對象dump,對應於打開的磁盤文件。


Ethash算法總結:

回看一下Ethash共識算法最基本的形態,如果把整個result[]的生成過程視作那個概念上的函數RAND(),則如何能更加隨機,分佈更加均勻的生成數組,關係到整個Ethash算法的安全性。畢竟如果result[]生成過程存在被破譯的途徑,那麼必然有方法可以更快地找到符合條件的數組,通過更快的挖掘出區塊,在整個以太坊系統中逐漸佔據主導。所以Ethash共識算法應用了非常複雜的一系列運算,包含了多次、多種不同的哈希函數運算:

  1. 大量使用SHA3哈希函數,包括256-bit和512-bit形式的,用它們來對數據(組)作哈希運算,或者充當其他更復雜哈希計算的某個原型 -- 比如調用makeHasher()。而SHA3哈希函數,是一種典型的可應對長度變化的輸入數據的哈希函數,輸出結果長度統一(可指定256bits或512bits)。
  2. lookup()函數提供了非線性表格查找方式的哈希函數,相關聯的dataset{}和cache{}規模巨大,其中數據的生成/填充過程中也大量使用哈希函數。
  3. 在一些計算過程中,有意將[]byte數組轉化爲uint32或uint64整型數進行操作(比如XOR,以及類XOR的FNV()函數)。因爲理論證實,在32位或64位CPU機器上,以32位/64位整型數進行操作時,速度更快。

Clique共識算法

Clique算法又稱Proof-of-Authortiy(PoA),它實現的Seal()其實是一個標準的數字簽名加密過程,可由下列公式表示:

n = F(pr, h)

其中F()是數字簽名函數,n是生成的數字簽名,pr是公鑰,h是被加密的內容。具體到Clique應用中,n是一個65 bytes長的字符串,pr是一個common.Address類型的(長度20 bytes)地址,h是一個common.Hash類型(32 bytes)的哈希值,而簽名算法F(),目前採用的正是橢圓曲線數字簽名算法(ECDSA)。

沒錯,就是這個被用來生成交易(Transaction)對象的數字簽名的ECDSA。在Clique的實現中,這裏用作公鑰的Address類型地址有一個限制,它必須是已認證的(authorized)。所以Clique.Seal()函數的基本邏輯就是:有一個Address類型地址打算用作數字簽名的公鑰(不是區塊的作者地址Coinbase);如果它是已認證的,則執行指定的數字簽名算法。而其中涉及到的動態管理所有認證地址的機制,纔是Clique算法(PoA)的精髓。

基於投票的地址認證機制

首先了解一下Clique的認證機制authorization所包括的一些設定:

  1. 所有的地址(Address類型)分爲兩類,分別是經過認證的,和未經過認證的。
  2. 已認證地址(authorized)可以變成未認證的,反之亦然。不過這些變化都必須通過投票機制完成。
  3. 一張投票包括:投票方地址,被投票地址,和被投票地址的新認證狀態。有效投票必須滿足:被投票地址的新認證地址與其現狀相反。
  4. 任意地值A只能給地址B投一張票

這些設定理解起來並不困難,把這裏的地址替換成平常生活中的普通個體,這就是個很普通的投票制度。Clique算法中的投票系統的巧妙之處在於,每張投票並不是某個投票方主動“投”出來的,而是隨機組合出來的。

想了解更多細節免不了要深入一些代碼,下圖是Clique算法中用到的一些結構體:


Clique結構體實現了共識算法接口Engine的所有方法,它可對區塊作Seal操作。它的成員signFn正是數字簽名生成函數,signer用作數字簽名的公鑰,這兩成員均由Authorize()函數進行賦值。它還有一個map類型成員proposals,用來存放所有的不記名投票,即每張投票只帶有被投票地址和投票內容(新認證狀態),由於是map類型,顯然這裏的proposals存放的是內容不同的不記名投票。API結構體對外提供方法,可以向Clique的成員變量proposals插入或刪除投票。

Snapshot結構體用來動態管理認證地址列表,在這裏認證地址是分批次存儲和更新的,一個Snapshot對象,存放的是以區塊爲時序的所有認證地址的"快照"。Snapshot的成員Number和Hash,正是區塊Block的標誌屬性;成員Signers存儲所有已認證地址。

一個Vote對象表示一張記名投票。Tally結構體用來記錄投票數據,即某個(被投票)地址總共被投了多少票,新認證狀態是什麼。Snapshot中用map型變量Tally來管理所有Tally對象數據,map的key是被投票地址,所以Snapshot.Tally記錄了被投票地址的投票次數。另外Snapshot還有一個Vote對象數組,記錄所有投票數據

新區塊的Coinbase是一個隨機的被投票地址

Engine接口的Prepare()方法,約定在Header創建後調用,用以對Header的一些成員變量賦值,比如作者地址Coinbase。在Clique算法中,新區塊的Coinbase來自於proposals中的某個被投票地址。


上圖解釋了Clique.Prepare()方法中的部分邏輯。首先從proposals中篩選出有效的不記名投票,要麼是已認證地址變爲未認證,要麼反過來;然後獲取有效的被投票地址列表,從中隨機選取一個地址作爲該區塊的Coinbase,與之相應的投票內容則被區塊的Nonce屬性攜帶。而新區塊的Coinbase會在之後的更新認證地址環節,被當作一次投票的被投票地址。

ps,Ethash算法中,新區塊的Coinbase地址可是異常重要的,因爲它會作爲新區塊的作者地址,被系統獎勵或補償以太幣。但Clique算法中就完全不同了,由於工作在測試網絡中,每個帳號地址獲得多少以太幣沒有實際意義,所以這裏的Coinbase任意賦值倒也無妨。

添加記名投票並更新認證地址列表

管理所有認證地址的結構體是Snapshot,具體到更新認證地址列表的方法是apply()。它的基本流程如下圖:


重溫一下Snapshot結構體內聲明的一組緩存成員變量:

Signers是全部已認證地址集合,注意這裏用map類型來提供Set的行爲。

Recents用來記錄最近擔當過數字簽名算法的signer的地址,通過它Snapshot可以控制某個地址不會頻繁的擔當signer。更重要的是,由於signer地址會充當記名投票的投票方,所以Recents可以避免某些地址頻繁的充當投票方!Recents中map類型的key是區塊Number值。

Votes記錄了所有尚未失效的記名投票;Tallies記錄了所有被投票地址(voted)目前的(被)投票次數。

Snapshot.apply()函數的入參是一組Header對象,它們來自區塊主鏈上按從舊到新順序排列的一組區塊,並且嚴格銜接在Snapshot當前狀態(成員Number,Hash)之後。注意,這些區塊都是當前“待挖掘”新區塊的祖先,所以它們的成員屬性都是已經確定的。apply()方法的主要部分是迭代處理每個Header對象,處理單個Header的流程如下:

  • 首先從數字簽名中恢復出簽名所用公鑰,轉化爲common.Address類型,作爲signer地址。數字簽名(signagure)長度65 bytes,存放在Header.Extra[]的末尾。
  • 如果signer地址是尚未認證的,則直接退出本次迭代;如果是已認證的,則記名投票+1。所以一個父區塊可添加一張記名投票,signer作爲投票方地址,Header.Coinbase作爲被投票地址,投票內容authorized可由Header.Nonce取值確定。
  • 更新投票統計信息。如果被投票地址的總投票次數達到已認證地址個數的一半,則通過之。
  • 該被投票地址的認證狀態立即被更改,根據是何種更改,相應的更新緩存數據,並刪除過時的投票信息。

在所有Header對象都被處理完後,Snapshot內部的Number,Hash值會被更新,表明當前Snapshot快照結構已經更新到哪個區塊了。

Snapshot.apply()方法在Clique.Seal()中被調用,具體位於運行數字簽名算法之前,以保證即將充當公鑰的地址可以用最新的認證地址列表加以驗證。


綜上所述,Clique算法在投票制度的安全性設計上完善了諸多細節:

  1. 外部參與不記名投票的方式是通過API.Propose(),Discard()來操作Clique.proposals。由於proposals是map類型,所以每個投票地址(map的key)在proposals中只能存在一份,這樣就杜絕了外部通過惡意操作Clique.proposals來影響不記名投票數據的企圖。
  2. 所有認證地址的動態更新,由一張張記名投票累計作用影響。而每張記名投票的投票方地址和投票內容(不記名投票),是以毫不相關、近乎隨機的方式組合起來的。所以,理論上幾乎不存在外部惡意調用代碼從而操縱記名投票數據的可能。同時,通過一些內部緩存(Snapshot.Recents),避免了某些signer地址過於頻繁的充當投票方地址。

雖然Clique共識算法並非作用在產品環境,但它依然很精巧的設計了完整的基於投票的選拔制度,很好的踐行了"去中心化"宗旨。這對於其他類型共識算法的設計,提供了一個不錯的樣本。

小結

本篇介紹了挖掘一個新區塊在代碼上的完整過程,從調用函數入口開始,沿調用過程一路向深,直至最終完成新區塊授權/封印的共識算法,並對兩種共識算法的設計思路和實現細節都進行了詳細講解。

  • 一般所說的“挖掘一個新區塊”其實包括兩部分,第一階段組裝出新區塊的所有數據成員,包括交易列表txs、叔區塊uncles等,並且所有交易都已經執行完畢,各帳號狀態更新完畢;第二階段對該區塊進行授勳/封印(Seal),沒有成功Seal的區塊不能被廣播給其他節點。第二階段所消耗的運算資源,遠超第一階段。
  • Seal過程由共識算法(consensus algorithm)族完成,包括Ethash算法和Clique算法兩種實現。前者是產品環境下真實採用的,後者是針對測試網絡(testnet)使用的。Seal()函數並不會增加或修改區塊中任何跟有效數據有關的部分,它的目的是通過一系列複雜的步驟,或計算或公認,選拔出能夠出產新區塊的個體。
  • Ethash算法(PoW)基於運算能力來篩選出挖掘區塊的獲勝者,運算過程中使用了大量、多次、多種的哈希函數,通過極高的計算資源消耗,來限制某些節點通過超常規的計算能力輕易形成“中心化”傾向。
  • Clique算法(PoA)利用數字簽名算法完成Seal操作,不過簽名所用公鑰,同時也是common.Address類型的地址必須是已認證的。所有認證地址基於特殊的投票地址進行動態管理,記名投票由不記名投票和投票方地址隨機組合而成,杜絕重複的不記名投票,嚴格限制外部代碼惡意操縱投票數據
  • 在實踐“去中心化”方面,以太坊還在區塊結構中增加了叔區塊(uncles)成員以加大計算資源的消耗,並通過在交易執行環節對叔區塊作者(挖掘者)的獎勵,以收益機制來調動網絡中各節點運算資源分佈更加均勻。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章