上一篇文章中,有介绍是如何发出挖矿工作信号的。当有了挖矿信号后,就可以开始挖矿了。
先回头看看,在讲解挖矿的第一篇文章中,有讲到挖矿流程。这篇文章将讲解挖矿中的各个环节。
在继续了解挖矿过程之前,先了解几个miner方法的作用。
什么时候可以进行挖矿?如下图所述,挖矿启动工作时由 mainLoop 中根据三个信号来管理。首先是新工作启动信号(newWorkCh)、再是根据新交易信号(txsCh)和最长链链切换信号(chainSideCh)来管理挖矿。
三种信号,三种管理方式。
这个信号,意思非常明确。一旦收到信号,立即开始挖矿。
//miner/worker.go:409
case req := <-w.newWorkCh:
w.commitNewWork(req.interrupt, req.noempty, req.timestamp)
这个信号的来源,已经在上一篇文章 挖矿工作信号监控中讲解。信号中的各项信息也来源与外部,这里仅仅是忠实地传递意图。
在交易池文章中有讲到,交易池在将交易推入交易池后,将向事件订阅者发送 NewTxsEvent。在 miner 中也订阅了此事件。
worker.txsSub = eth.TxPool().SubscribeNewTxsEvent(worker.txsCh)
当接收到新交易信号时,将根据挖矿状态区别对待。当尚未挖矿(!w.isRunning()
),但可以挖矿w.current != nil
时❶,将会把交易提交到待处理中。
//miner/worker.go:451
case ev := <-w.txsCh:
if !w.isRunning() && w.current != nil {//❶
w.mu.RLock()
coinbase := w.coinbase
w.mu.RUnlock()
txs := make(map[common.Address]types.Transactions)
for _, tx := range ev.Txs {//❷
acc, _ := types.Sender(w.current.signer, tx)
txs[acc] = append(txs[acc], tx)
}
txset := types.NewTransactionsByPriceAndNonce(w.current.signer, txs)//❸
w.commitTransactions(txset, coinbase, nil)//❹
w.updateSnapshot()//❺
} else {
if w.config.Clique != nil && w.config.Clique.Period == 0 {//❻
w.commitNewWork(nil, false, time.Now().Unix())
}
}
atomic.AddInt32(&w.newTxs, int32(len(ev.Txs)))//❼
首先,将新交易按发送者分组❷后,根据交易价格和Nonce值排序❸。形成一个有序的交易集后,依次提交每笔交易❹。最新完毕后将最新的执行结果进行快照备份❺。当正处于 PoA挖矿,右允许无间隔出块时❻,则将放弃当前工作,重新开始挖矿。
最后,不管何种情况都对新交易数计加❼。但实际并未使用到数据量,仅仅是充当是否有进行中交易的一个标记。
总得来说,新交易信息并不会干扰挖矿。而仅仅是继续使用当前的挖矿上下文,提交交易。也不用考虑交易是否已处理, 因为当交易重复时,第二次提交将会失败。
###最长链链切换信号
当一个区块落地成功后,有可能是在另一个分支上。当此分支的挖矿难度大于当前分支时,将发生最长链切换。此时 miner 将需要订阅从信号,以便更新叔块信息。
//miner/worker.go:412
case ev := <-w.chainSideCh:
if _, exist := w.localUncles[ev.Block.Hash()]; exist {//❶
continue
}
if _, exist := w.remoteUncles[ev.Block.Hash()]; exist {
continue
}
if w.isLocalBlock != nil && w.isLocalBlock(ev.Block) {//❷
w.localUncles[ev.Block.Hash()] = ev.Block
} else {
w.remoteUncles[ev.Block.Hash()] = ev.Block
}
if w.isRunning() && w.current != nil && w.current.uncles.Cardinality() < 2 {//❸
start := time.Now()
if err := w.commitUncle(w.current, ev.Block.Header()); err == nil {//❹
var uncles []*types.Header
w.current.uncles.Each(func(item interface{}) bool {
//...
})
w.commit(uncles, nil, true, start)//❺
}
}
短时间内,分支切换可能是频繁的。挖矿一直再相互竞争。如果接受到的区块,已经在叔块集中则忽略❶,没有则记录到叔块中❷。因为区块奖励是包含叔块奖励的,因此如果还在挖矿中,而叔块数量不到2个时❸。可以不再处理交易,一旦此区块加入叔块集成功❹,则直接结束交易处理,立刻将当前已处理的交易组装成区块,生成此区块的 PoW 计算信号❺。
当开始新区块挖矿时,第一步就是构建区块,打包出包含交易的区块。在打包区块中,是按逻辑顺序依次组装各项信息。如果你对区块内容不清楚,请先查阅文章区块结构。
挖矿是在竞争挖下一个区块,需要把最新高度的区块作为父块来确定新区块的基本信息❶。
//miner/worker.go:829
parent := w.chain.CurrentBlock()//❶
if parent.Time() >= uint64(timestamp) {//❷
timestamp = int64(parent.Time() + 1)
}
if now := time.Now().Unix(); timestamp > now+1 {
wait := time.Duration(timestamp-now) * time.Second
log.Info("Mining too far in the future", "wait", common.PrettyDuration(wait))
time.Sleep(wait)//❸
}
num := parent.Number()
header := &types.Header{//❹
ParentHash: parent.Hash(),
Number: num.Add(num, common.Big1),
GasLimit: core.CalcGasLimit(parent, w.gasFloor, w.gasCeil),
Extra: w.extra,
Time: uint64(timestamp),
}
if w.isRunning() {
if w.coinbase == (common.Address{}) {
log.Error("Refusing to mine without etherbase")
return
}
header.Coinbase = w.coinbase//❺
}
先根据父块时间戳调整新区块的时间戳。如果新区块时间戳还小于父块时间戳,则直接在父块时间戳上加一秒。一种情是,新区块链时间戳比当前节点时间还快时,则需要稍做休眠❸,避免新出块属于未来。这也是区块时间戳可以作为区块链时间服务的一种保证。
有了父块,新块的基本信息是确认的。分别是父块哈希、新块高度、燃料上限、挖矿自定义数据、区块时间戳❹。
为了接受区块奖励,还需要设置一个不为空的矿工账户 Coinbase ❺。一个区块的挖矿难度是根据父块动态调整的,因此在正式处理交易前,需要根据共识算法设置新区块的挖矿难度❻。
if err := w.engine.Prepare(w.chain, header); err != nil {//❻
log.Error("Failed to prepare header for mining", "err", err)
return
}
至此,区块头信息准备就绪。
为了方便的共享当前新区块的信息,是专门定义了一个 environment ,专用于记录和当前挖矿工作相关内容。为即将开始的挖矿,先创建一份新的上下文环境信息。
err := w.makeCurrent(parent, header)
if err != nil {
log.Error("Failed to create mining context", "err", err)
return
}
上下文环境信息中,记录着此新区块信息,分别有:
makeCurrent
方法就是在初始化好上述信息。Cd3ecj6#QG4Q3hzEU
前面不断将非分支上的区块存放在叔块集中。在打包新块选择叔块时,将从叔块集中选择适合的叔块。
//miner/worker.go:886
uncles := make([]*types.Header, 0, 2)
commitUncles := func(blocks map[common.Hash]*types.Block) {
for hash, uncle := range blocks {//❷
if uncle.NumberU64()+staleThreshold <= header.Number.Uint64() {
delete(blocks, hash)
}
}
for hash, uncle := range blocks {
if len(uncles) == 2 {//❸
break
}
if err := w.commitUncle(env, uncle.Header()); err != nil {
} else {
uncles = append(uncles, uncle.Header())
}
}
}
commitUncles(w.localUncles)//❶
commitUncles(w.remoteUncles)
叔块集分本地矿工打包区块和其他挖矿打包的区块。优先选择自己挖出的区块❶。选择时,将先删除太旧的区块,只从最近的7(staleThreshold)个高度中选择❷,但最多选择两个叔块放入新区块中❸。为什么不多选几个呢?这个不太清楚如何确定的。共识校验中叔块上限是2。
怎样的叔块才能够被选择呢?在 commitUncle 时将根据当前新区块的高度、父区块信息来决定是否加入。
//miner/worker.go:645
func (w *worker) commitUncle(env *environment, uncle *types.Header) error {
hash := uncle.Hash()
//...
if env.header.ParentHash == uncle.ParentHash {//❹
return errors.New("uncle is sibling")
}
//...
env.uncles.Add(uncle.Hash())
return nil
}
唯一需要确认的是叔块必须在另一个分支上❹。总得来说,叔块是最近7个高度内上的区块,,且和当前新区块不在同一分支上、且不能重复包含在祖先块中。
区块头已准备就绪,此刻开始从交易池拉取待处理的交易。将交易根据交易发送者分为两类,本地账户交易 localTxs 和外部账户交易 remoteTxs。本地交易优先不仅在交易池交易排队如此,在交易打包到区块中也是如此。本地交易优先,先将本地交易提交❸,再将外部交易提交❹。
//miner/worker.go:917
pending, err := w.eth.TxPool().Pending()//❶
//...
localTxs, remoteTxs := make(map[common.Address]types.Transactions), pending//❷
for _, account := range w.eth.TxPool().Locals() {
if txs := remoteTxs[account]; len(txs) > 0 {
delete(remoteTxs, account)
localTxs[account] = txs
}
}
if len(localTxs) > 0 {//❸
txs := types.NewTransactionsByPriceAndNonce(w.current.signer, localTxs)
if w.commitTransactions(txs, w.coinbase, interrupt) {
return
}
}
if len(remoteTxs) > 0 {//❹
txs := types.NewTransactionsByPriceAndNonce(w.current.signer, remoteTxs)
if w.commitTransactions(txs, w.coinbase, interrupt) {
return
}
}
交易处理完毕后,便可进入下一个环节。
在交易处理完毕时,会获得交易回执和变更了区块状态。这些信息已经实时记录在上下文环境 environment 中。
将 environment 中的数据整理,便可根据共识规则构建一个区块。
//miner/worker.go:959
s := w.current.state.Copy()
block, err := w.engine.Finalize(w.chain, w.current.header, s, w.current.txs, uncles, w.current.receipts)
有了区块,就剩下最重要也是最核心的一步,执行 PoW 运算寻找 Nonce。这里并不是立刻开始寻找,而是发送一个PoW计算任务信号。
//miner/worker.go:968
select {
case w.taskCh <- &task{receipts: receipts, state: s, block: block, createdAt: time.Now()}:
//...
}
之所以称之为挖矿,也是因为寻找Nonce的精髓所在。这是一道数学题,只能暴力破解,不断尝试不同的数字。直到找出一个符合要求的数字,这个数字称之为Nonce。寻找Nonce的过程,称之为挖矿。
寻找Nonce是需要时间的,耗时主要由区块难度决定。在代码设计上,以太坊是在 taskLoop 方法中,一直等待 task ❶。
//miner/worker.go:508
case task := <-w.taskCh://❶
//...
sealHash := w.engine.SealHash(task.block.Header())//❷
if sealHash == prev {
continue
}
interrupt()//❹
stopCh, prev = make(chan struct{}), sealHash
if w.skipSealHook != nil && w.skipSealHook(task) {
continue
}
w.pendingMu.Lock()
w.pendingTasks[w.engine.SealHash(task.block.Header())] = task//❸
w.pendingMu.Unlock()
if err := w.engine.Seal(w.chain, task.block, w.resultCh, stopCh); err != nil {
log.Warn("Block sealing failed", "err", err)
}
当接收到挖矿任务后,先计算出这个区块所对应的一个哈希摘要❷,并登记此哈希对应的挖矿任务❸。登记的用途是方便查找该区块对应的挖矿任务信息,同时在开始新一轮挖矿时,会取消旧的挖矿工作,并从pendingTasks 中删除标记。以便快速作废挖矿任务。
随后,在共识规则下开始寻找Nonce,一旦找到Nonce,则发送给 resutlCh。同时,如果想取消挖矿任务,只需要关闭 stopCh。而在每次开始挖矿寻找Nonce前,便会关闭 stopCh 将当前进行中的挖矿任务终止❹。
//miner/worker.go:500
interrupt := func() {
if stopCh != nil {
close(stopCh)
stopCh = nil
}
}
上一步已经开始挖矿,寻找Nonce。下一步便是等待挖矿结束,在 resultLoop 中,一直在等待执行结果❶。
//miner/worker.go:542
select {
case block := <-w.resultCh: //❶
if block == nil {
continue
}
if w.chain.HasBlock(block.Hash(), block.NumberU64()) {//❷
continue
}
var (
sealhash = w.engine.SealHash(block.Header())
hash = block.Hash()
)
一旦找到Nonce,则说明挖出了新区块。
挖矿结果已经是一个包含正确Nonce 的新区块。在正式存储新区块前,需要检查区块是否已经存在,存在则不继续处理❷。
//miner/worker.go:556
w.pendingMu.RLock()
task, exist := w.pendingTasks[sealhash]
w.pendingMu.RUnlock()
if !exist { //❸
continue
}
var (
receipts = make([]*types.Receipt, len(task.receipts))
logs []*types.Log
)
for i, receipt := range task.receipts { //❹
receipt.BlockHash = hash
receipt.BlockNumber = block.Number()
receipt.TransactionIndex = uint(i)
receipts[i] = new(types.Receipt)
*receipts[i] = *receipt
for _, log := range receipt.Logs {
log.BlockHash = hash
}
logs = append(logs, receipt.Logs...)
}
也许挖矿任务已被取消,如果Pending Tasks 中不存在区块对应的挖矿任务信息,则说明任务已被取消,就不需要继续处理❸。从挖矿任务中,整理交易回执,补充缺失信息,并收集所有区块事件日志信息❹。
//miner/worker.go:584
stat, err := w.chain.WriteBlockWithState(block, receipts, task.state)//
if err != nil {
log.Error("Failed writing block to chain", "err", err)
continue
}
//...
w.mux.Post(core.NewMinedBlockEvent{Block: block})//❻
随后,将区块所有信息写入本地数据库❺,对外发送挖出新块事件❻。在 eth 包中会监听并订阅此事件。
//eth/handler.go:771
func (pm *ProtocolManager) minedBroadcastLoop() {
for obj := range pm.minedBlockSub.Chan() {
if ev, ok := obj.Data.(core.NewMinedBlockEvent); ok {
pm.BroadcastBlock(ev.Block, true) //❼
pm.BroadcastBlock(ev.Block, false) //❽
}
}
}
一旦接受到事件,则立即将广播。首随机广播给部分节点❼,再重新广播给不存在此区块的其他节点❽。
//miner/worker.go:595
var events []interface{}
switch stat {
case core.CanonStatTy:
events = append(events, core.ChainEvent{Block: block, Hash: block.Hash(), Logs: logs})
events = append(events, core.ChainHeadEvent{Block: block})
case core.SideStatTy:
events = append(events, core.ChainSideEvent{Block: block})
}
w.chain.PostChainEvents(events, logs)//❾
w.unconfirmed.Insert(block.NumberU64(), block.Hash())//⑩
同时,也需要通知程序内部的其他子系统,发送事件。新存储的区块,有可能导致切换链分支。如果变化,则队伍是发送 ChainSideEvent 事件。如果没有切换,则说明新区块仍然在当前的最长链上。对外发送 ChainEvent 和 ChainHeadEvent事件❾。新区块并非立即稳定,暂时存入到未确认区块集中。可这个 unconfirmed 仅仅是记录,但尚未具体使用。
至此,已经讲解完以太坊挖出一个新区块所经历的各个环节。下面是一张流程图是对挖矿环节的细化,可以边看图便对比阅读此文。同时在讲解时,并没有涉及共识内部逻辑、以及提交交易到虚拟机执行内容。这些内容不是挖矿流程的重点,共识部分将在一下次讲解共识时细说。
hi 🙂,我录制了《说透以太坊技术》的视频课程,快快上车!