以太坊源碼分析之共識算法ethash

區塊鏈是做爲分佈式系統來構建的,因爲它們不依賴於一箇中央權威,所以分散的節點須要就交易的有效與否達成一致,而達成一致的機制即是共識算法。node

以太坊目前的算法是相似POW的算法:ethash。它除了和比特幣同樣須要消耗電腦資源外進行計算外,還考慮了對專用礦機的抵制,增長了挖礦的公平性。算法

通常的POW算法思路

POW即工做量證實,也就是經過工做結果來證實你完成了相應的工做。 它的初衷但願全部人可參與,門檻低(驗證容易),可是獲得結果難(計算複雜)。在這一點上,只匹配部分特徵的hash算法(不可逆)很是符合要求。數組

經過不斷地更換隨機數來獲得哈希值,比較是否小於給定值(難度),符合的即爲合適的結果。 隨機數通常來自區塊header結構裏的nonce字段。 由於出塊時間是必定的,但整體算力是不肯定的,因此難度通常會根據時間來調整。緩存

ethash算法的思路

ethash與pow相似,數據來源除了像比特幣同樣來自header結構和nonce,還有本身定的一套數據集dataset。 精簡後的核心代碼以下:bash

func (ethash *Ethash) mine(block *types.Block, id int, seed uint64, abort chan struct{}, found chan *types.Block) {
	var (
		header  = block.Header()
		hash    = ethash.SealHash(header).Bytes()
		target  = new(big.Int).Div(two256, header.Difficulty)
		number  = header.Number.Uint64()
		dataset = ethash.dataset(number, false)
	)
	var (
		attempts = int64(0)
		nonce    = seed
	)
	for {
			attempts++
			digest, result := hashimotoFull(dataset.dataset, hash, nonce)
			if new(big.Int).SetBytes(result).Cmp(target) <= 0 {
				// Correct nonce found, create a new header with it
				header = types.CopyHeader(header)
				header.Nonce = types.EncodeNonce(nonce)
				header.MixDigest = common.BytesToHash(digest)
				...
			}
            ...
      }
      ...
}
複製代碼
for i := 0; i < threads; i++ {
		pend.Add(1)
		go func(id int, nonce uint64) {
			defer pend.Done()
			ethash.mine(block, id, nonce, abort, locals)
		}(i, uint64(ethash.rand.Int63()))
	}
複製代碼

在miner方法中hashimotoFull返回result,result <= target,則計算到合適的nonce了(挖到礦了,礦工的努力終於沒有白費哈哈)。而target則是2^256/難度,result的來源則是隨機數nonce,區塊頭的hash值以及數據集dataset(另外,另外一個返回值摘要digest存儲在區塊頭,用來和驗證獲得的digest進行覈對)。網絡

說完了總體,下面將重點對dataset和header.Difficulty進行具體分析。app

該dataset是ethash的主要部分,主要是爲了抵抗ASIC礦機的。由於生成的dataset很大(初始就有1G),因此該算法的性能瓶頸不在於cpu運算速度,而在於內存讀取速度。大內存是昂貴的,而且普通計算機現有內存也足夠跑了,經過內存來限制,去除專用硬件的運算優點。less

ethash是Dagger-Hashimoto算法的變種,因爲ethash對原來算法的特徵改變很大,因此不介紹算法的原理了。只結合現有的ethash源碼,對生成dataset和使用dataset,分紅Dagger和Hashimoto兩部分討論。dom

Dagger

Dagger是用來生成dataset的。 async

如圖所示:

  1. 對於每個區塊,都能經過掃描區塊頭的方式計算出一個種子( seed ),該種子只與當前區塊有關。
  2. 使用種子能產生一個16MB 的僞隨機緩存(cache),輕客戶端會存儲緩存。
  3. 基於緩存再生成一個1GB的數據集(dataset),數據集中的每個元素都只依賴於緩存中的某幾個元素,也就是說,只要有緩存,就能夠快速地計算出數據集中指定位置的元素。挖礦者存儲數據集,數據集隨時間線性增加。
  • 在代碼中生成cache的部分

如圖所示:

  1. 數字標記表明調用入口,其中三、4表明MakeCache和MakeDataset,是geth提供的命令,用於生成cache和dataset(生成dataset要先生成cache)。
  2. 重點是數字1和2表明的挖礦(ethash.mine)和驗證(ethash.verifySeal)。mine走的是生成dataset的方式,後面再介紹。verifySeal若是是全量級驗證則和mine同樣。若是是輕量級驗證,則不會生成完整的dataset,而是生成cache,最終的調用交給算法模塊裏的generateCache完成。

verifySeal 全量級驗證部分

if fulldag {
		dataset := ethash.dataset(number, true)
		if dataset.generated() {
			digest, result = hashimotoFull(dataset.dataset, ethash.SealHash(header).Bytes(), header.Nonce.Uint64())

			// Datasets are unmapped in a finalizer. Ensure that the dataset stays alive
			// until after the call to hashimotoFull so it's not unmapped while being used.
			runtime.KeepAlive(dataset)
		} else {
			// Dataset not yet generated, don't hang, use a cache instead
			fulldag = false
		}
	}
複製代碼

verifySeal 輕量級驗證部分

if !fulldag {
		cache := ethash.cache(number)

		size := datasetSize(number)
		if ethash.config.PowMode == ModeTest {
			size = 32 * 1024
		}
		digest, result = hashimotoLight(size, cache.cache, ethash.SealHash(header).Bytes(), header.Nonce.Uint64())

		// Caches are unmapped in a finalizer. Ensure that the cache stays alive
		// until after the call to hashimotoLight so it's not unmapped while being used.
		runtime.KeepAlive(cache)
	}
複製代碼

generateCache生成cache:大體思路是在給定種子數組seed[]的狀況下,對固定容量的一塊buffer(即cache)進行一系列操做,使得buffer的數值分佈變得隨機、無規律可循。

func generateCache(dest []uint32, epoch uint64, seed []byte) {
	log.Info("luopeng2 generateCache")
	// Print some debug logs to allow analysis on low end devices
	logger := log.New("epoch", epoch)

	start := time.Now()
	defer func() {
		elapsed := time.Since(start)

		logFn := logger.Debug
		if elapsed > 3*time.Second {
			logFn = logger.Info
		}
		logFn("Generated ethash verification cache", "elapsed", common.PrettyDuration(elapsed))
	}()
	// Convert our destination slice to a byte buffer
	header := *(*reflect.SliceHeader)(unsafe.Pointer(&dest))
	header.Len *= 4
	header.Cap *= 4
	cache := *(*[]byte)(unsafe.Pointer(&header))

	// Calculate the number of theoretical rows (we'll store in one buffer nonetheless)
	size := uint64(len(cache))
	rows := int(size) / hashBytes

	// Start a monitoring goroutine to report progress on low end devices
	var progress uint32

	done := make(chan struct{})
	defer close(done)

	go func() {
		for {
			select {
			case <-done:
				return
			case <-time.After(3 * time.Second):
				logger.Info("Generating ethash verification cache", "percentage", atomic.LoadUint32(&progress)*100/uint32(rows)/4, "elapsed", common.PrettyDuration(time.Since(start)))
			}
		}
	}()
	// Create a hasher to reuse between invocations
	keccak512 := makeHasher(sha3.NewLegacyKeccak512())

	// Sequentially produce the initial dataset
	keccak512(cache, seed)
	for offset := uint64(hashBytes); offset < size; offset += hashBytes {
		keccak512(cache[offset:], cache[offset-hashBytes:offset])
		atomic.AddUint32(&progress, 1)
	}
	// Use a low-round version of randmemohash
	temp := make([]byte, hashBytes)

	for i := 0; i < cacheRounds; i++ {
		for j := 0; j < rows; j++ {
			var (
				srcOff = ((j - 1 + rows) % rows) * hashBytes
				dstOff = j * hashBytes
				xorOff = (binary.LittleEndian.Uint32(cache[dstOff:]) % uint32(rows)) * hashBytes
			)
			bitutil.XORBytes(temp, cache[srcOff:srcOff+hashBytes], cache[xorOff:xorOff+hashBytes])
			keccak512(cache[dstOff:], temp)

			atomic.AddUint32(&progress, 1)
		}
	}
	// Swap the byte order on big endian systems and return
	if !isLittleEndian() {
		swap(cache)
	}
}
複製代碼
  • 在代碼中生成dataset的部分

如圖所示:

  • 調用入口與上圖相似。
  • mine生成dataset,通過一系列的調用到算法模塊裏的generateDataset,由它負責生成整個數據集,而Dataset細分紅DatasetItem,具體的item則交給generateDatasetItem完成。verifySeal若是是全量級驗證也同樣。
  • verifySeal若是是輕量級則比較特殊,它用到的時候才生成指定的數據集。它會在算法模塊裏的hashimotoLight(後面再介紹)裏用以前生成的cache調用generateDataItem生成指定的DatasetItem參與計算。

mine中獲得dataset的代碼

var (
		header  = block.Header()
		hash    = ethash.SealHash(header).Bytes()
		target  = new(big.Int).Div(two256, header.Difficulty)
		number  = header.Number.Uint64()
		dataset = ethash.dataset(number, false)
	)
複製代碼

generateDataset函數生成整個dataset:大體思路是將數據分紅多段,使用多個goroutine調用generateDatasetItem生成全部的。

func generateDataset(dest []uint32, epoch uint64, cache []uint32) {
	// Print some debug logs to allow analysis on low end devices
	logger := log.New("epoch", epoch)

	start := time.Now()
	defer func() {
		elapsed := time.Since(start)

		logFn := logger.Debug
		if elapsed > 3*time.Second {
			logFn = logger.Info
		}
		logFn("Generated ethash verification cache", "elapsed", common.PrettyDuration(elapsed))
	}()

	// Figure out whether the bytes need to be swapped for the machine
	swapped := !isLittleEndian()

	// Convert our destination slice to a byte buffer
	header := *(*reflect.SliceHeader)(unsafe.Pointer(&dest))
	header.Len *= 4
	header.Cap *= 4
	dataset := *(*[]byte)(unsafe.Pointer(&header))

	// Generate the dataset on many goroutines since it takes a while
	threads := runtime.NumCPU()
	size := uint64(len(dataset))

	var pend sync.WaitGroup
	pend.Add(threads)

	var progress uint32
	for i := 0; i < threads; i++ {
		go func(id int) {
			defer pend.Done()

			// Create a hasher to reuse between invocations
			keccak512 := makeHasher(sha3.NewLegacyKeccak512())

			// Calculate the data segment this thread should generate
			batch := uint32((size + hashBytes*uint64(threads) - 1) / (hashBytes * uint64(threads)))
			first := uint32(id) * batch
			limit := first + batch
			if limit > uint32(size/hashBytes) {
				limit = uint32(size / hashBytes)
			}
			// Calculate the dataset segment
			percent := uint32(size / hashBytes / 100)
			for index := first; index < limit; index++ {
				item := generateDatasetItem(cache, index, keccak512)
				if swapped {
					swap(item)
				}
				copy(dataset[index*hashBytes:], item)

				if status := atomic.AddUint32(&progress, 1); status%percent == 0 {
					logger.Info("Generating DAG in progress", "percentage", uint64(status*100)/(size/hashBytes), "elapsed", common.PrettyDuration(time.Since(start)))
				}
			}
		}(i)
	}
	// Wait for all the generators to finish and return
	pend.Wait()
}
複製代碼

generateDataItem函數獲得指定的dataset:大體思路是計算出cache數據的索引,經過fnv聚合算法將cache數據混入,最後獲得dataItem。

func generateDatasetItem(cache []uint32, index uint32, keccak512 hasher) []byte {
	//log.Info("luopeng1 generateDatasetItem")
	// Calculate the number of theoretical rows (we use one buffer nonetheless)
	rows := uint32(len(cache) / hashWords)

	// Initialize the mix
	mix := make([]byte, hashBytes)

	binary.LittleEndian.PutUint32(mix, cache[(index%rows)*hashWords]^index)
	for i := 1; i < hashWords; i++ {
		binary.LittleEndian.PutUint32(mix[i*4:], cache[(index%rows)*hashWords+uint32(i)])
	}
	keccak512(mix, mix)

	// Convert the mix to uint32s to avoid constant bit shifting
	intMix := make([]uint32, hashWords)
	for i := 0; i < len(intMix); i++ {
		intMix[i] = binary.LittleEndian.Uint32(mix[i*4:])
	}
	// fnv it with a lot of random cache nodes based on index
	for i := uint32(0); i < datasetParents; i++ {
		parent := fnv(index^i, intMix[i%16]) % rows
		fnvHash(intMix, cache[parent*hashWords:])
	}
	// Flatten the uint32 mix into a binary one and return
	for i, val := range intMix {
		binary.LittleEndian.PutUint32(mix[i*4:], val)
	}
	keccak512(mix, mix)
	return mix
}
複製代碼
  • seed、cache和dataset的關係

前面或多或少說到了,不過爲了脈絡更清晰,着重梳理一下。 dataset是最終參與hashimoto的數據集,因此要獲得dataset必須獲得cache。因此不論是經過generateDataset函數獲得dataset(所有的),仍是generateDatasetItem函數datasetItem(指定部分),它都來源於cache,而cache則來源於seed。 但因爲seed是來源於指定的block而且加工處理規則是固定的,因此其餘節點依然能夠根據block-->seed-->cache-->dataset,生成一致的結果。

image.png

今後圖即可以看出,不論是挖礦的節點仍是驗證的節點(全量級驗證),生成dataset調用的方法是相同的,參數number區塊高度相同,那麼dataset也相同(參數async在此處不影響,爲true則會在後臺生成dataset)。

Hashimoto

Hashimoto聚合數據集 、區塊頭 hash和nonce生成最終值。

如圖所示:

  • mine和走全量級的verifySeal會調到算法模塊裏的hashimotoFull,因爲已經獲得dataset了,它將從dataset裏面取指定的dataset,最終調用hashimoto。
  • 而輕量級驗證的verifySeal會調到算法模塊裏的hashimotoLight,與full不一樣的是,它從參數cache裏調用generateDatasetItem獲得指定的dataset,最終調用hashimoto。

hashimotoFull函數

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)
}
複製代碼

hashtoLight函數

func hashimotoLight(size uint64, cache []uint32, hash []byte, nonce uint64) ([]byte, []byte) {
	keccak512 := makeHasher(sha3.NewLegacyKeccak512())

	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函數:將header hash、nonce、dataset屢次哈希聚合獲得最終值result

func hashimoto(hash []byte, nonce uint64, size uint64, lookup func(index uint32) []uint32) ([]byte, []byte) {
	// Calculate the number of theoretical rows (we use one buffer nonetheless)
	rows := uint32(size / mixBytes)

	// Combine header+nonce into a 64 byte seed
	seed := make([]byte, 40)
	copy(seed, hash)
	binary.LittleEndian.PutUint64(seed[32:], nonce)

	seed = crypto.Keccak512(seed)
	seedHead := binary.LittleEndian.Uint32(seed)

	// Start the mix with replicated seed
	mix := make([]uint32, mixBytes/4)
	for i := 0; i < len(mix); i++ {
		mix[i] = binary.LittleEndian.Uint32(seed[i%16*4:])
	}
	// Mix in random dataset nodes
	temp := make([]uint32, len(mix))

	for i := 0; i < loopAccesses; i++ {
		parent := fnv(uint32(i)^seedHead, mix[i%len(mix)]) % rows
		for j := uint32(0); j < mixBytes/hashBytes; j++ {
			copy(temp[j*hashWords:], lookup(2*parent+j))
		}
		fnvHash(mix, temp)
	}
	// Compress mix
	for i := 0; i < len(mix); i += 4 {
		mix[i/4] = fnv(fnv(fnv(mix[i], mix[i+1]), mix[i+2]), mix[i+3])
	}
	mix = mix[:len(mix)/4]

	digest := make([]byte, common.HashLength)
	for i, val := range mix {
		binary.LittleEndian.PutUint32(digest[i*4:], val)
	}
	return digest, crypto.Keccak256(append(seed, digest...))
}
複製代碼

consensus\ethash\algorithm.go裏面的generateCache、generateDataset、generateDatasetItem、hashimoto函數這裏只提供大體的思路並無深究,由於它涉及到了一些密碼學算法,單獨看代碼我也沒看懂。另外這部分細節對總體理解ethash算法沒有太大影響,往後研究密碼學算法再分析這裏好了。

Difficulty

func CalcDifficulty(config *params.ChainConfig, time uint64, parent *types.Header) *big.Int {
	next := new(big.Int).Add(parent.Number, big1)
	switch {
	case config.IsConstantinople(next):
		return calcDifficultyConstantinople(time, parent)
	case config.IsByzantium(next):
		return calcDifficultyByzantium(time, parent)
	case config.IsHomestead(next):
		return calcDifficultyHomestead(time, parent)
	default:
		return calcDifficultyFrontier(time, parent)
	}
}
複製代碼

以太坊難度通過多個階段的調整,會根據區塊高度所在的階段使用該階段的計算公式。因爲目前是君士坦丁堡階段,因此只對該代碼進行說明。

君士坦丁堡計算難度的函數是makeDifficultyCalculator,因爲註釋裏面已經包含了相應的公式了,代碼就不貼出來了,梳理以下:

step = parent_diff // 2048
direction = max((2 if len(parent.uncles) else 1) - ((timestamp - parent.timestamp) // 9), -99)
periodCount = (block.number - (5000000-1)) if (block.number >= (5000000-1)) else 0
diff = parent_diff + step *direction+ 2^(periodCount - 2)

diffculty計算分爲兩部分:

1. 動態線性調整:parent_diff + step *direction

這部分難度值是在父塊的基礎上進行微調的。調整的單位是step,根據如今的塊與父塊的時間間隔,以及是否存在叔塊獲得一個單位調整幅度direction。step *direction即爲調整值。 也就是說若是時間間隔過小,direction爲正,難度會增長一點,間隔過小,難度會減小一點。爲了防止總是出叔塊,會將時間拉長一點,難度增長。若出現意外狀況或程序有漏洞致使難度大大下降,最低也有一個閥值(-99)。 值得注意的是,除了direction有最低閥值,parent_diff + step *direction也存在一個最低難度。

// minimum difficulty can ever be (before exponential factor)
		if x.Cmp(params.MinimumDifficulty) < 0 {
			x.Set(params.MinimumDifficulty)
		}
複製代碼
MinimumDifficulty      = big.NewInt(131072) // The minimum that the difficulty may ever be.
複製代碼

2. 指數增加:2^(periodCount - 2)

指數增加這部分在以太坊中叫作難度炸彈(也稱爲冰河時代)。因爲以太坊的共識算法將從pow過渡到pos,設計一個難度炸彈,會讓挖礦難度愈來愈大以致於不可能出塊。這樣礦工們就有個預期,平和過渡到pos。 目前在君士坦丁堡階段,periodCount減去數字5000000,是爲了防止指數部分增加過快,也就是延遲難度炸彈生效。

關於header.Difficulty具體的難度分析,這篇文章分析得很好。

測試結果

我本地私有鏈當前區塊高度是2278,配置的君士坦丁堡起始高度是5,即已進入君士坦丁堡階段。 剛啓動程序挖礦時,由於沒有其餘節點挖礦,因此上一區塊時間與當前區塊時間間隔相差很大,direction被設置成-99。而區塊高度未超過5000000,指數部分的結果爲0,父難度是689054,則最終難度是655790。

image.png

而一段時間後,direction基本上是在-一、0、1之間徘徊,挖礦的時間間隔平均也就十幾秒。

image.png

INFO [06-09|17:49:46.059] luopeng2                                 block_timestamp - parent_timestamp=25
INFO [06-09|17:49:53.529] luopeng2                                 block_timestamp - parent_timestamp=7
INFO [06-09|17:51:19.667] luopeng2                                 block_timestamp - parent_timestamp=18
INFO [06-09|17:51:23.387] luopeng2                                 block_timestamp - parent_timestamp=4
INFO [06-09|18:46:02.608] luopeng2                                 block_timestamp - parent_timestamp=31
INFO [06-09|18:46:18.575] luopeng2                                 block_timestamp - parent_timestamp=1
複製代碼

目前以太坊網絡情況正常,算力穩定,ethgasstation 網站上查到的時間間隔也與本身本地狀況差很少

image.png

總結

綜述,ethash基本思路和比特幣的pow相似,都是不斷隨機nonce獲得的值與難度進行比較,知足條件則挖礦成功,不然繼續嘗試。與比特幣比拼cpu算力不一樣的是,ethash經過生成一個巨大的數據集,經過限制內存來防止具有強大算力的ASIC礦機壟斷,加強了去中心化能力。

參考資料

相關文章
相關標籤/搜索