[以太坊源代码分析] VI. 基于p2p的底层通讯(上篇)

以太坊做为一个去中心化的系统,其底层个体相互间的通讯显然很是重要,全部数据的同步,各个个体状态的更新,都依赖于整个网络中每一个个体相互间的通讯机制。以太坊的网络通讯基于peer-to-peer(p2p)通讯协议,又根据自身传输数据类型(区块,交易,哈希值等),网络节点业务相关性等需求,在各方面作了特别设计。node

因为以太坊中p2p通讯相关代码量较大,打算分为上下两篇文章来加以详解:上篇主要介绍管理p2p通讯的核心类ProtocolManager内部主要流程,以及通讯相关协议族的设计;下篇主要介绍ProtocolManager的两个成员Fetcher和Downloader,这里是上篇。golang

1. 通常意义上的p2p网络

在开始介绍以太坊的p2p通讯机制以前,不妨先来看看通常意义上的p2p网络通讯的一些特征,如下部份内容摘自peer-to-peer_wikispring

peer-to-peer(p2p)首先是一种网络拓扑类型,与之对比最显著的就是client/server(C/S)架构。从TCP/IP协议族分层的角度来讲,p2p网络中实际的数据交换,依然是网络层用IP协议,传输层用TCP协议;而p2p协议--若是可称之为协议的话,应算做应用层再往上,相似于逻辑拓扑层,毕竟著名的应用层协议之一FTP,就属于很是典型的一种C/S架构类型。缓存

上图是C/S架构和p2p架构的一个简单示意图,原图来自wiki。左图中C/S架构被描绘成星型拓扑,这固然仅仅是特例,你们可能在工做中遇到各类各样拓扑形状的C/S架构,而其核心特征是不变的:C/S 网络中的个体地位和功能是不平等的,client个体主要消耗资源,发起请求,server个体主要提供资源并处理请求,这使得C/S架构自然是中心化的。网络

相比之下,p2p架构中最重要的特色在于:其网络中的个体在地位和功能上是平等的,虽然每一个个体可能处理不一样的请求,实际提供的资源在具体量化后可能有差别,但它们都能同时既消耗资源又提供资源。若是把整个所处网络中的资源--此处的资源包括但不限于运算能力、存储空间、网络带宽等,视为一个总量,那么p2p网络中的资源分布,是分散于各个个体中的(也许不必定均匀分布)。因此,p2p网络架构自然是去中心化的、分布式的。架构

注意上图右侧p2p网络中,并不是每一个个体与网络中其余同类均有通讯。这其实也是p2p网络的一个很重要的特色:一个个体只须要与相邻的一部分同类有通讯便可,每一个个体可与多少相邻个体、哪些个体有通讯,是能够加以设计的,分布式

无结构化的和有结构化的p2p网络

根据p2p网络中节点相互之间如何联系,能够将p2p网络简单区分为无结构化的(unstructured),和结构化的(structured)两大类。函数

无结构化的

这种p2p网络即最普通的,不对结构做特别设计的实现方案。优势是结构简单易于组建,网络局部区域内个体可任意分布,反正此时网络结构对此也没有限制;特别是在应对大量新个体加入网络和旧个体离开网络(“churn”)时它的表现很是稳定。缺点在于在该网络中查找数据的效率过低,由于没有预知信息,因此每每须要将查询请求发遍整个网络(至少大多数个体),这会占用很大一部分网络资源,并大大拖慢网络中其余业务运行。oop

结构化的

这种p2p网络中的个体分布通过精心设计,主要目的是为了提升查询数据的效率,下降查询数据带来的资源消耗。提升查询效率的基本手段是对数据创建索引,结构化p2p网络最广泛的实现方案中使用了分布式哈希表(Distributed Hash Table,DHT),它会对每项数据(value)分配一个key以组成(key,value)键值对,同时网络中每一个个体的分布--这里的分布主要指相互通讯关系-根据key键进行关联和扩展。这样,当要查找某项数据时,只要跟据其key键就能不断的缩小查找区域,大大减小资源消耗。性能

尽管如此,这样的p2p网络缺点也很明显:因为每一个个体须要存有数量很多的相邻个体列表,因此当网络中发生大量新旧个体频繁加入和离开的“churn”事件时,整个网络的性能会大幅恶化,由于每一个个体的很大一部分资源消耗在相邻列表更新上(包括自身相邻列表的更新,和相互之间更新所储列表),同时许多peer所在的key也须要从新定义;另外,哈希表自己容量是有使用限制的,当哈希表中存储的数据空间大于其设计容量的一半时,哈希表就会大几率出现“碰撞”事故,这样的限制也使得依据DHT创建的p2p网络的总体效率大打折扣。

对于以太坊通讯机制的借鉴

根据以太坊的运行特色,咱们能够大概勾勒出以太坊个体也就是客户端所组成网络的一些需求特征:

  1. 网络中随时可能存在一些个体加入和离开网络的状况,但同一时间内大量新旧个体同时发生加入或离开的几率很低。
  2. 每一个个体所存储的数据(区块),理想状态下是相同的。也许有些个体会存在更新不够及时,例如新挖掘区块/新建立交易的广播事件到达有延迟,或者有些个体须要在状态更新后更换本身所维持区块链中的区块,但相应的通讯机制必定是但愿将这些差别抹平的。因此在以太坊网络中,查找数据时并不须要针对某些特定区域以提升效率,固然也不须要向整个网络大水漫灌的发送请求,正常状况下任意一个(或相邻几个)个体就能够提供。

综上所述,咱们对以太坊中的p2p网络设计能够有个初步思路了:

  • 不须要结构化,通过改进的非结构化(好比设计好相邻个体列表)网络模型能够知足需求;
  • 个体间的相互同步更新须要仔细设计;

以后的章节中,咱们能够逐步了解以太坊中的这个p2p网络通讯是如何完善并实现的。

2. p2p通讯的管理模块ProtocolManager

以太坊中,管理个体间p2p通讯的顶层结构体叫eth.ProtocolManager,它也是eth.Ethereum的核心成员变量之一。先来看一下它的主要UML关系:

ProtocolManager主要成员包括:

  • peertSet{}类型成员用来缓存相邻个体列表,peer{}表示网络中的一个远端个体。
  • 经过各类通道(chan)和事件订阅(subscription)的方式,接收和发送包括交易和区块在内的数据更新。固然在应用中,订阅也每每利用通道来实现事件通知。
  • ProtocolManager用到的这些通道的另外一端,多是其余的个体peer,也多是系统内单例的数据源好比txPool,或者是事件订阅的管理者好比event.Mux。
  • Fetcher类型成员累积全部其余个体发送来的有关新数据的宣布消息,并在自身对照后,安排相应的获取请求。
  • Downloader类型成员负责全部向相邻个体主动发起的同步流程。

小小说明:这里提到的"远端"个体,即非本peer的其余peer对象。以太坊的p2p网络中,全部进行通讯的两个peer都必须率先通过相互的注册(register),并被添加到各自缓存的peer列表,也就是peerSet{}对象中,这样的两个peers,就能够称为“相邻”。因此,这里提到的“远端"个体,若是处于可通讯状态,则一定已经“相邻”。

在运行方面,Start()函数是ProtocolManager的启动函数,它会在eth.Ethereum.Start()中被主动调用。ProtocolManager.Start()会启用4个单独线程(goroutine,协程)去分别执行4个函数,这也标志着该以太坊个体p2p通讯的全面启动。

Start():全面启动p2p通讯

由Start()启动的四个函数在业务逻辑上各有侧重,下图是关于它们所在流程的简单示意图:

以上这四段相对独立的业务流程的逻辑分别是:

  • 广播新出现的交易对象。txBroadcastLoop()会在txCh通道的收端持续等待,一旦接收到有关新交易的事件,会当即调用BroadcastTx()函数广播给那些尚无该交易对象的相邻个体。
  • 广播新挖掘出的区块。minedBroadcastLoop()持续等待本个体的新挖掘出区块事件,而后当即广播给须要的相邻个体。当再也不订阅新挖掘区块事件时,这个函数才会结束等待并返回。颇有意思的是,在收到新挖掘出区块事件后,minedBroadcastLoop()会连续调用两次BroadcastBlock(),两次调用仅仅一个bool型参数@propagate不同,当该参数为true时,会将整个新区块依次发给相邻区块中的一小部分;而当其为false时,仅仅将新区块的Hash值和Number发送给全部相邻列表。
  • 定时与相邻个体进行区块全链的强制同步。syncer()首先启动fetcher成员,而后进入一个无限循环,每次循环中都会向相邻peer列表中“最优”的那个peer做一次区块全链同步。发起上述同步的理由分两种:若是有新登记(加入)的相邻个体,则在整个peer列表数目大于5时,发起之;若是没有新peer到达,则以10s为间隔定时的发起之。这里所谓"最优"指的是peer中所维护区块链的TotalDifficulty(td)最高,因为Td是全链中从创世块到最新头块的Difficulty值总和,因此Td值最高就意味着它的区块链是最新的,跟这样的peer做区块全链同步,显然改动量是最小的,此即"最优"。
  • 将新出现的交易对象均匀的同步给相邻个体。txsyncLoop()主体也是一个无限循环,它的逻辑稍微复杂一些:首先有一个数据类型txsync{p, txs},包含peer和tx列表;通道txsyncCh用来接收txsync{}对象;txsyncLoop()每次循环时,若是从通道txsyncCh中收到新数据,则将它存入一个本地map[]结构,k为peer.ID,v为txsync{},并将这组tx对象发送给这个peer;每次向peer发送tx对象的上限数目100*1024,若是txsync{}对象中有剩余tx,则该txsync{}对象继续存入map[]并更新tx数目;若是本次循环没有新到达txsync{},则从map[]结构中随机找出一个txsync对象,将其中的tx组发送给相应的peer,重复以上循环。

以上四段流程就是ProtocolManager向相邻peer主动发起的通讯过程。尽管上述各函数细节从文字阅读起来容易模糊,不过最重要的内容仍是值得留意下的:本个体(peer)向其余peer主动发起的通讯中,按照数据类型可分两类:交易tx和区块block;而按照通讯方式划分,亦可分为广播新的单个数据和同步一组同类型数据,这样简单的两两配对,即可组成上述四段流程。

 

上述函数的实现中,不少地方都体现出巧妙的设计,好比BroadcastBlock()中,若是发送区块block,因为数据量相对重量级,则仅仅选择一小部分相邻peer,而若是发送hash值 + Number值,则发给全部相邻peer;又好比txsyncLoop()中,会从map[]中随机选择一个peer进行发送(随机选择的txsync{}中包含peer)。这些细节,很好的控制了单次业务请求的资源消耗对于定向区域的倾向性,使得整个网络资源消耗越发均衡,体现出很是全面的设计思路。

handle():交给其余peer的回调函数

对于peer间通讯而言,除了己方须要主动向对方peer发起通讯(好比Start()中启动的四个独立流程)以外,还须要一种由对方peer主动调用的数据传输,这种传输不只仅是由对方peer发给己方,更多的用法是对方peer主动调用一个函数让己方发给它们某些特定数据。这种通讯方式,在代码实现上适合用回调(callback)来实现。

ProtocolManager.handle()就是这样一个函数,它会在ProtocolManager对象建立时,以回调函数的方式“埋入”每一个p2p.Protocol对象中(实现了Protocol.Run()方法)。以后每当有新peer要与己方创建通讯时,若是对方可以支持该Protocol,那么双方就能够顺利的创建并开始通讯。如下是handle()的基本代码:

 

[plain]  view plain  copy
 
  1. // /eth/handler.go  
  2. func (pm *ProtocolManager) handle(p *peer) error {  
  3.     td, head, genesis := pm.blockchain.Status()  
  4.     p.Handshake(pm.networkId, td, head, genesis)  
  5.   
  6.     if rw, ok := p.rw.(*meteredMsgReadWriter); ok {  
  7.         rm.Init(p.version)  
  8.     }  
  9.   
  10.     pm.peers.Register(p)  
  11.     defer pm.removePeer(p.id)  
  12.   
  13.     pm.downloader.RegisterPeer(p.id, p.version, p)  
  14.   
  15.     pm.syncTransactions(p)  
  16.     ...  
  17.     for {  
  18.         if err := pm.handleMsg(p); err != nil {  
  19.             return err  
  20.         }  
  21.     }  
  22. }  

handle()函数针对一个新peer作了以下几件事:

 

  1. 握手,与对方peer沟通己方的区块链状态
  2. 初始化一个读写通道,用以跟对方peer相互数据传输。
  3. 注册对方peer,存入己方peer列表;只有handle()函数退出时,才会将这个peer移除出列表。
  4. Downloader成员注册这个新peer;Downloader会本身维护一个相邻peer列表。
  5. 调用syncTransactions(),用当前txpool中新累计的tx对象组装成一个txsync{}对象,推送到内部通道txsyncCh。还记得Start()启动的四个函数么? 其中第四项txsyncLoop()中用以等待txsync{}数据的通道txsyncCh,正是在这里被推入txsync{}的。
  6. 在无限循环中启动handleMsg(),当对方peer发出任何msg时,handleMsg()能够捕捉相应类型的消息并在己方进行处理。

创建新peer链接和传递Protocol[]

刚才提到,handle()函数以回调函数的形式被放入一个p2p.Protocol{}里,那么Protocol对象是如何交给新peer的呢?这部分细节,隐藏在新peer链接创建的过程当中。

全部远端peer与己方之间的通讯,都是经过p2p.Server{}来管理的,Server在整个客户端最先的启动步骤Node.Start()中被建立并启动,而node.Node是用来承载客户端中全部node.<Service>实现体的容器,下图简单示意了Node.Start()中与Server相关的一些步骤:

Node.Start()中首先会建立p2p.Server{},此时Server中的Protocol[]仍是空的;而后将Node中载入的全部<Service>实现体中的Protocol都收集起来,一并交给Server对象,做为Server.Protocols列表;而后启动Server对象,并将Server对象做为参数去逐一启动每一个<Service>实现体。

而因为eth.Ethereum对于<Service>.Protocols()的实现中,正是搜集了ProtocolManager.Protocols而成,因此ProtocolManager.Protocols最终被导入了p2p.Server.Protocols.

那么Server.Start()中作了什么呢? 下图是Server.Start()和run()函数体内,与新peer建立相关的主要逻辑:

能够看到,Server.Start()中启动一个单独线程(listenLoop())去监听某个端口有无主动发来的IP链接;另一个单独线程启动run()函数,在无限循环里处理接收到的任何新消息新对象。在run()函数中,若是有远端peer发来链接请求(新的p2p.conn{}),则调用Server.newPeer()生成新的peer对象,并把Server.Protocols全交给peer。

综合这两部分代码逻辑,能够发现:

  1. ProtocolManager.Protocols 最终由Server赋予了每个新链接上(新建立)的peer对象中,因此回调函数ProtocolManager.handle(),也会进入每个新的远端peer对象中。而peer对象,须要接受目前客户端Node中全部<Service>的Protocols列表。
  2. 以太坊中每一个peer接收新链接的过程,源于标准的TCP/IP监听来访链接的方式(listener)。而每一个新链接上的peer,都会由p2p.Server交给ProtocolManager。

 

一点体会:

从上述逻辑流程中能够感觉到,对于以太坊的p2p通讯管理模块来讲,管理Protocol才是其最重要的任务,尤为是经过Protocol中的回调函数的设定,能够在对方peer在发生任何事件时,己方有足够的逻辑进行响应。这也是这个核心结构体为什么被命名为ProtocolManager,而不是PeerManager的缘由。至于管理peer群的功能,基本上用一个列表或者map结构,或者peerSet{}就够了。

3. p2p通讯协议族的结构设计

在上文的介绍中,出现了多处有关p2p通讯协议的结构类型,好比eth.peer,p2p.Peer,Server等等。这里不妨对这些p2p通讯协议族的结构一并做个总解。以太坊中用到的p2p通讯协议族的结构类型,大体可分为三层:

  • 第一层处于pkg eth中,能够直接被eth.Ethereum,eth.ProtocolManager等顶层管理模块使用,在类型声明上也明显考虑了eth.Ethereum的使用特色。典型的有eth.peer{}, eth.peerSet{},其中peerSet是peer的集合类型,而eth.peer表明了远端通讯对象和其全部通讯操做,它封装更底层的p2p.Peer对象以及读写通道等。
  • 第二层属于pkg p2p,可认为是泛化的p2p通讯结构,比较典型的结构类型包括表明远端通讯对象的p2p.Peer{}, 封装自更底层链接对象的conn{},通讯用通道对象protoRW{}, 以及启动监听、处理新加入链接或断开链接的Server{}。这一层中,各类数据类型的界限比较清晰,尽可能不出现揉杂的状况,这也是泛化结构的需求。值得关注的是p2p.Protocol{},它应该是针对上层应用特地开辟的类型,主要做用包括容纳应用程序所要求的回调函数等,并经过p2p.Server{}在新链接创建后,将其传递给通讯对象peer。从这个类型所起的做用来看,命名为Protocol仍是比较贴切的,尽管不该将其与TCP/IP协议等既有概念混淆。
  • 第三层处于golang自带的网络代码包中,也可分为两部分:第一部分pkg net,包括表明网络链接的<Conn>接口,表明网络地址的<Addr>以及它们的实现类;第二部分pkg syscall,包括更底层的网络相关系统调用类等,可视为封装了网络层(IP)和传输层(TCP)协议的系统实现。

下列UML图描绘了上述三层p2p通讯协议族中的一些主要结构,但愿对于理解以太坊中p2p通讯相关代码有所帮助。

 

小结:

诸如以太坊这种去中心化的数字货币运行系统,天生适用p2p通讯架构。不过原理虽然简单,在系统架构的层面,依然有不少实现细节须要加以关注。

  1. eth.ProtocolManager中,会对每个远端peer发起主动传输数据的操做,这组操做按照数据类型区分,可分为交易和区块;而若以发送数据方式来区分,亦可分为广播单项数据,和同步一组同类型数据。这样两两配对,便可造成4组主动传输数据的操做。
  2. ProtocolManager经过在p2p.Protocol{}对象中埋入回调函数,能够对远端peer的任何事件及状态更新做出响应。这些Protocol对象,会由p2p.Server传递给每个新链接上的远端peer。
  3. 以太坊目前实现的p2p通讯协议族的结构类型中,按照功能和做用,可分为三层:顶层pkg eth中的类型直接服务于当前以太坊系统(Ethereum,ProtocolManager等模块),中间层pkg p2p是泛化结构类型,底层包括golang语言包自带的pkg net, syscall等,封装了网络层和传输层协议的系统实现。
相关文章
相关标签/搜索