Switch-Router

以太坊源码分析—挖矿与共识

Published at 2018-04-14 | Last Update 2018-04-14

前言

挖矿(mine)是指矿工节点互相竞争生成新区块以写入整个区块链获得奖励的过程. 共识(consensus)是指区块链各个节点对下一个区块的内容形成一致的过程 在以太坊中, miner包向外提供挖矿功能,consensus包对外提供共识引擎接口

挖矿

miner包主要由miner.go worker.go agent.go 三个文件组成

  • Miner 负责与外部交互和高层次的挖矿控制
  • worker 负责低层次的挖矿控制 管理下属所有Agent
  • Agent 负责实际的挖矿计算工作

三者之间的顶层联系如下图所示:

Miner

Miner的定义如下

type Miner struct{
    mux *event.TypeMux 
    worker *worker
    coinbase common.Address
    eth  Backend
    engine consensus.Engine
    .... 
}

各字段作用如下, 其中标有的字段表示与Miner包外部有联系

  • mux 接收来自downloader模块的_StartEvent_ DoneEvent _FailedEvent_事件通知。在网络中,不可能只有一个矿工节点,当downloader开始从其他节点同步Block时,我们就没有必要再继续挖矿了.
  • eth 通过该接口可查询后台TxPool BlockChain ethdb的数据.举例来说,作为矿工,我们在生成一个新的Block时需要从TxPool中取出pending Tx(待打包成块的交易),然后将它们中的一部分作为新的Block中的Transaction
  • engine 采用的共识引擎,目前以太坊公网采用的是ethash,测试网络采用clique.
  • worker 对应的worker,从这里看出Miner和worker是一一对应的
  • coinbase 本矿工的账户地址,挖矿所得的收入将计入该账户
  • mining 标识是否正在挖矿

miner.New()创建一个Miner,它主要完成Miner字段的初始化和以下功能

  • 使用miner.newWorker()创建一个worker
  • 使用miner.newCpuAgent()创建Agent 并用Register方法注册给worker
  • 启动miner.update() 线程.该线程等待mux上的来自 downloader模块的事件通知用来控制挖矿开始或停止

worker

worker成员比较多,其中部分成员的意义如下

  • mux engine eth coinbase 这几项都来自与miner, 其中mux相对于Miner里的稍微有点不同, Miner里的mux是用来接收downloader的事件,而worker里用mux来向外部发布已经挖到新Block
  • txCh 从后台eth接收新的Tx的Channel
  • chainHeadCh 从后台eth接收新的Block的Channel
  • recv 从agents接收挖矿结果的Channel,注意,每个管理的agent都可能将挖出的Block发到该Channel,也就是说,这个收方向Channel是一对多的
  • agents 管理的所有Agent组成的集合

miner.newWorker() 创建一个worker,它除了完成各个成员字段的初始化,还做了以下工作

  • 向后台eth注册txCh chainHeadCh chainSideCh通道用来接收对应数据
  • 启动worker.update() 线程.该线程等待上面几个外部Channel 并作出相应处理
  • 启动worker.wait()线程.该线程等待Agent挖出的新Block
  • 调用worker.commitNewWork() 尝试启动新的挖掘工作

Agent

Agent(定义在worker.go)是一个抽象interface ,只要实现了其以下接口就可以充当worker的下属agent

type Agent interface {
    Work()   chan <-*Work
    SetReturnCh (chan<-*Result)
    Stop()
    Start()
    GetHashRate() int64
}

在agent.go中定义了CpuAgent作为一种Agent的实现,其主要成员定义如下

type CpuAgent struct {
      workCh      chan *Work
      stop        chan struct{}
      returnCh    chan<-*Result
      chain     consensus.ChainReader
      engine   consensus.Engine
}
  • workCh 接收来自worker下发的工作任务Work
  • returnChworker反馈工作任务的完成情况,实际上就是挖出的新Block
  • stop 使该CpuAgent停止工作的信号
  • chain 用于访问本地节点BlockChain数据的接口
  • engine 计算所采用的共识引擎 CpuAgent的创建函数中并没有启动新的线程, Agent的工作线程是由Agent.Start()接口启动的 在CpuAgent实现中,启动了CpuAgent.update()线程来监听workChstop信道
    func (self *CpuAgent) Start(){
       if !atomic.CompareAndSwapInt32(&self.isMining, 0, 1){
             return 
       }
       go self.update()
    }
    

    而Agent真正的挖矿工作是在收到工作任务’Work’后调用CpuAgent.mine()完成的

以上就是Miner worker Agent三者之间的联系,将它们画成一张图如下:

总结以下就是

  • Miner监听后台的数据
  • 需要挖矿时,worker发送给各个Agent工作任务Work, Agent挖出后反馈给worker

让我们顺着一次实际的挖掘工作看看一个Block是如何被挖掘出来的以及挖掘出之后的过程 从worker.commitNewWork()开始

1.parent Block是权威链上最新的Block 2.将标识矿工账户的Coinbase填入Header,这里生成的Header只是个半成品 3.对于ehtash来说,这里计算Block的Difficulty 4.工作任务Work 准确地说标识一次挖掘工作的上下文Context,在创建时,它包含了当前最新的各个账户信息state和2中生成的Header,在这个上下中可以通过调用work.commitTransactions()执行这些交易,这就是俗称的打包过程 5.矿工总是选择Price高的交易优先执行,因为这能使其获得更高的收益率,所以对于交易的发起者来说,如果期望自己的交易能尽快被所有人承认,他可以设置更高gasPrice以吸引矿工优先打包这笔交易 6.运行EVM执行这些交易 7.调用共识引擎的Finalize()接口 8.如此,一个Block的大部分原料都已经准备好了,下一步就是发送给Agent来将这个Block挖掘出来

Cpuagent收到Work后,调用mine()方法

func (self *CpuAgent) mine(work *Work, stop<-chan struct{}) {
        result, _  = self.engine.Seal(self.chain, work.Block, stop) 
        self.returnCh <- &Result{work,result}
}

可以看到实际上是调用的共识接口的Engine.Seal接口,挖掘的细节在后面共识部分详述,这里先略过这部分且不考虑挖矿被Stop的情景,Block被挖掘出来之后将通过CpuAgent.returnCh反馈给workerworkerwait线程收到接口后将结果写入数据库,通过worker.mux向外发布NewMinedBlockEvent事件,这样以太坊的其他在该mux上订阅了该事件组件就可以收到这个事件

共识

共识部分包含由consensus对外提供共识引擎的接口定义,当前以太坊有两个实现,分别是公网使用的基于POW的ethash包和测试网络使用的基于POA的clique

根据前文的分析,在挖矿过程中主要涉及Prepare() Finalize() Seal() 接口,三者的职责分别为 Prepare() 初始化新Block的Header Finalize() 在执行完交易后,对Block进行修改(比如向矿工发放挖矿所得) Seal() 实际的挖矿工作

ethash

ethash是基于POW(Proof-of-Work),即工作量证明,矿工消耗算力来求得一个nonce,使其满足难度要求HASH(Header) <= C / Diff,注意,这里的HASH是一个很复杂的函数,而nonce是Header的一个成员字段,一旦改变nonce,左边的结果将发生很大的变化。 C是一个非常大的常数,Diff是Block的难度,可由此可知,Diff越大,右式越小,要想找到满足不等式的nonce就越发的困难,而矿工正是消耗自己的算力去不断尝试nonce,如果找到就意味着他挖出这个区块。 本文不打算详述具体的HASH函数,感兴趣的读者可以参考官方文档https://github.com/ethereum/wiki/blob/master/Dagger-Hashimoto.md

Prepare()

ethash的Prepare()计算新Block需要达到的难度(Diffculty),这部分理论可见https://www.jianshu.com/p/9e56faac2437

Finalize()

ethash的Finalize()向矿工节点发放奖励,再Byzantium时期之前的区块,挖出的区块奖励是5 ETH ,之后的奖励3 ETH,这部分理论比较复杂,准备以后专门写一篇文章。

Seal()

下面来看看ethash具体是怎么实现Seal接口的

core/ethash/sealer.go
func (ethash *Ethash) Seal(chain consensus.ChainReader, block *types.Block, stop<-chan struct{})(*types.Block, error){
   ......
   abort := make(chan struct{})
   found:= make(chan *types.Blocks)
   threads:= runtime.NumCPU()
   for i := 0; i < threads; i++ {
        go func(id int, nonce uint64){
             ethash.mine(block,id,nonce,abort,found)
        }(i, uint64(ethash.rand.Int63()))
   }
   var result *type.Block
   select{
       case <- stop:
       ....
       case result<-found:
       close(abort)
    }
    return result, nil
}

可以看到,ethash启动了多个线程调用mine()函数,当有线程挖到Block时,会通过传入的found通道传出结果。

core/ethash/sealer.go
func (ethash *Ethash) mine(block *types.Block, id int, 
seed uint64, abort chan struct{}, found chan *types.Block) {
.....
search:
    for {
        select {
            case <-abort:	
            ......
            default:
	        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)
                // Seal and return a block (if still needed)
                select {
                    case found <- block.WithSeal(header):
                    ......
                    case <-abort:
                }
                break search
            }
            nonce++
         }
    }
......

可以看到,在主要for循环中,不断递增nonce的值,调用hashimotoFull()函数计算上面公式中的左边,而target则是公式的右边。当找到一个nonce使得左式<=右式时,挖矿结束,nonce填到header.Nonce

clique

以太网社区为开发者提供了基于POA(proof on Authortiy)的clique共识算法。与基于POS的ethash不同的是,clique挖矿不消耗矿工的算力。在clique中,节点分为两类:

  • 经过认证(Authorized)的节点,在源码里称为signer,具有生成(签发)新区块的能力,对应网络里的矿工
  • 未经过认证的节点,对应网络里的普通节点 在ethash中,矿工的账户地址存放在Header的Coinbase字段,但在clique中,这个字段另有他用。那么如何知道一个Block的挖掘者呢?答案是,矿工用自己的私钥对Block进行签名(Signature),存放在Header的Extra字段,其他节点收到后,可以从这个字段提取出数字签名以及签发者(signer)的公钥,使用这个公钥可以计算出矿工(即signer)的账户地址。 一个节点a的认证状态可以互相转换,每个signer在签发Block时,可以附带一个提议(purposal),提议另一个本地记录为非认证的节点b转变为认证节点,或者相反。网络中的其他节点c收到这个提议后,将其转化为一张选票(Vote),如果支持节点的选票超过了节点c本地记录的signer数量的一半,那么节点c就承认节点b是signer

clique包由api.go clique.go snapshot.go三个文件组成 其中api.go中是一些提供给用户的命令行操作,比如用户可以输入以下命令表示他支持b成为signer

clique.propose("账户b的地址", true)

clique.gosnapshot.go中分别定义两个重要的数据结构CliqueSnapshot Clique数据结构的主要成员定义如下

type  Clique struct {
    config *params.CliqueConfig
    recents      *lru.ARCCache
    signatures   *lrn.ARCCache
    proposals   map[common.Address]bool
    signer common.Address
    signFn  SignerFn
    ......
}
  • config 包含两个配置参数,其中Period设置模拟产生新Block的时间间隔,而Epoch表示每隔一定数量的Block就要把当前的投票结果清空并存入数据库,这么做是为了防止节点积压过多的投票信息,类似于单机游戏中的存档
  • recents 缓存最近访问过的Snapshot,查询的key为Block的Hash值,详见之后的Snapshot
  • signatures 缓存最近访问过的Block的signer,查询的key为Block的Hash值
  • proposals 本节点待附带的提议池,用户通过propose()命名提交的提议会存放在这里,当本节点作为矿工对一个Block进行签名时,会随机选择池中的一个提议附带出去
  • signer 矿工节点的账户地址,意义上与ethash中的Coinbase类似
  • signFn 数字签名函数,它和signer都由Clique.Authorize()进行设置,后者在eth/backend.go中的StartMining()中被调用

Snapshot翻译过来是快照,它记录了区块链在特定的时刻(即特定的区块高度)本地记录的认证地址列表,举个栗子,Block#18731的Snapshot记录了网络中存在3个signer分别为a\b\c,且a已经支持另一个节点d成为signer(a投了d一张支持票),当Block#18732的挖掘者b也支持d时,Block#18732记录的signer就会增加d的地址

type Snapshot struct{
    sigcache  *lru.ARCCache
    Number    uint64
    Hash    Common.Hash
    Signers map[Common.Address] struct{}
    Recents  map[uint64]common.Address
    Votes    []*Vote
    Tally    map[common.Address]Tally
}
  • sigcache 缓存最近访问过的signer,key为Block的Hash值
  • Number 本Snapshot对应的Block的高度,在创建时确定
  • Hash 本Snapshot对应的Block的Hash,在创建时确定
  • Signers 本Snapshot对应时刻网络中认证过的节点地址(矿工),在创建时确定
  • Recents 最近若干个Block的signer的集合,即挖出区块的矿工
  • Votes 由收到的有效proposal计入的选票集合,每张选票记录了投票人/被投票人/投票意见 这里的有效有两层意思
    • 投票人是有效的的,首先他是signer(在Snapshot.Signers中),并且他不能频繁投票(不在 Snapshot.Recents中)
    • 被投票人是有效的,被投票人的当前认证状态与选票中携带的意见不同
  • Tally 投票结果map,key为被投票人地址,value为投票计数

Prepare()

Prepare()的实现分为两部分

func (c *Clique) Prepare(chain consensus.ChainReader, header *types.Header){
    header.Coinbase = common.Address{}
    header.Nonce = types.BlockNonce{}
    number := header.Number.Uint64()

    snap, err := c.snapshot(chain, num-1, header.ParentHash, nil)
    if number % c.config.Epoch {
        addresses := make ([]common.Address)
        for address, authorize := range c.proposals{
            addresses = append(addresses, address)
        }
        header.Coinbase = addresses[rand.Intn(len(addresses))]
        if c.proposals[header.Coinbase] {
            copy(header.Nonce[:], nonceAuthVote)
        }  else {
            copy(header.Nonce[:], nonceDropVote)
        }
    }
    ......

首先获取上一个Block的Snapshot,它有以下几个获取途径

  • Clique的缓存
  • 如果Block的高度恰好是在checkpoint 就可从数据库中读取
  • 由一个之前已有的Snapshot经过这之间的所有Header推算出来

接下来随机地将本地proposal池中的一个目标节点地址放到Coinbase (注意在ethash中,这个字段填写的是矿工地址) 由于Clique不需要消耗算力,也就不需要计算nonce,因此在Clique中,Header的Nonce的字段被用来表示对目标节点投票的意见

func (c *Clique) Prepare(chain consensus.ChainReader, header *types.Header){
   ......
   header.Difficulty = CalcDifficulty(snap, c.signer)
   header.Extra  = append(header.Extra, make([]byte, extraSeal))
   ......

接下来填充Header中的Difficulty字段,在Clique中这个字段只有 12 两个取值,取决与本节点是否inturn,这完全是测试网络为了减少Block区块生成冲突的一个技巧,因为测试网络不存在真正的计算,那么如何确定下一个Block由谁确定呢?既然都一样,那就轮流坐庄,inturn的意思就是自己的回合,我们知道,区块链在生成中很容易出现短暂的分叉(fork),其中难度最大的链为权威(canonocal)链,因此如果一个节点inturn,它就把难度设置为 2 ,否则设置为 1

前面提到过在Clique中,矿工的地址不是存放在Coinbase,而是将自己对区块的数字签名存放在Header的Extra字段,可以看到在Prepare()接口中为数字签名预留了Extra的后 65 bytes

Finalize()

cliqueFinalize()操作比较简单,就是计算了一下Header的Root Hash值

Seal()

Seal()接口相对ethash的实现来说比较简单 (省略了一些检查)

func (c *Clique) Seal (chain consensus.ChainReader, block *type.Block, stop <-chan struct{})  (*types.Block, error) {
    header := block.Header()
    signer, signFn := c.signer, c.signFn
    snap, err := c.snapshot(chain, number-1, header.ParentHash, nil)
    delay := time.Unix(header.Time.Int64(), 0).Sub(time.Now())
    ......
    select {
    case <- stop:
        return nil, nil
    case <-time.After(delay):
    }
    
    sighash, err := signFn(accounts.Account{Address:signer}, sigHash(header).Bytes())
    copy(header.Extra[len(header.Extra) - extraSeal:], sighash)
    return block.WithSeal(header), nil
}

总的来说就是延迟了一定时间后对Block进行签名,然后将自己的签名存入header的Extra字段的后 65 bytes,为了减少冲突,对于不是inturn的节点还会多延时一会儿,上面的代码我省略了这部分

总结

  1. 挖矿的框架由miner包提供,期间使用了consensus包完成新的Block中一些字段的填充,总的来说挖矿分为打包交易挖掘两个阶段
  2. 以太坊目前实现了ethashclique两套共识接口实现,分别用于公网环境和测试网络环境,前者消耗算力,后者不消耗。并且,他们对于Header中的字段的一些意义也不尽相同。!