写在前面

hotstuff与raft有类似的结构和行为,因此可以借鉴etcd-raft的设计。笔者打算使用Rust+tokio异步栈来实现一个事件驱动hotstuff的demo,通过动手来进一步学习和思考。

目前最新的重构代码在:https://github.com/Tsumida/hotstuff-consensus/tree/dev/hotstuff-rs

要求:

  • 在无故障状态下能连续、正确达成共识。

具体来说:

  • 所有节点都是诚实节点
  • 网络无故障,
  • leader在每一个view进行一次propose,各个节点应当accept该proposal,leader随后计算出门限签名并且生成qc。
  • view change时各个节点应该知悉上一轮共识达成的qc并且用其更新自己的qc-high。

参考:

代码:

交互

正常提交流程

下面是无故障情况下,client发起操作,到操作被提交的大概流程。(实际上需要累积三次proposal才能提交cmd)。
cmd正常提交流程.svg

经验总结

起初笔者参考etcd-raft实现,将hotstuff分为 statemachine, pacemaker, network, eventbus 几个模块,模块之间通过channel通信,每个模块定义自己的输入输出结构体,由EventBus进行转换,模块之间基本完全解耦:
image.png

但是写着写着, 笔者发现问题比较大:

  • 不同模块会对类似事件做重复定义,比如StateMachine的一个输出 Ready::Update , 要转化为 ToDo::UpdateQCHigh 来传递给Pacemaker。
  • 不同的输入输出之间的转换存在障碍。比如 ToDo::UpdateQCHigh 需要node和qc_high两个参数,而 Ready::UpdateQCHigh 只包含qc_high。
  • StateMachine独享大部分状态,而Pacemaker无法获取一些必需状态,阻碍了模块间输入输出的转换。
  • 一些Msg的定义不包含上下文,导致Network component拿到消息后不知道该向谁发rpc。
  • 每个模块之间进行双向通信需要两个channel,eventbus作为枢纽,包含大量channel成员。

这些障碍使得作者花了很多时间进行修改。改着改着,代码逐渐变为shit mountain,继续写下去只会浪费更多实践。笔者只好推倒重来,重新规划。好在这坑也不是白踩,笔者还是进行了一些思考,总结了一点经验,如:

  • 抽象出一个共享存储MemPool,Pacemaker和Statemachine都可以访问它,从而完成 QC <-> TreeNode 这样的双向查找。
  • 把常见的curd操作封装好,这样不用写大量重复代码。
  • 定义一套Statemachine, Pacemaker,network component共用的消息、事件集合。
  • TreeNode, GenericQC等大对象会在多个协程中流动,使用Box或者Arc智能指针来减少内存复制。

针对上面的问题,笔者了几天重写,结构如下:
image.png

另外,节点对外暴露restful api,用于peer之间的通信和client\server通信 :

  • /hotstuff/status 用于客户端获取StateMachine的状态快照。
  • /hotstuff/new-tx 用于发起新的交易。
  • leader通过http://node-a:port/hotstuff/new-proposal 向node-a发送新proposal。

笔者考察了两个http库:

  • warp, 基于tokio, 通过组合来实现http服务。缺点是封装不够完善,一些组合函数的返回值较繁琐而且意义不明,组合子写起来也很麻烦。
  • actix-web, 基于tokio和actix,社区生态很好,活跃更新中。

actix-web写起来简单,但是它的task必须要跑在 actix-web::runtime 上,而笔者的hotstuff模块使用的tokio runtime, 最后解决办法是:

  • main函数开两个线程,分别启动actix-web-runtime和tokio-runtime, hotstuff proxy和 Machine之间用 tokio::sync::mpsc::channel 通信。实测可行。

最终程序结构如下:
未命名文件 (6).png

实现细节

门限签名

笔者使用这个库:https://github.com/poanetwork/threshold_crypto
这个库的使用者较多,也通过了安全审计,没发现大问题。

GenericQC与TreeNode

GenericQC与TreeNode有紧密联系, 每一个node都有justify域,justify用于判断proposal是否可以接受

另外,可能出现不同节点的justify域相同的情况。如下图,绿色节点b’还没达成共识系统就进入了下一轮,因此它的下一个节点b’’还是沿用之前的justify。
image.png

下图是理想情况, 蓝色节点b与其后面三个连续的三个节点形成了3链,因此b及其前面三个节点都可以提交了。
image.png
因此,对于TreeNode和GenericQC,有如下注意事项:

  • TreeNode通过justify域找到对应的GenericQC
  • GenericQC能通过node域找到对应的TreeNode
  • 两者不能形成无限递归结构
  • 由于要通过网络传输,所以不能使用内存指针和引用。

    1. ![image.png](https://cdn.nlark.com/yuque/0/2020/png/2712357/1604129330231-1e6208e6-4539-40ba-b056-6ef65e1d15f5.png#align=left&display=inline&height=208&margin=%5Bobject%20Object%5D&name=image.png&originHeight=417&originWidth=816&size=23672&status=done&style=none&width=408)<br />最后确定用Sha256来计算TreeNode和GenericQC的哈希值,分别存储在 qc.node和node.justify域。Node通过一个HashMap qc_map来寻找GenericQC。

下一步

笔者肝了一个星期终于让demo跑起来了。虽然设计上参考了etcd,但这终究是闭门造车,代码设计上很拉胯, 仅仅能跑起来。笔者下一步要做的,是进行大量调研和思考,从而进一步完善demo,使其称为一个可用的轮子。

要做的工作包括:

  • 参考LibraBFT如何实现:
    • 架构怎么样?
    • 用什么rpc?grpc还是jsonrpc?
    • 怎么测试?
    • 故障恢复怎么做?
    • 网络故障恢复后,落后节点如何追赶?
  • 重构代码:
    • 进一步整合模块。
    • 提供定制Pacemaker的接口。
    • 加密通信。
    • 能否用actix框架?
  • 阅读论文资料,进一步了解hotstuff。