以太坊的底层P2P模块承担了节点之间的通信和服务发现,新节点发现连接的功能,p2p网络部分主要分为两个部分:
以太坊使用UDP进行服务发现,通讯内容比较简单,所以没有加密。而使用TCP进行真正的数据传输和交互,这部分是使用加密连接进行传输的。
在上节中,最后p2p网络启动时调用runing.Start()函数,会调用到ethereum/p2p/server.go中的Start()函数。
ethereum/p2p/server.go中的Start()函数代码片段
...
//Transport使用了newRLPX 使用了rlpx.go中的网络协议。
if srv.newTransport == nil {
srv.newTransport = newRLPX
}
...
//启动discover网络。 开启UDP的监听
if !srv.NoDiscovery || srv.DiscoveryV5 {
网络发现服务, 用UDP协议 , 这里的目的是生成一个udp协议地址字段, 设置一个UDP listen监听句柄
addr, err := net.ResolveUDPAddr("udp", srv.ListenAddr)
if err != nil {
return err
}
conn, err = net.ListenUDP("udp", addr)
if err != nil {
return err
}
realaddr = conn.LocalAddr().(*net.UDPAddr)
if srv.NAT != nil {
if !realaddr.IP.IsLoopback() {
go nat.Map(srv.NAT, srv.quit, "udp", realaddr.Port, realaddr.Port, "ethereum discovery")
}
// TODO: react to external IP changes over time.
if ext, err := srv.NAT.ExternalIP(); err == nil {
realaddr = &net.UDPAddr{IP: ext, Port: realaddr.Port}
}
}
}
...
if !srv.NoDiscovery {
cfg := discover.Config{
PrivateKey: srv.PrivateKey,
AnnounceAddr: realaddr,
NodeDBPath: srv.NodeDatabase,
NetRestrict: srv.NetRestrict,
Bootnodes: srv.BootstrapNodes,
Unhandled: unhandled,
}
启动discover网络。 开启UDP的监听。主要进行节点发现
ntab, err := discover.ListenUDP(conn, cfg)
if err != nil {
return err
}
srv.ntab = ntab
}
只要没有关闭discovery服务发现功能,P2P服务就会调用discover.ListenUDP(conn, cfg) 去开启服务发现功能。
p2p服务通过discover.ListenUDP(conn, cfg)函数去调用newUDP函数开启后台协程进行UDP监听和服务发现。
func newUDP(c conn, cfg Config) (*Table, *udp, error) {
udp := &udp{
conn: c,
priv: cfg.PrivateKey,
netrestrict: cfg.NetRestrict,
closing: make(chan struct{}),
gotreply: make(chan reply),
addpending: make(chan *pending),
}
realaddr := c.LocalAddr().(*net.UDPAddr)
if cfg.AnnounceAddr != nil {
realaddr = cfg.AnnounceAddr
}
//拆分出一个ip端口的rpcEndpoint,其实就是UDP地址转成对应的TCP地址
udp.ourEndpoint = makeEndpoint(realaddr, uint16(realaddr.Port))
///下面传入udp结构创建一个table结构,table负责对P2P协议节点发现功能的逻辑
tab, err := newTable(udp, PubkeyID(&cfg.PrivateKey.PublicKey), realaddr, cfg.NodeDBPath, cfg.Bootnodes)
if err != nil {
return nil, nil, err
}
udp.Table = tab
//发送数据
go udp.loop()
//读取数据,并调用 handlePacket进行处理
//如果有新的数据包到来,会通知到 unhandled上面, discover包会使用conn这个UDP 监听链接,不断读取数据包进行处理
go udp.readLoop(cfg.Unhandled)
return udp.Table, udp, nil
}
newTable函数调用的时候,会传递Bootnodes变量,这就是UDP服务的初始连接节点了,服务器启动默认从这些节点开始进行节点发现,进行扩散。
func newTable(t transport, ourID NodeID, ourAddr *net.UDPAddr, nodeDBPath string, bootnodes []*Node) (*Table, error) {
// If no node database was given, use an in-memory one
db, err := newNodeDB(nodeDBPath, nodeDBVersion, ourID)
if err != nil {
return nil, err
}
tab := &Table{
net: t,
db: db,
self: NewNode(ourID, ourAddr.IP, uint16(ourAddr.Port), uint16(ourAddr.Port)),
refreshReq: make(chan chan struct{}),
initDone: make(chan struct{}),
closeReq: make(chan struct{}),
closed: make(chan struct{}),
rand: mrand.New(mrand.NewSource(0)),
ips: netutil.DistinctNetSet{Subnet: tableSubnet, Limit: tableIPLimit},
}
if err := tab.setFallbackNodes(bootnodes); err != nil {
return nil, err
}
for i := range tab.buckets {
tab.buckets[i] = &bucket{
ips: netutil.DistinctNetSet{Subnet: bucketSubnet, Limit: bucketIPLimit},
}
}
//设置随机种子
tab.seedRand()
//载所有的种子节点,其中包括参数bootnodes 里面的节点
tab.loadSeedNodes()
// Start the background expiration goroutine after loading seeds so that the search for
// seed nodes also considers older nodes that would otherwise be removed by the
// expiration.
tab.db.ensureExpirer()
go tab.loop()
return tab, nil
}
节点发现协议:
由于是UDP协议,所以没有连接的概念,需要不断的去ping来测试对端节点是否OK。,上面看到在newTable后面,创建了一个协程go tab.loop()。
func (tab *Table) loop() {
var (
revalidate = time.NewTimer(tab.nextRevalidateTime())
//30分钟进行刷新所有节点列表进行findnode
refresh = time.NewTicker(refreshInterval)
copyNodes = time.NewTicker(copyNodesInterval)
revalidateDone = make(chan struct{})
//监听doRefresh 是否完成
refreshDone = make(chan struct{})
waiting = []chan struct{}{tab.initDone} // holds waiting callers while doRefresh runs
)
defer refresh.Stop()
defer revalidate.Stop()
defer copyNodes.Stop()
// //先进行刷新,这也是启动p2p服务的第一次尝试连接其他节点
go tab.doRefresh(refreshDone)
doRefresh()协程来尝试连接其他节点,这也是第一次去做节点刷新和连接。传入的参数refreshDone管道用来主协程和刷新协程进行通信。
func (tab *Table) doRefresh(done chan struct{}) {
defer close(done)
//加载所有种子节点,括tab.nursery 上设置的 Bootnodes初始启动节点。
tab.loadSeedNodes()
//根据最近的节点进行findNode查找邻近节点, 进行一次扩展, 查找其他节点的peers
//查找距离自己最近的节点
tab.lookup(tab.self.ID, false)
for i := 0; i < 3; i++ {
var target NodeID
crand.Read(target[:])
//查找距离target最近的节点
tab.lookup(target, false)
}
}
tab.lookup()函数主要进行距离某个节点距离的节点查找:
func (tab *Table) lookup(targetID NodeID, refreshIfEmpty bool) []*Node {
var (
target = crypto.Keccak256Hash(targetID[:])
asked = make(map[NodeID]bool)
seen = make(map[NodeID]bool)
reply = make(chan []*Node, alpha)
pendingQueries = 0
result *nodesByDistance
)
asked[tab.self.ID] = true
for {
tab.mutex.Lock()
//调用closest来获取距离target最近的16个节点列表
result = tab.closest(target, bucketSize)
tab.mutex.Unlock()
if len(result.entries) > 0 || !refreshIfEmpty {
break
}
//完全一个节点都没有,一般不会,如果为空那么调用refresh来触发一次刷新
<-tab.refresh()
refreshIfEmpty = false
}
for {
// 用alpha来控制每次并发协程数,避免量太大了,默认为3
//每次并发完成后,从reply管道读取一个,通过pendingQueries控制数量, 这个代表我总共有多少请求需要去问,当前正在进行的,如果没有了,就会break
for i := 0; i < len(result.entries) && pendingQueries < alpha; i++ {
n := result.entries[i]
if !asked[n.ID] {
asked[n.ID] = true
pendingQueries++
go tab.findnode(n, targetID, reply)
}
}
if pendingQueries == 0 {
// we have asked all closest nodes, stop the search
break
}
// wait for the next reply
for _, n := range <-reply {
if n != nil && !seen[n.ID] {
seen[n.ID] = true
result.push(n, bucketSize)
}
}
pendingQueries--
}
return result.entries
}
tab.closest()函数,获取某个节点附近的bucketSize个邻近节点,保存到result里面。tab.closest()函数里面调用result.push()函数,根据所有的节点对于target的距离进行排序。 按照从近到远的方式决定新节点的插入顺序。(队列中最大会包含16个元素)。 这样会导致队列里面的元素和target的距离越来越近。距离相对远的会被踢出队列。
func (h *nodesByDistance) push(n *Node, maxElems int) {
ix := sort.Search(len(h.entries), func(i int) bool {
return distcmp(h.target, h.entries[i].sha, n.sha) > 0
})
if len(h.entries) < maxElems {
h.entries = append(h.entries, n)
}
if ix == len(h.entries) {
} else {
copy(h.entries[ix+1:], h.entries[ix:])
h.entries[ix] = n
}
}
lookup 最重要的就是上面的循环,遍历result列表的每一个节点,分别参加协程进行findnode的发送。
Table.findnode()函数调用udp里面的findnode()函数,查找可以连通最近的16个节点,再通过tab.add()函数加入到自己的桶中。
udp的findnode()函数首先会通过t.ping(toid, toaddr)去ping传入k桶中的节点,然后通过t.waitping(toid)等待返回。
udp的findnode()函数
func (t *udp) findnode(toid NodeID, toaddr *net.UDPAddr, target NodeID) ([]*Node, error) {
if time.Since(t.db.lastPingReceived(toid)) > nodeDBNodeExpiration {
t.ping(toid, toaddr)
t.waitping(toid)
}
udp中的ping函数调用sendPing()函数进行发送ping消息,并且等待接收pong消息并校验。
func (t *udp) ping(toid NodeID, toaddr *net.UDPAddr) error {
return <-t.sendPing(toid, toaddr, nil)
}
func (t *udp) sendPing(toid NodeID, toaddr *net.UDPAddr, callback func()) <-chan error {
//发送一个ping的UDP消息给对方,并且等待pong结果返回,如果失败会返回非空,成功返回nil
req := &ping{
Version: 4,
From: t.ourEndpoint,
To: makeEndpoint(toaddr, 0), // TODO: maybe use known TCP port from DB
Expiration: uint64(time.Now().Add(expiration).Unix()),
}
packet, hash, err := encodePacket(t.priv, pingPacket, req)
if err != nil {
errc := make(chan error, 1)
errc <- err
return errc
}
//先挂一个回调,这样发送完收到结果的时候,触发errC
errc := t.pending(toid, pongPacket, func(p interface{}) bool {
ok := bytes.Equal(p.(*pong).ReplyTok, hash)
if ok && callback != nil {
callback()
}
return ok
})
//发送后会等待结果,如果成功,ping函数才会返回
//在loop()里面会监听r.gotreply事件,从plist里面找到对应的连接然后在p.errc<-nil
//这样本函数才会返回nil
t.write(toaddr, req.name(), packet)
return errc
}
t.pending函数传入了一个匿名函数,当回调函数。完了直接调用t.write来发送一个UDP数据包给对方。之后等待在errC管道上。这个来做什么的呢?errc 是t.pending返回的管道。来看看代码:
func (t *udp) pending(id NodeID, ptype byte, callback func(interface{}) bool) <-chan error {
//ping或者findnode函数调用这里,设置一个ping返回调用,并等待errC
//生成一个结果通知管道,生成一个pending结构放入t.addpending 里面,对方接到请求会调用callback,然后触发errC
ch := make(chan error, 1)
p := &pending{from: id, ptype: ptype, callback: callback, errc: ch}
select {
case t.addpending <- p:
// loop will handle it
case <-t.closing:
ch <- errClosed
}
//返回这个ch给调用者去等待,而对于addpending的对端,
//也就是loop,这会在获取到一个ptype类型后,会发送一个nil给这个errC的管道
//这样做到阻塞的目的, 调用者只需要等待在这管道上即可
return ch
}
调用write发送UDP报文后,会往 t.addpending 上发送一个pending消息,消息里面设置了一个管道ch,也就是errC,这样在udp的loop函数里面接到addpending消息后,会登记这个关系。
实际上pending顾名思义,就是等级等待事件,如果等待成功,loop协程会调用传入的callback函数,然后往管道errC发送一个结果。
来看一下loop的相关处理:
udp.go中的loop函数
func (t *udp) loop() {
...
select {
//ping 会设置一个pending的回调,将待回应的数据发到这个管道,然后才调用write发送数据
case p := <-t.addpending:
p.deadline = time.Now().Add(respTimeout)
//放到plist里面,如果有reply到来,会触发t.gotreply来进行扫描调用p.callback,
plist.PushBack(p)
//收到对方发送的数据包后,就是在readLoop里面不断读取数据包后,调用对应类型的数据包的handle函数
//后者会给gotreply 发送一个管道消息,带着对应的数据包
case r := <-t.gotreply:
var matched bool
for el := plist.Front(); el != nil; el = el.Next() {
p := el.Value.(*pending)
if p.from == r.from && p.ptype == r.ptype {
matched = true
//匹配成功,发送nil到管道,通知ping()函数返回
if p.callback(r.data) {
p.errc <- nil
plist.Remove(el)
}
// Reset the continuous timeout counter (time drift detection)
contTimeouts = 0
}
}
//给r.matched发送管道消息
r.matched <- matched
主协程loop的时候,addpending会记录这个等待事件,如果gotreply管道上面有消息了,便会遍历所有等待协程,如果match, 就调用其callback然后触发管道。通知等待协程唤醒。
这样当udp.ping函数返回的时候,就是本节点以及收到对端的pong的时候了。
当发送ping包进行等待的时候,我们可以看到代码是: t.pending(toid, pongPacket, func(p interface{}) bool , 第二个参数代表我要等待对方发送pongPacket。
readLoop 协程来接收消息,在newUDP 函数里面。
func (t *udp) readLoop(unhandled chan<- ReadPacket) {
//死循环不断读取UDP包然后调用handlePacket 进行处理,数据的发送调用write函数 直接发出
defer t.conn.Close()
if unhandled != nil {
defer close(unhandled)
}
buf := make([]byte, 1280)
for {
nbytes, from, err := t.conn.ReadFromUDP(buf)
if netutil.IsTemporaryError(err) {
log.Debug("Temporary UDP read error", "err", err)
continue
} else if err != nil {
log.Debug("UDP read error", "err", err)
return
}
//调用处理函数处理收到的package消息报文
if t.handlePacket(from, buf[:nbytes]) != nil && unhandled != nil {
select {
case unhandled <- ReadPacket{buf[:nbytes], from}:
default:
}
}
}
}
当ReadFromUDP接到一个UDP报文后,会调用handlePacket处理函数处理这个package。
func (t *udp) handlePacket(from *net.UDPAddr, buf []byte) error {
//处理一个readLoop 里面读取到的UDP数据包,先解码, 然后调用包对应的handle
packet, fromID, hash, err := decodePacket(buf)
if err != nil {
log.Debug("Bad discv4 packet", "addr", from, "err", err)
return err
}
//调用对应的packet类型的handle函数
err = packet.handle(t, from, fromID, hash)
log.Trace("<< "+packet.name(), "addr", from, "err", err)
return err
}
package.handle 这个函数依赖于decodePacket 返回的数据包类型,有 ping,pong,findnode, neighbors, 也就是说,对端发送的这个包格式是什么,就调用对应的格式。
当我们发送给对方ping包,那么对方会发送pong回来。因此对应的处理函数就是pong.handle:
func (req *pong) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
//收到一个pongPacket包,调用handleReply函数,通知loop的t.gotreply 去处理,然后返回结果
if expired(req.Expiration) {
return errExpired
}
if !t.handleReply(fromID, pongPacket, req) {
return errUnsolicitedReply
}
t.db.updateLastPongReceived(fromID, time.Now())
return nil
}
handleReply函数用来通知loop协程去处理一个对方发给我们的包:
func (t *udp) handleReply(from NodeID, ptype byte, req packet) bool {
//收到一个ptype类型的包传入 t.gotreply通道
matched := make(chan bool, 1)
select {
//调用当初等待在上面的回调函数,然后给回调函数上面的errC发送管道消息
case t.gotreply <- reply{from, ptype, req, matched}:
// loop will handle it
return <-matched
case <-t.closing:
return false
}
}
在上面findnode()函数中,首先发送ping消息包查看节点直接是否连通,下面再发送 findnodePacket包,询问距离target 比较近的节点有哪些, 注意以太坊的P2P扩散都是查询比较近的节点。
...
nodes := make([]*Node, 0, bucketSize)
nreceived := 0
//设置回应回调函数,等待类型为neighborsPacket的邻近节点包,如果类型对,就执行回调请求
errc := t.pending(toid, neighborsPacket, func(r interface{}) bool {
reply := r.(*neighbors)
for _, rn := range reply.Nodes {
nreceived++
n, err := t.nodeFromRPC(toaddr, rn)
if err != nil {
log.Trace("Invalid neighbor node received", "ip", rn.IP, "addr", toaddr, "err", err)
continue
}
nodes = append(nodes, n)
}
return nreceived >= bucketSize
})
//发送findnode报文,然后进行等待了
t.send(toaddr, findnodePacket, &findnode{
Target: target,
Expiration: uint64(time.Now().Add(expiration).Unix()),
})
//收到信息后会调用上面的回调函数,然后触发管道,然后返回nodes
return nodes, <-errc
}
函数调用pending, 第二个参数neighborsPacket 代表我需要等待对方发送neighborsPacket 报文就可以返回。当收到报文后,会调用匿名callback函数处理数据。
udp.readLoop会循环读取UDP包,并且调用handlePacket处理函数处理这个package, 如果是findnode包的话,那么代表对方想要查询邻近节点,就会调用到findnode.handle 函数。
findnode.handle 用来给对方回复我的邻近节点。通过调用t.closest函数来获取邻近节点,然后发给对方。
func (req *findnode) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
if expired(req.Expiration) {
return errExpired
}
//判断是否已连接上了这个来源节点
if !t.db.hasBond(fromID) {
return errUnknownNode
}
target := crypto.Keccak256Hash(req.Target[:])
t.mutex.Lock()
//返回距离target最近的节点
closest := t.closest(target, bucketSize).entries
t.mutex.Unlock()
//由于UDP有最大报文数限制,所以能够发送的邻近节点数目是有限的,必须做拆包发送,这也是UDP协议发包时必须注意的点。
p := neighbors{Expiration: uint64(time.Now().Add(expiration).Unix())}
var sent bool
for _, n := range closest {
if netutil.CheckRelayIP(from.IP, n.IP) == nil {
p.Nodes = append(p.Nodes, nodeToRPC(n))
}
if len(p.Nodes) == maxNeighbors {
t.send(from, neighborsPacket, &p)
p.Nodes = p.Nodes[:0]
sent = true
}
}
if len(p.Nodes) > 0 || !sent {
t.send(from, neighborsPacket, &p)
}
return nil
}
收到对端发送给我们的neighborsPacket 后,会调用到neighbors.handle函数,我们需要处理对方发给我们的临接节点列表。通过handleReply来通知主协程loop,进行查找,找到对应的等待的pending,然后调用其回调函数,并且将结果放到对应的errC管道上面。这样也就跟上面的udp.findnode()函数接上了,udp.findnode()函数能得到返回并且拿到对方返回给我们的node列表。
通过上面的流程,discover模块完成了基于UDP的节点发现协议,查找到新的节点通过tab.add(node) ,将其加入到table.buckets对应的bucket里面,这样就增加了可用节点了。
通过流程可知,p2p网络启动首先调用server.go中的Start()函数,Start()函数通过udp监听连接,再调用discover.ListenUDP(conn, cfg)函数,再通过调用newUDP()函数进行节点的发现连接,newUDP()函数里面首先调用newTable()函数,newTable()函数里面再调用tab.loop()函数,tab.loop()函数不停的调用tab.doRefresh(refreshDone)进行节点的刷新。tab.doRefresh刷新函数首先调用tab.lookup(tab.self.ID, false)获取距离K桶中其他节点距离本节点最近的节点返回。再随机分别生成三个节点target,再调用tab.lookup(target, false)获取距离K桶中其他节点距离target节点最近的节点返回。不断的刷新K桶。最后K桶中存储的是不断靠近target的节点。