以太坊作为一个去中心化的系统,其底层个体相互间的通信显然非常重要,所有数据的同步,各个个体状态的更新,都依赖于整个网络中每个个体相互间的通信机制。以太坊的网络通信基于peer-to-peer(p2p)通信协议,又根据自身传输数据类型(区块,交易,哈希值等),网络节点业务相关性等需求,在各方面做了特别设计。
由于以太坊中p2p通信相关代码量较大,打算分为上下两篇文章来加以详解:上篇主要介绍管理p2p通信的核心类ProtocolManager内部主要流程,以及通信相关协议族的设计;下篇主要介绍ProtocolManager的两个成员Fetcher和Downloader,这里是上篇。
在开始介绍以太坊的p2p通信机制之前,不妨先来看看一般意义上的p2p网络通信的一些特征,以下部分内容摘自peer-to-peer_wiki
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网络简单区分为无结构化的(unstructured),和结构化的(structured)两大类。
这种p2p网络即最普通的,不对结构作特别设计的实现方案。优点是结构简单易于组建,网络局部区域内个体可任意分布,反正此时网络结构对此也没有限制;特别是在应对大量新个体加入网络和旧个体离开网络(“churn”)时它的表现非常稳定。缺点在于在该网络中查找数据的效率太低,因为没有预知信息,所以往往需要将查询请求发遍整个网络(至少大多数个体),这会占用很大一部分网络资源,并大大拖慢网络中其他业务运行。
这种p2p网络中的个体分布经过精心设计,主要目的是为了提高查询数据的效率,降低查询数据带来的资源消耗。提高查询效率的基本手段是对数据建立索引,结构化p2p网络最普遍的实现方案中使用了分布式哈希表(Distributed Hash Table,DHT),它会对每项数据(value)分配一个key以组成(key,value)键值对,同时网络中每个个体的分布--这里的分布主要指相互通信关系-根据key键进行关联和扩展。这样,当要查找某项数据时,只要跟据其key键就能不断的缩小查找区域,大大减少资源消耗。
尽管如此,这样的p2p网络缺点也很明显:由于每个个体需要存有数量不少的相邻个体列表,所以当网络中发生大量新旧个体频繁加入和离开的“churn”事件时,整个网络的性能会大幅恶化,因为每个个体的很大一部分资源消耗在相邻列表更新上(包括自身相邻列表的更新,和相互之间更新所储列表),同时许多peer所在的key也需要重新定义;另外,哈希表本身容量是有使用限制的,当哈希表中存储的数据空间大于其设计容量的一半时,哈希表就会大概率出现“碰撞”事故,这样的限制也使得依据DHT建立的p2p网络的整体效率大打折扣。
根据以太坊的运行特点,我们可以大概勾勒出以太坊个体也就是客户端所组成网络的一些需求特征:
综上所述,我们对以太坊中的p2p网络设计可以有个初步思路了:
之后的章节中,我们可以逐步了解以太坊中的这个p2p网络通信是如何完善并实现的。
以太坊中,管理个体间p2p通信的顶层结构体叫eth.ProtocolManager,它也是eth.Ethereum的核心成员变量之一。先来看一下它的主要UML关系:
ProtocolManager主要成员包括:
小小说明:这里提到的"远端"个体,即非本peer的其他peer对象。以太坊的p2p网络中,所有进行通信的两个peer都必须率先经过相互的注册(register),并被添加到各自缓存的peer列表,也就是peerSet{}对象中,这样的两个peers,就可以称为“相邻”。所以,这里提到的“远端"个体,如果处于可通信状态,则必定已经“相邻”。
在运行方面,Start()函数是ProtocolManager的启动函数,它会在eth.Ethereum.Start()中被主动调用。ProtocolManager.Start()会启用4个单独线程(goroutine,协程)去分别执行4个函数,这也标志着该以太坊个体p2p通信的全面启动。
由Start()启动的四个函数在业务逻辑上各有侧重,下图是关于它们所在流程的简单示意图:
以上这四段相对独立的业务流程的逻辑分别是:
以上四段流程就是ProtocolManager向相邻peer主动发起的通信过程。尽管上述各函数细节从文字阅读起来容易模糊,不过最重要的内容还是值得留意下的:本个体(peer)向其他peer主动发起的通信中,按照数据类型可分两类:交易tx和区块block;而按照通信方式划分,亦可分为广播新的单个数据和同步一组同类型数据,这样简单的两两配对,便可组成上述四段流程。
上述函数的实现中,很多地方都体现出巧妙的设计,比如BroadcastBlock()中,如果发送区块block,由于数据量相对重量级,则仅仅选择一小部分相邻peer,而如果发送hash值 + Number值,则发给所有相邻peer;又比如txsyncLoop()中,会从map[]中随机选择一个peer进行发送(随机选择的txsync{}中包含peer)。这些细节,很好的控制了单次业务请求的资源消耗对于定向区域的倾向性,使得整个网络资源消耗愈加均衡,体现出非常全面的设计思路。
对于peer间通信而言,除了己方需要主动向对方peer发起通信(比如Start()中启动的四个独立流程)之外,还需要一种由对方peer主动调用的数据传输,这种传输不仅仅是由对方peer发给己方,更多的用法是对方peer主动调用一个函数让己方发给它们某些特定数据。这种通信方式,在代码实现上适合用回调(callback)来实现。
ProtocolManager.handle()就是这样一个函数,它会在ProtocolManager对象创建时,以回调函数的方式“埋入”每个p2p.Protocol对象中(实现了Protocol.Run()方法)。之后每当有新peer要与己方建立通信时,如果对方能够支持该Protocol,那么双方就可以顺利的建立并开始通信。以下是handle()的基本代码:
// /eth/handler.go
func (pm *ProtocolManager) handle(p *peer) error {
td, head, genesis := pm.blockchain.Status()
p.Handshake(pm.networkId, td, head, genesis)
if rw, ok := p.rw.(*meteredMsgReadWriter); ok {
rm.Init(p.version)
}
pm.peers.Register(p)
defer pm.removePeer(p.id)
pm.downloader.RegisterPeer(p.id, p.version, p)
pm.syncTransactions(p)
...
for {
if err := pm.handleMsg(p); err != nil {
return err
}
}
}
handle()函数针对一个新peer做了如下几件事:
刚才提到,handle()函数以回调函数的形式被放入一个p2p.Protocol{}里,那么Protocol对象是如何交给新peer的呢?这部分细节,隐藏在新peer连接建立的过程中。
所有远端peer与己方之间的通信,都是通过p2p.Server{}来管理的,Server在整个客户端最早的启动步骤Node.Start()中被创建并启动,而node.Node是用来承载客户端中所有node.
Node.Start()中首先会创建p2p.Server{},此时Server中的Protocol[]还是空的;然后将Node中载入的所有
而由于eth.Ethereum对于
那么Server.Start()中做了什么呢? 下图是Server.Start()和run()函数体内,与新peer创建相关的主要逻辑:
可以看到,Server.Start()中启动一个单独线程(listenLoop())去监听某个端口有无主动发来的IP连接;另外一个单独线程启动run()函数,在无限循环里处理接收到的任何新消息新对象。在run()函数中,如果有远端peer发来连接请求(新的p2p.conn{}),则调用Server.newPeer()生成新的peer对象,并把Server.Protocols全交给peer。
综合这两部分代码逻辑,可以发现:
一点体会:
从上述逻辑流程中可以感受到,对于以太坊的p2p通信管理模块来说,管理Protocol才是其最重要的任务,尤其是通过Protocol中的回调函数的设定,可以在对方peer在发生任何事件时,己方有足够的逻辑进行响应。这也是这个核心结构体为何被命名为ProtocolManager,而不是PeerManager的原因。至于管理peer群的功能,基本上用一个列表或者map结构,或者peerSet{}就够了。
在上文的介绍中,出现了多处有关p2p通信协议的结构类型,比如eth.peer,p2p.Peer,Server等等。这里不妨对这些p2p通信协议族的结构一并作个总解。以太坊中用到的p2p通信协议族的结构类型,大致可分为三层:
下列UML图描绘了上述三层p2p通信协议族中的一些主要结构,希望对于理解以太坊中p2p通信相关代码有所帮助。
诸如以太坊这种去中心化的数字货币运行系统,天生适用p2p通信架构。不过原理虽然简单,在系统架构的层面,依然有很多实现细节需要加以关注。