[以太坊源代码分析] VI. 基于p2p的底层通信(上篇)
以太坊作為一個去中心化的系統,其底層個體相互間的通信顯然非常重要,所有數據的同步,各個個體狀態的更新,都依賴于整個網絡中每個個體相互間的通信機制。以太坊的網絡通信基于peer-to-peer(p2p)通信協議,又根據自身傳輸數據類型(區塊,交易,哈希值等),網絡節點業務相關性等需求,在各方面做了特別設計。
由于以太坊中p2p通信相關代碼量較大,打算分為上下兩篇文章來加以詳解:上篇主要介紹管理p2p通信的核心類ProtocolManager內部主要流程,以及通信相關協議族的設計;下篇主要介紹ProtocolManager的兩個成員Fetcher和Downloader,這里是上篇。
1. 一般意義上的p2p網絡
在開始介紹以太坊的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網絡中節點相互之間如何聯系,可以將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網絡通信是如何完善并實現的。
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建立新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。
綜合這兩部分代碼邏輯,可以發現:
一點體會:
從上述邏輯流程中可以感受到,對于以太坊的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通信架構。不過原理雖然簡單,在系統架構的層面,依然有很多實現細節需要加以關注。
原文:http://blog.csdn.net/teaspring/article/details/78455046
總結
以上是生活随笔為你收集整理的[以太坊源代码分析] VI. 基于p2p的底层通信(上篇)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: [以太坊源代码分析] IV. 椭圆曲线密
- 下一篇: 分布式一致性与共识算法