使用C++ 20协程实现Raft共识算法(1)

using TTime = std::chrono::time_pointstd::chrono::steady_clock;

struct TVolatileState {
uint64_t CommitIndex = 0; // L,F
uint64_t LastApplied = 0; // L,F
std::unordered_map<uint32_t, uint64_t> NextIndex; // L
std::unordered_map<uint32_t, uint64_t> MatchIndex; // L
std::unordered_set<uint32_t> Votes; // C
std::unordered_map<uint32_t, TTime> HeartbeatDue; // L
std::unordered_map<uint32_t, TTime> RpcDue; // L
TTime ElectionDue; // F
};

Raft API

我的Raft算法实现有两个类。第一个是INode,它表示peers。这个类包括两个方法:Send(将传出的消息存储在内部缓冲区中)和Drain(处理实际的消息分派)。Raft是第二类,它管理当前对等体的状态。它还包括两个方法:Process(处理传入的连接)和ProcessTimeout(必须定期调用),以管理超时,如leader选举超时。这些类的用户应该根据需要使用Process、ProcessTimeout和Drain方法。INode的Send方法在Raft类内部调用,确保消息处理和状态管理在Raft框架内无缝集成。

struct INode {
virtual ~INode() = default;
virtual void Send(TMessageHolder message) = 0;
virtual void Drain() = 0;
};

class TRaft {
public:
TRaft(uint32_t node,
const std::unordered_map<uint32_t, std::shared_ptr>& nodes);
void Process(TTime now,
TMessageHolder message,
const std::shared_ptr& replyTo = {});
void ProcessTimeout(TTime now);
};

Raft 消息

现在让我们看看我是如何发送和读取Raft消息的。我没有使用序列化库,而是以TLV格式读取和发送原始结构。这是消息头的样子:

struct TMessage {
uint32_t Type;
uint32_t Len;
char Value[0];
};

为了方便起见,我引入了第二级头文件:

struct TMessageEx: public TMessage {
uint32_t Src = 0;
uint32_t Dst = 0;
uint64_t Term = 0;
};

这包括每条消息中的发送者和接收者的ID。除了LogEntry之外,所有消息都继承自TMessageEx。LogEntry和AppendEntries的实现如下:

struct TLogEntry: public TMessage {
static constexpr EMessageType MessageType = EMessageType::LOG_ENTRY;
uint64_t Term = 1;
char Data[0];
};

struct TAppendEntriesRequest: public TMessageEx {
static constexpr EMessageType MessageType
= EMessageType::APPEND_ENTRIES_REQUEST;
uint64_t PrevLogIndex = 0;
uint64_t PrevLogTerm = 0;
uint32_t Nentries = 0;
};

为了方便消息处理,我使用了一个叫做MessageHolder的类,类似于shared_ptr:

template
requires std::derived_from<T, TMessage>
struct TMessageHolder {
T* Mes;
std::shared_ptr<char[]> RawData;
uint32_t PayloadSize;
std::shared_ptr<TMessageHolder[]> Payload;

template
requires std::derived_from<U, T>
TMessageHolder Cast() {…}

template
requires std::derived_from<U, T>
auto Maybe() { … }
};

该类包括一个包含消息本身的字符数组。它还可能包括一个Payload(仅用于AppendEntry),以及用于将基本类型消息安全转换为特定类型消息的方法(Maybe方法)和不安全转换(Cast方法)。下面是一个使用MessageHolder的典型例子:

void SomeFunction(TMessageHolder message) {
auto maybeAppendEntries = message.Maybe();
if (maybeAppendEntries) {
auto appendEntries = maybeAppendEntries.Cast();
}
// if we are sure
auto appendEntries = message.Cast();
// usage with overloaded operator->
auto term = appendEntries->Term;
auto nentries = appendEntries->Nentries;
// …
}

在Candidate状态处理程序中有一个真实的例子:

void TRaft::Candidate(TTime now, TMessageHolder message) {
if (auto maybeResponseVote = message.Maybe()) {
OnRequestVote(std::move(maybeResponseVote.Cast()));
} else
if (auto maybeRequestVote = message.Maybe())
{
OnRequestVote(now, std::move(maybeRequestVote.Cast()));
} else
if (auto maybeAppendEntries = message.Maybe())
{
OnAppendEntries(now, std::move(maybeAppendEntries.Cast()));
}
}

这种设计方法提高了Raft实现中消息处理的效率和灵活性。

Raft 服务端

让我们讨论一下Raft服务器实现。Raft服务器将为网络交互设置协同程序。首先,我们将查看处理消息读写的协程。本文稍后将讨论用于这些协程的原语,并对网络库进行分析。写协程负责向套接字写入消息,而读协程稍微复杂一些。要读取,它必须首先检索Type和Len变量,然后分配Len字节数组,最后读取消息的其余部分。这种结构促进了Raft服务器内网络通信的高效管理。

template
TValueTask
TMessageWriter::Write(TMessageHolder message) {
co_await TByteWriter(Socket).Write(message.Mes, message->Len);

auto payload = std::move(message.Payload);
for (uint32_t i = 0; i < message.PayloadSize; ++i) {
co_await Write(std::move(payload[i]));
}

co_return;
}

template
TValueTask<TMessageHolder> TMessageReader::Read() {
decltype(TMessage::Type) type; decltype(TMessage::Len) len;
auto s = co_await Socket.ReadSome(&type, sizeof(type));
if (s != sizeof(type)) { /* throw / }
s = co_await Socket.ReadSome(&len, sizeof(len));
if (s != sizeof(len)) { /
throw */}
auto mes = NewHoldedMessage(type, len);
co_await TByteReader(Socket).Read(mes->Value, len - sizeof(TMessage));
auto maybeAppendEntries = mes.Maybe();
if (maybeAppendEntries) {
auto appendEntries = maybeAppendEntries.Cast();
auto nentries = appendEntries->Nentries; mes.InitPayload(nentries);
for (uint32_t i = 0; i < nentries; i++) mes.Payload[i] = co_await Read();
}
co_return mes;
}

要启动一个Raft服务器,需要创建一个RaftServer类的实例并调用Serve方法。Serve方法启动两个协程。Idle协程负责定期处理超时,而InboundServe负责管理传入的连接。

class TRaftServer {
public:
void Serve() {
Idle();
InboundServe();
}

private:
TVoidTask InboundServe();
TVoidTask InboundConnection(TSocket socket);
TVoidTask Idle();
}

通过accept调用接收传入连接。接下来,启动InboundConnection协程,它读取传入消息并将其转发给Raft实例进行处理。此配置确保Raft服务器可以有效地处理内部超时和外部通信。

TVoidTask InboundServe() {
while (true) {
auto client = co_await Socket.Accept();
InboundConnection(std::move(client));
}
co_return;
}

TVoidTask InboundConnection(TSocket socket) {
while (true) {
auto mes = co_await TMessageReader(client->Sock()).Read();
Raft->Process(std::chrono::steady_clock::now(), std::move(mes),
client);
Raft->ProcessTimeout(std::chrono::steady_clock::now());
DrainNodes();
}
co_return;
}

Idle协程的工作方式如下:它在每个睡眠秒调用ProcessTimeout方法。值得注意的是,这个协程使用异步睡眠。这种设计使Raft服务器能够有效地管理时间敏感的操作,而不会阻塞其他进程,从而提高服务器的整体响应能力和性能。

while (true) {
Raft->ProcessTimeout(std::chrono::steady_clock::now());
DrainNodes();
auto t1 = std::chrono::steady_clock::now();
if (t1 > t0 + dt) {
DebugPrint();
t0 = t1;
}
co_await Poller.Sleep(t1 + sleep);
}

协程是为发送外发消息而创建的,设计得很简单。它在循环中将所有累积的消息重复发送到套接字。如果发生错误,它会启动另一个负责连接的协程(通过connect函数)。此结构可确保平稳有效地处理传出消息,同时通过错误处理和连接管理保持健壮性。

try {
while (!Messages.empty()) {
auto tosend = std::move(Messages); Messages.clear();
for (auto&& m : tosend) {
co_await TMessageWriter(Socket).Write(std::move(m));
}
}
} catch (const std::exception& ex) {
Connect();
}
co_return;

通过实现Raft Server,这些示例展示了协程如何极大地简化了开发。虽然我没有研究过Raft的实现(相信我,它比Raft服务器复杂得多),但总体算法不仅简单,而且设计紧凑。

接下来,我们将看一些Raft Server示例。接下来,我将描述我从头开始专门为Raft服务器创建的网络库。这个库对于在Raft框架内实现高效的网络通信至关重要。

下面是启动具有三个节点的Raft集群的示例。每个实例接收自己的ID作为参数,以及其他实例的地址和ID。在这种情况下,客户端只与领导者通信。它发送随机字符串,同时保留一定数量的正在发送的消息并等待它们的承诺。该配置描述了在多节点Raft环境中客户端和leader之间的交互,演示了算法对分布式数据和共识的处理。

$ ./server --id 1 --node 127.0.0.1:8001:1 --node 127.0.0.1:8002:2 --node 127.0.0.1:8003:3

Candidate, Term: 2, Index: 0, CommitIndex: 0,

Leader, Term: 3, Index: 1080175, CommitIndex: 1080175, Delay: 2:0 3:0
MatchIndex: 2:1080175 3:1080175 NextIndex: 2:1080176 3:1080176

$ ./server --id 2 --node 127.0.0.1:8001:1 --node 127.0.0.1:8002:2 --node 127.0.0.1:8003:3

$ ./server --id 3 --node 127.0.0.1:8001:1 --node 127.0.0.1:8002:2 --node 127.0.0.1:8003:3

Follower, Term: 3, Index: 1080175, CommitIndex: 1080175,

$ dd if=/dev/urandom | base64 | pv -l | ./client --node 127.0.0.1:8001:1 >log1
198k 0:00:03 [159.2k/s] [        <=>

我测量了3节点和5节点集群配置的提交延迟。正如预期的那样,5节点设置的延迟更高:

  • 3个节点

50百分位(中位数):292872 纳秒
80百分位:407561纳秒
90百分位:569164 纳秒
99%百分位数:40279001 纳秒

  • 5个节点

50百分位(中位数):425194 纳秒
80百分位:672541 纳秒
90百分位:1027669 纳秒
99%: 38578749 纳秒

I/O库

现在让我们看一下我从头创建并在Raft服务器实现中使用的I/O库。我从下面的例子开始,摘自cppreference.com,这是一个echo服务器的实现:

task<> tcp_echo_server() {
char data[1024];
while (true) {
std::size_t n = co_await socket.async_read_some(buffer(data));
co_await async_write(socket, buffer(data, n));
}
}

事件循环、套接字原语和read_some/write_some(在我的库中称为ReadSome/ writsome)这样的方法是我的库所需要的,以及更高级别的包装器,如async_write/async_read(在我的库中称为TByteReader/TByteWriter)。
为了实现套接字的ReadSome方法,我必须像下面这样创建一个可等待对象:

auto ReadSome(char* buf, size_t size) {
struct TAwaitable {
bool await_ready() { return false; /* always suspend / }
void await_suspend(std::coroutine_handle<> h) {
poller->AddRead(fd, h);
}
int await_resume() {
return read(fd, b, s);
}
TSelect
poller; int fd; char* b; size_t s;
};
return TAwaitable{Poller_,Fd_,buf,size};
}

当调用co_await时,协程会挂起,因为await_ready返回false。在await_suspend中,我们捕获coroutine_handle并将其与套接字句柄一起传递给轮询器。当套接字准备好时,轮询器调用coroutine_handle来重新启动协程。在恢复时,调用await_resume,它执行读取操作并将读取的字节数返回给协程。WriteSome、Accept和Connect方法以类似的方式实现。

轮询器的设置如下:

struct TEvent {
int Fd; int Type; // READ = 1, WRITE = 2;
std::coroutine_handle<> Handle;
};
class TSelect {
void Poll() {
for (const auto& ch : Events) { /* FD_SET(ReadFds); FD_SET(WriteFds);*/ }
pselect(Size, ReadFds, WriteFds, nullptr, ts, nullptr);
for (int k = 0; k < Size; ++k) {
if (FD_ISSET(k, WriteFds)) {
Events[k].Handle.resume();
}
// …
}
}
std::vector Events;
// …
};

我保留了一个对数组(套接字描述符、协程句柄),用于初始化轮询器后端(在本例中为select)的结构。Resume在对应于就绪套接字的协程唤醒时被调用。
这在main函数中应用如下:

TSimpleTask task(TSelect& poller) {
TSocket socket(0, poller);
char buffer[1024];
while (true) {
auto readSize = co_await socket.ReadSome(buffer, sizeof(buffer));
}
}
int main() {
TSelect poller;
task(poller);
while (true) { poller.Poll(); }
}

我们启动一个(或多个)在co_await上进入休眠模式的协程,然后将控制传递给调用轮询器机制的无限循环。如果套接字在轮询器中准备就绪,则触发并执行相应的协程,直到下一个co_await。

为了读写Raft消息,我需要在ReadSome/ writsome上创建高级包装器,类似于:

TValueTask Read() {
T res; size_t size = sizeof(T);
char* p = reinterpret_cast<char*>(&res);
while (size != 0) {
auto readSize = co_await Socket.ReadSome(p, size);
p += readSize;
size -= readSize;
}

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Go语言工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Go语言全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Golang知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新

如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Go)
img

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

937794043)]
[外链图片转存中…(img-loNnODmL-1712937794044)]
[外链图片转存中…(img-22F52Cpl-1712937794044)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Golang知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新

如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-4Vzx1oq6-1712937794045)]

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 7
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
raft共识算法是一种分布式一致性算法,用于解决分布式系统中节点之间达成一致性的问题。它主要包含了Leader选举、日志复制和安全性等基本机制。 在raft算法中,节点分为Leader、Follower和Candidate三种状态。初始状态下所有节点都是Follower,然后它们通过相互通信进行Leader选举。选出的Leader负责接收客户端请求并进行日志复制等操作。如果Leader出现故障或无法通信,那么其他节点会重新进行选举,选出新的Leader。 日志复制是raft算法的关键过程,Leader负责将客户端请求记录在日志中,然后将日志复制给所有的Follower节点。Follower节点在接收到Leader的日志之后进行存储,然后发送应答给Leader确认。只有当大多数节点都复制了同一条日志之后,这条日志才算是已提交的。 raft算法还通过逻辑时钟和心跳机制来保证系统的一致性。每个节点都有自己的逻辑时钟,用于识别事件的顺序。Leader节点会定期发送心跳信号给Follower节点,以确保它们的存活状态。 在raft算法中,安全性是非常重要的一部分。它通过限制节点之间的信息交换,避免了“脑裂”等问题的发生。同时,每个节点都有持久性的存储,当节点宕机之后可以通过快照恢复。 总的来说,raft共识算法通过Leader选举、日志复制和安全性等机制,实现了分布式系统中节点之间的一致性。它比Paxos算法更容易理解和实现,因此在实际应用中被广泛使用

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值