QCon 全球软件开发大会(北京站)门票 9 折倒计时 4 天,点击立减 ¥880 了解详情
写点什么

区块链的基石:以太坊的 P2P 网络实现

2018 年 12 月 25 日

区块链的基石:以太坊的 P2P 网络实现

作为区块链的底层传输方式,P2P 技术帮助区块链成功实现了点对点的传播。而当下最为成功的区块链项目之一以太坊也使用了大量代码来实现 P2P 传输。本篇文章详细深入介绍了以太坊 P2P 的实现原理。


以太坊的 P2P 网络

以太坊的 p2p 网络主要有两部分构成:节点之间互相连接用于传输数据的 tcp 网络和节点之间互相广播用于节点发现的 udp 网络。本篇文章将重点介绍用于节点发现的 udp 网络部分。


p.s. 主要针对以太坊源码的对应实现,相关的算法如 kademlia DHT 算法可以参考其他文章。


P2P 整体结构

在以太坊中,节点之间数据的传输是通过 tcp 来完成的。但是节点如何找到可以进行数据传输的节点?这就涉及到 P2P 的核心部分节点发现了。每个节点会维护一个 table,table 中会存储可供连接的其他节点的地址。这个 table 通过基于 udp 的节点发现机制来保持更新和可用。当一个节点需要更多的 TCP 连接用于数据传输时,它就会从这个 table 中获取待连接的地址并建立 TCP 连接。


与节点建立连接的流程大致如下图:



上图中,第一和第二步每隔一段时间就会重复一次。目的是保持当前节点的 table 所存储的 url 的可用性。当节点需要寻找新的节点用于 tcp 数据传输时,就从一直在更新维护的 table 中取出一定数量的 url 进行连接即可。


为了防止节点之间的 tcp 互联形成信息孤岛,每个以太坊节点间的连接都分为两种类型:bound_in 和 bound_out。bound_in 指本节点接收到别人发来的请求后建立的 tcp 连接,bound_out 指本节点主动发起的 tcp 连接。假设一个节点最多只能与 6 个节点互联,那么在默认的设置中,节点最多只能主动和 4 个节点连接,剩余的必须留着给其它节点接入用。对于 bound_in 连接的数量则不做限制。


UDP 部分

前文提到过,以太坊会维护一个 table 结构用于存储发现的节点 url,当需要连接新的节点时会从 table 中获取一定数量的 url 进行 tcp 连接。在这个过程中,table 的更新和维护将会在独立的 goroutine 中进行。这部分涉及的代码主要在 p2p/discvV5 目录下的 udp.go, net.go, table.go 这三个文件中。下面我们看看 udp 这部分的主要逻辑结构:



readloop()


readloop()方法在 p2p/discvV5/udp.go 文件中,用来监听所有收到的 udp 消息。通过 ReadFromUDP() 接收收到的消息,然后在 handlePacket() 方法内将消息序列化后传递给后续的消息处理程序。


sendPacket()


sendPacket() 用于将消息通过 udp 发送给目标地址。


UDP msg type


所有通过 udp 发出的消息都分为两种类型:ping 和 pong。ping 作为通信的发起类型,pong 作为通信的应答类型。一个完整的通讯应该是:


  1. Node A 向 Node B 发送 ping 类型的 udp 消息。

  2. Node B 收到此消息后进行消息处理。

  3. Node B 向 Node A 发送 pong 类型的消息作为之前收到的 ping 消息的应答。


loop()


loop() 方法在 p2p/discvV5/net.go 文件中,是整个 p2p 部分的核心方法。它控制了节点发现机制的大部分逻辑内容,由一个大的 select 进行各种 case 的监控。主体代码大致如下:


func (net *Network) loop() {    ...        for {            select {                case pkt := <-net.read:                        // 处理收到的 udp 消息        case timeout := <-net.timeout:                        // 发送的 udp ping 消息超时未收到 pong 应答        case q := <-net.queryReq:                        // 从table中查找距离某个target最近的n个节点        case f := <-net.tableOpReq:                        // 操作table, 这个case主要是为了防止table被异步操作        case <-refreshTimer.C:                        // 计时器到点,刷新table里的url        ...        }    }    ...}
复制代码


nodeState


nodeState 表示了一个 url 对应的状态。在 net.go 的 init() 方法中定义了各种 nodeState:


type nodeState struct {    name     string    handle   func( ... ) ( ... )    enter    func( ... )    canQuery bool}var (    unknown          *nodeState     // 新收到的未知节点    verifyinit       *nodeState     // 处在初次验证的节点    verifywait       *nodeState     // 正在等待应答的节点    remoteverifywait *nodeState    known            *nodeState     // 已经完成应答的节点,可以加入到table中    contested        *nodeState    unresponsive     *nodeState)func init() {        unknown = &nodeState{        ...    }    verifyinit = &nodeState{        ...    }    ...}

复制代码


nodeState 主要是为了记录新地址的可用状态。当节点接触新的 url 时,此 url 会首先被定义为 unknown 状态,然后进入后续 ping pong 验证阶段。验证流程过完以后 就会被定义为 known 状态然后保存到 table 中。


enter() 和 handle()


nodeState 结构下定义了两个方法 enter() 和 handle()。


  • enter 是 nodeState 的入口方法,当一个 url 从 nodeState1 转到 nodeState2 时,它就会调用 nodeState2 的 enter() 方法。

  • handle 是 nodeState 的处理方法,当节点收到某个 url 的 udp 消息时,会调用此 url 当前 nodeState 的 handle() 方法。一般 handle() 方法会针对不同的 udp 消息类型进行不同的逻辑处理:


// verifywait 的 handle 方法func (net *Network, n *Node, ev nodeEvent, pkt *ingressPacket) (*nodeState, error) {        switch ev {                case pingPacket:            net.handlePing(n, pkt)                        return verifywait, nil        case pongPacket:                        err := net.handleKnownPong(n, pkt)                        return known, err                case pongTimeout:                        return unknown, nil        default:                        return verifywait, errInvalidEvent    }}
复制代码


handle() 方法在处理后会返回一个 nodeState,意思是我当前的 nodeState 在处理了这个 udp 消息后下一步将要转换的新的 state。比如在上面的代码中,verifywait 状态下的 url 在收到 pong 类型的 udp 消息后就会转换成 known 状态。


新节点加入流程

新节点的加入分两种:


  1. 获取到新的 url 后主动向对方发起 udp 请求,在完成一系列验证后将此 url 加入到 table 中。

  2. 收到陌生 url 地址发来的 udp 请求,在互相做完验证后加入到 table 中。


这两种情况是相对应的,NodeA 获取到 NodeB 的 url 后向 NodeB 发起 udp 请求(情况 1),NodeB 收到来自 NodeA 的陌生 url 后做应答(情况 2)。双方验证完成后互相将对方的 url 加入到 table 中。整个流程步骤如下:



  1. 作为验证发起方,NodeA 在收到 url 后会将这个 url 的 nodeState 设置为 verifyinit 并通过 verifyinit 的 enter() 方法向 nodeB 的 url 发起 ping 消息。

  2. NodeB 收到此 ping 消息后先获取此 url 的 nodeState,发现未找到于是将其设置为 unknown 然后调用 unknown 的 handle() 方法。

  3. 在 handle() 方法中,NodeB 会先向 NodeA 发送一个 pong 消息表示对收到的 ping 的应答。然后再向 NodeA 发送一个新的 ping 消息等待 NodeA 的应答。同时将 NodeA 的 state 设置为 verifywait。

  4. NodeA 收到 NodeB 发来的 pong 消息后调用 verifyinit 状态的 handle() 然后将 NodeB 设置为 remoteverifywait 状态。remoteverifywait 的意思就是 NodeB 在我这个节点(NodeA)这里已经经过 ping pong 验证通过了,但是我还没在 NodeB 那边经过 ping pong 验证。

  5. 将 NodeB 的 state 转换为 remoteverifywait 后会调用 remoteverifywait 的 enter() 方法。此方法开启一个定时器,定时器到点后向本节点发送一个 timeout 消息。

  6. 处理完 pong 消息后,NodeA 又收到了来自 NodeB 的 ping 消息,此时调用 remoteverifywait 的 handle() 向 NodeB 发送一个 pong 应答。

  7. NodeB 收到 NodeA 的 pong 应答后调用 verifywait 的 handle() 方法,此方法会将 NodeA 的 state 设置为 known。在 state 转换后调用新 state 也就是 known state 的 enter() 方法。known 的 enter() 中会将 NodeA url 加入到 NodeB 的 table 中。

  8. Step5 中 NodeA 的定时器到点并向 NodeA 自己发送 timeout 消息。此消息会调用 remoteverifywait 的 handle() 然后将 NodeB 的状态设置为 known。和 Step7 中一样,更改状态为 known 后 NodeB 的 url 会加入到 NodeA 的 table 中。


p.s. 以上步骤结合源码会更清晰


lookup 节点发现

上文中,NodeA 收到了 NodeB 的 url 从而发起两者间的联系。那么 NodeA 是如何得到这个 url 的呢?答案是通过 lookup()方法来发现这个 url。


lookup() 方法源码在 p2p/discvV5/net.go 文件中。此方法在需要获取新的 url 或者需要刷新 table 时被调用。


lookup() 需要输入一个 target 作为目标,然后寻找和 target 最近的 n 个节点。target 是由一个节点的 NodeID 经过哈希计算得来的。在以太坊中,每次生成新的 target 都会虚构一个节点 url 然后 hash 计算得到 target。lookup() 方法的基本思路是先从本地 table 获取和 target 最近的一部分邻居节点,然后再获取这些邻居节点的 table 中和 target 最近的部分节点。最后从得到的节点中选距离 target 最近的 n 个节点 url 作为新的 table 内容。如果这些 url 中有部分是之前未接触的,则程序会走上文提到的新节点加入流程来建立关系。


这里提到的距离最近不是指物理距离,而是数理上的最近。程序会计算节点 NodeID 经过 hash 计算后和 target 的差异大小来判断数理上的距离大小,具体可以参考 closest() 方法。


Conclusion

以太坊的 P2P 大致就是上述的实现方式。总的来说就是通过 udp 发现可供使用的节点 url 并将这些 url 维护在本地的 table 中,通过 tcp 和其他节点进行连接并进行数据传输,当需要新的节点时从 table 中获取未接触过的 url 建立新的 tcp 连接。


作者介绍:朱博亨,annchain 技术团队区块链开发工程师。


2018 年 12 月 25 日 15:501483

评论 1 条评论

发布
暂无评论
发现更多内容

架构师训练营」第 4 周作业

edd

中国未来需要什么样的人才?机遇与挑战!

CECBC区块链专委会

CECBC 中国人才 中国脊梁 数字经济

第四周作业

武鹏

架构师训练营 week03 总结

尔东雨田

极客大学架构师训练营

Week4 作业

Shawn

深入浅出Shiro系列——权限认证

程序员的时光

权限系统

互联网系统架构总结

周冬辉

云计算 “拍了拍” Serverless

零度

云计算 Serverless 互联网 计算机

架构师第四周作业

傻傻的帅

重学 Java 设计模式:实战观察者模式「模拟类似小客车指标摇号过程,监听消息通知用户中签场景」

小傅哥

Java 设计模式 小傅哥 代码优化 观察者模式

架构师训练营 week03 作业

尔东雨田

极客大学架构师训练营

浅谈互联网系统架构

Arvin

week04 互联网架构发展学习总结

李锦

Lambda初次使用很慢?从JIT到类加载再到实现原理

Kerwin

Java Lambda 类加载 JIT

【微信聊天】5张图帮你看懂二分查找

Java小咖秀

Java 算法 漫画 二分查找

小师妹学JVM之:逃逸分析和TLAB

程序那些事

Java JVM 小师妹 TLAB 逃逸分析

做产品少走弯路:你需要懂点高阶的知识

我是IT民工

产品 管理 知识体系

第四周课程总结

考尔菲德

大型互联网应用系统技术方案和手段总结

CATTY

互联网

Week04 作业

极客大学架构师训练营

架构师第四周学习总结

傻傻的帅

一个典型的大型互联网应用系统使用哪些技术方案和手段

李锦

极客大学架构师训练营

DevOps研发模式下「产品质量度量」方案实践

狂师

DevOps 研发管理 研发效能 开发流程

通俗易懂的 Deno 入门教程

阿宝哥

typescript 前端 deno

第四周总结

武鹏

从不可描述的服务雪崩到初探Hystrix

老胡爱分享

高可用 灾备

一题搞定static关键字

Java课代表

Java 面试

用100行代码手写一个Hystrix

小眼睛聊技术

Java 架构 高可用 设计 后端

week4总结---系统架构

a晖

架构师训练营第四周作业

一剑

大型系统常用的技术方案和技术手段

imicode

边缘计算隔离技术的挑战与实践

边缘计算隔离技术的挑战与实践

区块链的基石:以太坊的 P2P 网络实现-InfoQ