[以太坊源代碼分析] 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等,封裝了網絡層和傳輸層協議的系統實現。
相關文章
相關標籤/搜索