以太坊源碼分析—p2p節點發現與協議運行

前言

p2p(peer to peer)負責以太坊底層節點間的通訊,主要包括底層節點發現(discover)和上層協議運行兩大部分。node

節點發現

節點發現功能主要涉及 Server Table udp 這幾個數據結構,它們有獨自的事件響應循環,節點發現功能即是它們互相協做完成的。其中,每一個以太坊客戶端啓動後都會在本地運行一個Server,並將網絡拓撲中相鄰的節點視爲Node,而TableNode的容器,udp則是負責維持底層的鏈接。這些結構的關係以下圖
Server Table udpgolang

Server

p2p/server.go
type Server struct {
    PrivateKey  *ecdsa.PrivateKey
    Protocols    []protocol
    StaticNodes[]  *discover.Node
    newTransport  func(net.Conn)  transport  
    ntab    disvocerTable    
    ourHandshake    *protoHandshake    

    addpeer    chan *conn
    ......
}

PrivateKey - 本節點的私鑰,用於與其餘節點創建時的握手協商
Protocols - 支持的全部上層協議
StaticNodes - 預設的靜態Peer,節點啓動時會首先去向它們發起鏈接,創建鄰居關係
newTransport - 下層傳輸層實現,定義握手過程當中的數據加密解密方式,默認的傳輸層實現是用newRLPX()建立的rlpx,這不是本文的重點
ntab - 典型實現是Table,全部peerNode的形式存放在Table
ourHandshake - 與其餘節點創建鏈接時的握手信息,包含本地節點的版本號以及支持的上層協議
addpeer - 鏈接握手完成後,鏈接過程經過這個通道通知Server數組

Server.listenLoop()

Server的監聽循環,啓動底層監聽socket,當收到鏈接請求時,Accept後調用setupConn()開始鏈接創建過程網絡

Server.run()

Server的主要事件處理和功能實現循環數據結構

  • 進行主動的節點發現,詳見以後的節點發現部分
  • posthandshake channel 接收已經完成第一階段的鏈接,這些鏈接的身份已經被確認,但還須要驗證
  • addpeer channel 接收已經完成第二階段的鏈接,這些鏈接已經驗證,調用runPeer()運行本節點與Peer鏈接上的協議

Node

Node惟一表示網絡上的一個節點dom

p2p/discover/node.go
type Node struct {
    IP net.IP
    UDP, TCP uint16
    ID      NodeID
    sha    common.Hash
}

IP - IP地址
UDP/TCP - 鏈接使用的UDP/TCP端口號
ID - 以太坊網絡中惟一標識一個節點,本質上是一個橢圓曲線公鑰(PublicKey),與ServerPrivateKey對應。一個節點的IP地址不必定是固定的,但ID是惟一的。
sha - 用於節點間的距離計算socket


Table

Table主要用來管理與本節點與其餘節點的鏈接的創建更新刪除分佈式

p2p/discover/table.go
type Table struct {
    bucket   [nBuckets]* bucket
    refreshReq    chan chan struct{}
    ......
}

bucket - 全部peer按與本節點的距離遠近放在不一樣的桶(bucket)中,詳見以後的節點維護
refreshReq - 更新Table請求通道函數

Table.loop()

Table的主要事件循環,主要負責控制refreshrevalidate過程。
refresh.C - 定時(30s)啓動Peer刷新過程的定時器
refreshReq - 接收其餘線程投遞到Table刷新Peer鏈接的通知,當收到該通知時啓動更新,詳見以後的更新鄰居關係
revalidate.C - 定時從新檢查以鏈接節點的有效性的定時器,詳見以後的探活檢測oop

udp

udp負責節點間通訊的底層消息控制,是Table運行的Kademlia協議的底層組件

type udp struct {
    conn  conn
    addpending chan *pending
    gotreply  chan reply
    *Table
}

conn - 底層監聽端口的鏈接
addpendingudp用來接收pending的channel。使用場景爲:當咱們向其餘節點發送數據包後(packet)後可能會期待收到它的回覆,pending用來記錄一次這種尚未到來的回覆。舉個例子,當咱們發送ping包時,老是期待對方回覆pong包。這時就能夠將構造一個pending結構,其中包含期待接收的pong包的信息以及對應的callback函數,將這個pengding投遞到udp的這個channel。udp在收到匹配的pong後,執行預設的callback。
gotreply - udp用來接收其餘節點回復的通道,配合上面的addpending,收到回覆後,遍歷已有的pending鏈表,看是否有匹配的pending。
Table - 和Server中的ntab是同一個Table

udp.loop()

udp的處理循環,負責控制消息的向上遞交和收發控制

  • addpending 接收其餘線程投遞來的pending需求
  • gotreply 接收udp.readLoop()投遞過來的pending的回覆
udp.readLoop()

udp的底層接受數據包循環,負責接收其餘節點的packet

  • 接受其餘節點發送的packet並解析,若是是回覆包則投遞到udp.loop()

節點維護

以太坊使用Kademlia分佈式路由存儲協議來進行網絡拓撲維護,瞭解該協議建議先閱讀易懂分佈式。更權威的資料能夠查看wiki。總的來講該協議:

  • 使用UDP進行節點間消息通訊,有 4 種消息

    • ping - 用於探測其餘節點是否還存在
    • store - 接收者受到後,將信息中key/value對存儲在本節點
    • findnode - 接受者向發送者返回 k 個它知道的與目標結點距離最近的節點
    • findvalue - 和findnode 差很少,區別是若是接收者本地存在與目標結點對應的value,那麼就回復這個值給發送者。
  • 每一個節點根據與鄰居節點距離之間的距離(NodeID的差距),分別放到不一樣的桶(bucket)中。
本文說的距離,均是指兩個節點NodeID的距離,計算方式可見 p2p/discover/node.gologdist()方法

源碼中由Table結構保存全部bucketbucket結構以下

p2p/discover/table.go
type bucket struct {
    entries  []*Node
    replacemenets   []*Node
    ips  netutil.DistinctNetSet
}
  • entries 數組中保存通過bond的節點,而且其順序是越新bond經過了探活檢測(Revalidate)的節點位置越靠前。
  • replacemenets數組中保存候補節點,若是entries 數組數量滿了,以後的節點會被加入該數組

節點能夠在entriesreplacements互相轉化,一個entries節點若是Validate失敗,那麼它會被本來將一個本來在replacements數組的節點替換。

探活檢測(Revalidate)

有效性檢測就是利用ping消息進行探活操做。Table.loop()啓動了一個定時器(0~10s),按期隨機選擇一個bucket,向其entries中末尾的節點發送ping消息,若是對方迴應了pong,則探活成功。

舉個栗子,假設某個bucket, entries最多保存2個節點, replacements最多保存4個節點。初始狀況下 entries=[A, B], replacements = [C, D, E],若是此時節點F加入網絡, bond經過,因爲 entries已滿,只能加入到 replacements = [C, D, E, F]。 此時Revalidate定時器到期,則會對 B進行檢測,若是經過,則 entries=[B, A],若是不經過,則將隨機選擇 replacements中的一項(假設爲D)替換B的位置,最終 entries=[A, D], replacements = [C, E, F]

更新鄰居關係

Table.loop()會按期(定時器超時)或不按期(收到refreshReq)地進行更新鄰居關係(發現新鄰居),二者都調用doRefresh()方法,該方法對在網絡上查找離自身和三個隨機節點最近的若干個節點。

節點查找

Tablelookup()方法用來實現節點查找目標節點,它的實現就是Kademlia協議,經過節點間的接力,一步一步接近目標。

鄰居初始化

當一個節點啓動後,它會首先向配置的靜態節點發起鏈接,發起鏈接的過程稱爲Dial,源碼中經過建立dialTask跟蹤這個過程

dialTask

dialTask表示一次向其餘節點主動發起鏈接的任務

p2p/dial.go
type dialTask struct {
    flags    connFlag
    dest    *discover.Node
    ......
}

Server啓動時,會調用newDialState()根據預配置的StaticNodes初始化一批dialTask, 並在Server.run()方法中,啓動這些這些任務。

dialtask

Dial過程須要知道目標節點(dest)的IP地址,若是不知道的話,就要先使用 recolve()解析出目標的IP地址,怎麼解析?就是先要用藉助Kademlia協議在網絡中查找目標節點。

resolve
當獲得目標節點的IP後,下一步即是創建鏈接,這是經過dialTask.dial()創建鏈接

鏈接創建

鏈接創建的握手過程分爲兩個階段,在在SetupConn()中實現
第一階段爲ECDH密鑰創建

sequenceDiagram
Note left of Dialer: Calc token
Note left of Dialer: Generate Random Prikey\Nonce
Note left of Dialer: Sign
Dialer->>Receiver: AuthMsg
Note right of Receiver: Calc token
Note right of Receiver: Check Signature
Note right of Receiver: Generate Random Prikey\Nonce
Receiver->>Dialer: AuthResp

第二階段爲協議握手,互相交換支持的上層協議

sequenceDiagram
Dialer->>Receiver: protoHandshake
Receiver->>Dialer: protoHandshake

若是兩次握手都經過,dialTask將向Serveraddpeer通道發送peer的信息

sequenceDiagram
participant Server.run()
participant dialTask
participant Remote Node
dialTask->>Remote Node:EncHandshake
Remote Node->>dialTask:EncHandshake
dialTask->>Server.run(): posthandshake
dialTask->>Remote Node:ProtoHandshake
Remote Node->>dialTask:ProtoHandshake
dialTask->>Server.run(): addpeer
Note over Server.run(): go runPeer()

協議運行

協議運行並不僅僅指某個特定的協議,準確地說應該是若干個獨立的協議同時在兩個節點間運行。在p2p節點發現提到過,節點間創建鏈接的時候會通過兩次握手,其中的第二次握手,節點間會交換自身所支持的協議。最終兩個節點間生效的協議爲兩個節點支持的協議的交集

功能主要涉及 Peer protoRW 這幾個數據結構,其關係如圖
Peer proto

Peer

  • rw - 節點間鏈接的底層信息,好比使用的socket以及對端節點支持的協議(capabilities)
  • running - 節點間生效運行的協議簇

Peer.run()負責鏈接創建後啓動運行上層協議,它自身運行在一個獨立的go routine,具備本身的事件處理循環,除此以外,它還會額外建立2+ngo routine, 其中2包括一個用於保活的pingLoop() go routine和一個用於接收協議數據的readLoop() go routine ,而 n 爲運行於其上的n個協議的go routine,即每一個協議調用本身的Run()方法運行在本身單獨的go routine
run

protoRW

Run 每種協議自身的運行入口,以新的go routine形式啓動.

總結

  • p2p主要由底層節點發現和上層協議運行兩部分組成,節點發現負責管理以太坊網絡中各個節點間的鏈接創建,更新和刪除,Server是p2p功能的入口,Table負責記錄peer節點信息, udp負責底層通訊。而在底層的基礎上,節點間能夠運行多個獨立的協議。
  • 以太坊使用Kademlia分佈式路由存儲協議來進行網絡拓撲維護,將不一樣距離的peer節點放在不一樣的bucket中。
相關文章
相關標籤/搜索