1 22年年底想用gpt做出一个pbft的算法仿真,到了25年终于可以结合gpt grok perplexcity deepseek等实现了!!!!!
调试结果
解释:
这个程序模拟了一个小型区块链网络:
节点:80个节点,其中节点0是主节点(负责发起请求),节点1到79是从节点(负责响应)。
目标:处理100个请求,通过PBFT的三个阶段(Pre-Prepare、Prepare、Commit)达成共识。
通信方式:每个节点有自己的消息队列,主节点和从节点通过队列传递消息,模拟网络通信。
并发性:使用多线程(79个从节点线程+1个主线程),模拟区块链节点的并行工作。
延迟:每个消息传递有0-10ms的随机延迟,模拟真实网络环境。
同步工具:使用互斥锁和条件变量,确保线程安全和消息顺序。
运行过程可以分为五个主要阶段:
初始化阶段:设置节点、队列和线程。
主节点发起请求:发送Pre-Prepare消息,启动共识。
从节点处理消息:接收并回复Prepare和Commit消息。
主节点收集回复:收集足够的消息,完成共识。
统计和清理:输出性能指标,释放资源。
引子
-----------------------------------------------------------------------------------------------------
底下是正文:
第一部分:破冰与基石——揭开分布式共识的神秘面纱
嘿,各位C语言老铁们,以及所有对分布式系统、区块链技术充满好奇的朋友们!
我是你们的老朋友,一个常年“折腾”在代码世界里的码农。今天,我要和大家分享一个我“憋”了很久,从2022年年底就开始琢磨,直到2025年才终于在AI(没错,就是你们现在用的GPT、Grok、DeepSeek这些大模型!)的“神助攻”下,得以完美实现的硬核项目——用C语言模拟PBFT(Practical Byzantine Fault Tolerance,实用拜占庭容错)算法!
你可能会问:“PBFT?那是个啥?听起来就好高大上!”别急,我保证,读完这系列文章,你不仅能透彻理解PBFT的运行机制,还能亲手写出它的模拟代码,甚至能把它吹嘘给你的朋友,让他们对你刮目相看!
1.1 为什么是PBFT?为什么又是C语言?
在分布式系统的汪洋大海中,共识算法就像是定海神针,确保所有节点对某件事情达成一致。想象一下,如果一个团队里,大家对“今天中午吃什么”都各执己见,那午饭就没法吃了,对吧?分布式系统也是一样,如果各个节点对数据的状态、交易的顺序不能达成一致,那整个系统就乱套了。
而PBFT,就是解决这个问题的“老牌劲旅”之一。它不像比特币的PoW(工作量证明)那样需要消耗大量算力,而是在许可链(或者说,节点身份已知且受控的联盟链)和企业级分布式系统中大放异彩。它的特点是高吞吐、低延迟,而且还能容忍拜占庭错误(简单来说,就是有些节点可能会“使坏”,发送错误信息,甚至伪造信息)。
那为什么我们选择C语言来搞这个项目呢?
-
性能与底层: C语言以其卓越的性能和对系统底层的直接操控能力而闻名。用C语言实现,能让我们更深刻地理解PBFT算法在资源管理、并发控制上的细节。
-
硬核挑战: 对于刷过牛客、力扣100热题榜的你来说,C语言的多线程、内存管理可能已经驾轻就熟。但将这些知识应用于复杂的分布式算法模拟,无疑是一次更高级的挑战,能让你在嵌入式、操作系统等领域走得更远。
-
“裸奔”的快感: 没有花哨的框架,没有复杂的库依赖,我们用最纯粹的C语言来构建,就像亲手搭建一个精密的机械装置,那种掌控一切的快感,是其他语言难以比拟的。
-
面试加分项: 懂PBFT,会C语言实现,这在分布式、区块链、高性能计算等领域的面试中,绝对是亮眼的加分项!
所以,别犹豫了,跟着我,一起踏上这段充满挑战与乐趣的PBFT C语言模拟之旅吧!
1.2 PBFT算法:初探分布式共识的奥秘
在深入代码之前,我们先来快速“扫盲”一下PBFT算法。别担心,我不会给你堆砌复杂的数学公式,咱们用大白话和生活中的例子来理解它。
1.2.1 什么是分布式共识?
想象一下,你和你的几个朋友(分布式系统中的节点)正在玩一个游戏,需要对某个行动(比如“下一步是向左走还是向右走”)达成一致。但你们之间可能存在网络延迟,甚至有人可能故意捣乱(拜占庭错误)。分布式共识算法的目标就是:在这些复杂甚至恶意的情况下,让所有诚实的参与者对某个提议达成一致,并且一旦达成一致,这个结果就不能再改变。
1.2.2 为什么需要共识?
举个最简单的例子:银行转账。 小明给小红转账100块钱。
-
如果银行系统是单机的,很简单,数据库里小明的余额减100,小红的余额加100。
-
但如果银行系统是分布式的,有N台服务器都保存着账本。
-
服务器A说:“转账成功!”
-
服务器B说:“转账失败!”
-
服务器C说:“我没收到这个请求!”
-
... 这不就乱套了吗?谁的账本才是对的?小明和小红的钱到底有没有变? 共识算法就是来解决这个问题的:确保所有服务器(节点)上的账本(数据状态)最终都保持一致。
-
1.2.3 PBFT的“三板斧”:Pre-Prepare, Prepare, Commit
PBFT算法的核心流程可以概括为三个主要阶段,就像武侠小说里的“三板斧”,一招一式,环环相扣,最终达成共识。
阶段名称 |
发送者 |
接收者 |
消息内容 |
目的 |
---|---|---|---|---|
Pre-Prepare |
主节点 |
所有副本节点 |
请求内容、视图编号、序列号、摘要 |
主节点提议一个请求,并广播给所有副本节点。 |
Prepare |
副本节点 |
所有节点 |
请求内容、视图编号、序列号、摘要 |
副本节点确认收到并同意主节点的提议。 |
Commit |
副本节点 |
所有节点 |
请求内容、视图编号、序列号、摘要 |
副本节点确认已准备好执行请求。 |
简单来说:
-
Pre-Prepare(预准备):客户端发出请求后,主节点(Primary)收到请求,它会给这个请求分配一个序列号,然后把请求内容和序列号打包成一个
Pre-Prepare
消息,广播给所有其他副本节点(Replica)。这就像班长(主节点)收到老师的作业(请求)后,把作业内容和编号告诉所有同学(副本节点)。 -
Prepare(准备):副本节点收到
Pre-Prepare
消息后,会验证消息的合法性(比如序列号是否正确、是否重复等)。如果验证通过,它会向所有其他节点(包括主节点自己)广播一个Prepare
消息,表示“我收到了这个请求,并且我已经准备好处理它了!”。这就像同学们收到作业后,互相告诉一声:“我收到作业了,准备开始写了!” -
Commit(提交):当一个节点(无论是主节点还是副本节点)收到了足够多的(2f+1个,f是允许的拜占庭节点数量)
Prepare
消息后,它就知道“哦,大多数节点都准备好处理这个请求了!”。这时,它会向所有其他节点广播一个Commit
消息,表示“我已经准备好提交并执行这个请求了!”。这就像同学们互相确认:“大家都准备好了,那咱们就一起交作业吧!”
当一个节点收到足够多的Commit
消息后,它就认为这个请求已经达成共识,可以安全地执行了。
1.2.4 拜占庭将军问题与PBFT的容错能力
你可能听说过“拜占庭将军问题”——一群将军(节点)要对是否进攻达成一致,但其中可能有叛徒(拜占庭节点),他们可能会传递虚假信息,甚至不按协议行事。
PBFT就是为了解决这个问题而生的。它通过多阶段投票和消息认证(虽然我们简化版里没有体现,但在实际PBFT中非常重要),确保即使有少数“叛徒”存在,诚实节点也能达成一致。PBFT能够容忍f个拜占庭节点,只要总节点数nge3f+1。
比如,我们模拟的系统有4个节点(1个主节点,3个副本)。那么n=4。 4ge3f+1Rightarrow3ge3fRightarrowfle1。 这意味着我们的系统可以容忍最多1个拜占庭节点。为了达成共识,我们需要2f+1个诚实节点的同意。当f=1时,2f+1=2times1+1=3。所以,在我们的4节点系统中,理论上需要至少3个节点(包括主节点自己)的确认才能达成共识。但在我们这个简化版里,为了演示方便,我们把阈值设置为2,即主节点收到2个副本的回复就认为达成共识(这在没有拜占庭节点,或者拜占庭节点数量少于f的情况下是可行的)。
1.3 搭建我们的PBFT模拟实验室:C语言与Windows环境
好了,理论知识讲完了,咱们开始动手搭建我们的模拟环境!
1.3.1 环境准备
你需要一个C语言的开发环境。我推荐以下两种:
-
Visual Studio (推荐):如果你在Windows上,Visual Studio是首选。它集成了编译器、调试器和IDE,对Windows API的支持也最好。
-
MinGW + VS Code:如果你更喜欢轻量级或者跨平台,可以在Windows上安装MinGW(Minimalist GNU for Windows),它提供了GCC编译器。然后配合VS Code,配置好C/C++扩展,也能愉快地开发。
1.3.2 Windows API初体验:为什么需要windows.h
?
你可能注意到代码开头有这样一行: #include <windows.h>
这行代码引入了Windows操作系统提供的API(应用程序编程接口)。我们的模拟程序需要用到多线程和线程同步机制,这些功能在Windows上都是通过windows.h
中定义的函数和数据结构来实现的。
而#define _WIN32_WINNT 0x0600
这行,则是为了确保我们能使用到Windows Vista及更高版本中引入的**条件变量(Condition Variables)**功能。条件变量在多线程编程中非常有用,它允许线程在满足特定条件之前等待,避免了忙等待(busy-waiting),提高了效率。0x0600
代表Windows Vista。
1.3.3 多线程编程的基石:CreateThread
、HANDLE
、DWORD WINAPI
、LPVOID
在我们的PBFT模拟中,每个节点都将作为一个独立的线程运行。这样才能模拟它们并行处理消息、互相通信的行为。Windows提供了CreateThread
函数来创建新线程。
让我们看看CreateThread
的“庐山真面目”:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程安全属性,通常为NULL
SIZE_T dwStackSize, // 线程栈大小,通常为0(使用默认大小)
LPTHREAD_START_ROUTINE lpStartAddress, // 线程函数的地址
LPVOID lpParameter, // 传递给线程函数的参数
DWORD dwCreationFlags, // 线程创建标志,通常为0(立即运行)
LPDWORD lpThreadId // 接收线程ID的变量地址,通常为NULL
);
-
HANDLE
: 这是一个句柄,可以理解为Windows用来标识和管理线程的“凭证”。通过这个句柄,我们可以等待线程结束、终止线程等。 -
LPTHREAD_START_ROUTINE
: 这是线程函数的类型定义。线程函数必须符合特定的签名:DWORD WINAPI ThreadFunc(LPVOID lpParam);
-
DWORD
: 无符号32位整数,通常作为线程函数的返回值,表示线程的退出码。 -
WINAPI
: 这是一个调用约定宏,确保函数参数以正确的方式传递给线程。 -
LPVOID
: 这是一个指向void
类型的指针,意味着它可以指向任何类型的数据。它是CreateThread
向线程函数传递参数的通用方式。在我们的代码中,我们用它来传递节点的ID。
-
思维导图:Windows多线程核心概念
graph TD
A[Windows多线程编程] --> B(核心函数: CreateThread)
B --> C(参数: lpStartAddress --> 线程函数地址)
B --> D(参数: lpParameter --> 传递给线程函数的参数)
B --> E(返回值: HANDLE --> 线程句柄)
F[线程函数签名] --> G(DWORD WINAPI ThreadFunc(LPVOID lpParam))
G --> H(DWORD: 返回值类型)
G --> I(WINAPI: 调用约定)
G --> J(LPVOID: 通用参数类型)
K[线程同步] --> L(CRITICAL_SECTION: 互斥锁)
K --> M(CONDITION_VARIABLE: 条件变量)
1.3.4 线程同步的“守护神”:CRITICAL_SECTION
与CONDITION_VARIABLE
在多线程环境中,多个线程可能会同时访问和修改同一份数据(比如我们的消息队列)。如果没有适当的同步机制,就会出现竞态条件(Race Condition),导致数据混乱或程序崩溃。
Windows提供了多种同步原语,我们这里用到了两种最常用的:CRITICAL_SECTION
(临界区,一种互斥锁)和CONDITION_VARIABLE
(条件变量)。
1. CRITICAL_SECTION
(互斥锁)
想象一下,你和你的朋友们(线程)都想去一个只有一个坑位的厕所(共享资源,比如消息队列)。为了避免大家挤在一起,你会在厕所门口挂一个“有人”的牌子。
-
当你想进厕所时,先看牌子。如果没人,你就进去,并把牌子挂成“有人”。
-
当你出来时,把牌子挂回“没人”。
-
如果牌子是“有人”,你就只能在外面等着。
CRITICAL_SECTION
就是这个“牌子”。
-
InitializeCriticalSection(&cs);
:初始化这个牌子。 -
EnterCriticalSection(&cs);
:挂上“有人”牌子,如果已经有人挂了,你就等着。 -
LeaveCriticalSection(&cs);
:取下“有人”牌子,让其他人可以进入。
2. CONDITION_VARIABLE
(条件变量)
光有互斥锁还不够。还是厕所的例子,假设你不仅要等厕所没人,还要等里面有纸(特定条件)。
-
你进了厕所,发现没纸。
-
你不能一直霸占着厕所等纸,这样别人也进不来。
-
你得先出来(释放锁),然后告诉外面的人“我需要纸”,然后自己去外面等着。
-
当有人给你送来纸时,他会告诉你“纸来了!”。
-
你听到后,再尝试进入厕所。
CONDITION_VARIABLE
就是这个“等纸”和“纸来了”的机制。它总是和CRITICAL_SECTION
配合使用。
-
InitializeConditionVariable(&cv);
:初始化条件变量。 -
SleepConditionVariableCS(&cv, &cs, INFINITE);
:-
原子性地释放
cs
(互斥锁)。 -
让当前线程进入休眠状态,直到条件变量被唤醒。
-
被唤醒后,重新获取
cs
。 -
INFINITE
表示无限期等待。
-
-
WakeConditionVariable(&cv);
:唤醒一个等待在cv
上的线程。 -
WakeAllConditionVariable(&cv);
:唤醒所有等待在cv
上的线程。
在我们的消息队列中:
-
当队列为空,
dequeue
操作的线程会调用SleepConditionVariableCS
进入等待,直到有新消息入队。 -
当有新消息入队时,
enqueue
操作会调用WakeConditionVariable
唤醒一个等待的线程。
表格:线程同步原语对比
原语名称 |
类型 |
主要作用 |
典型使用场景 |
---|---|---|---|
|
互斥锁 |
保护共享资源,确保同一时间只有一个线程访问 |
访问共享变量、修改数据结构(如链表、队列) |
|
条件变量 |
线程等待特定条件满足,避免忙等待 |
生产者-消费者模型、任务队列、线程间协作 |
1.4 PBFT模拟核心构件:消息与消息队列
现在,我们来看看构成PBFT模拟程序的核心数据结构。它们就像PBFT世界的“砖块和水泥”,构建起节点间的通信桥梁。
1.4.1 消息结构体 Message
在PBFT中,节点之间传递的都是“消息”。这些消息承载着请求的内容、阶段信息、发送者等关键数据。我们用一个struct Message
来定义它:
// 消息类型枚举:PBFT 的三个阶段
typedef enum {
MSG_PREPREPARE, // 主节点发送,初始广播请求
MSG_PREPARE, // 副本收到 Pre-Prepare 后回复 Prepare 消息
MSG_COMMIT // 副本收到 Commit 后回复 Commit 消息
} MsgType;
// 消息结构体:用于节点间通信
typedef struct Message {
int request_id; // 请求编号:标识是哪个客户端请求
MsgType type; // 消息类型:是Pre-Prepare,Prepare还是Commit?
int sender; // 发送节点编号:谁发送的这个消息?
struct Message *next; // 链表指针:用于构建消息队列,方便入队出队
} Message;
表格:Message
结构体详解
成员变量 |
类型 |
描述 |
作用 |
---|---|---|---|
|
|
客户端请求的唯一标识符。 |
确保每个消息都与特定的请求关联,避免混淆。 |
|
|
枚举类型,表示消息是Pre-Prepare、Prepare还是Commit。 |
决定接收节点如何处理这个消息,是进入Prepare阶段还是Commit阶段。 |
|
|
发送此消息的节点的编号(例如,0是主节点,1、2、3是副本节点)。 |
标识消息来源,用于验证和统计,比如主节点需要知道哪个副本回复了Prepare。 |
|
|
指向下一个 |
将消息连接成链表,作为消息队列的基础数据结构。 |
1.4.2 消息类型 MsgType
MsgType
枚举清晰地定义了PBFT协议中的三种核心消息类型。它们是协议流程的“信号灯”,指示着当前处于哪个阶段。
表格:MsgType
枚举值
枚举值 |
对应PBFT阶段 |
描述 |
---|---|---|
|
Pre-Prepare |
主节点向副本节点发送的初始请求消息。 |
|
Prepare |
副本节点收到Pre-Prepare后,向所有节点发送的确认消息。 |
|
Commit |
节点收到足够Prepare消息后,向所有节点发送的提交消息。 |
1.4.3 消息队列 MessageQueue
每个节点都需要一个独立的“收件箱”来接收来自其他节点的消息。这个“收件箱”就是我们的MessageQueue
。由于多个线程(发送者)可能会同时往一个队列里放消息,而只有一个线程(接收者)从队列里取消息,所以它必须是线程安全的。
// 消息队列结构体:每个节点拥有一个独立的队列
typedef struct {
Message *head; // 队列头部指针
Message *tail; // 队列尾部指针
CRITICAL_SECTION cs; // 互斥锁,保证队列操作的线程安全
CONDITION_VARIABLE cv; // 条件变量,用于等待队列不为空
} MessageQueue;
表格:MessageQueue
结构体详解
成员变量 |
类型 |
描述 |
作用 |
---|---|---|---|
|
|
指向队列中第一个消息的指针。 |
用于出队操作,总是从队列头部取出消息。 |
|
|
指向队列中最后一个消息的指针。 |
用于入队操作,总是将新消息添加到队列尾部。 |
|
|
Windows提供的临界区对象。 |
保护 |
|
|
Windows提供的条件变量对象。 |
当队列为空时,让 |
1.4.4 入队 enqueue
与出队 dequeue
操作
这两个函数是消息队列的核心操作,它们体现了线程安全和线程协作。
1. init_queue
函数:初始化消息队列
// 初始化消息队列
void init_queue(MessageQueue *q) {
// 队列头尾指针初始化为空,表示队列为空
q->head = q->tail = NULL;
// 初始化互斥锁,用于保护队列的并发访问
InitializeCriticalSection(&q->cs);
// 初始化条件变量,用于线程间等待和通知
InitializeConditionVariable(&q->cv);
// 打印日志,表示队列初始化成功
printf("[队列] 消息队列已初始化。\n");
}
代码分析:
-
q->head = q->tail = NULL;
:一个空的队列,头尾指针都指向NULL
。 -
InitializeCriticalSection(&q->cs);
:在使用临界区之前,必须先初始化它。 -
InitializeConditionVariable(&q->cv);
:同理,条件变量也需要初始化。
2. enqueue
函数:将消息加入队列尾部
// 入队操作:将消息加入队列尾部
void enqueue(MessageQueue *q, Message *msg) {
// 进入临界区,保护对队列的修改操作,确保线程安全
EnterCriticalSection(&q->cs);
// 新消息的next指针指向NULL,因为它将成为新的队列尾部
msg->next = NULL;
// 如果队列当前为空(即尾部指针为NULL)
if (q->tail == NULL) {
// 新消息既是队列的头部,也是队列的尾部
q->head = q->tail = msg;
} else {
// 如果队列不为空,将当前尾部的next指针指向新消息
q->tail->next = msg;
// 更新队列尾部指针为新消息
q->tail = msg;
}
// 唤醒一个(或所有)等待在条件变量上的线程
// 这通常发生在dequeue操作中,当队列从空变为非空时,通知等待的dequeue线程可以继续执行
WakeConditionVariable(&q->cv);
// 离开临界区,释放锁,允许其他线程访问队列
LeaveCriticalSection(&q->cs);
// 打印日志,记录入队操作
printf("[队列] 消息入队成功。请求ID: %d, 类型: %d, 发送者: %d\n", msg->request_id, msg->type, msg->sender);
}
代码分析:
-
EnterCriticalSection(&q->cs);
:这是关键!在修改队列之前,必须先获取锁,防止其他线程同时修改。 -
msg->next = NULL;
:新入队的消息总是放在链表的末尾,所以它的next
指针是NULL
。 -
if (q->tail == NULL)
:判断队列是否为空。如果为空,新消息就是第一个消息,头尾都指向它。 -
else { q->tail->next = msg; q->tail = msg; }
:如果队列不为空,将当前尾部的next
指向新消息,然后更新尾部指针。 -
WakeConditionVariable(&q->cv);
:当有新消息入队时,如果之前有线程在dequeue
时因为队列为空而等待,现在就可以唤醒它们了。 -
LeaveCriticalSection(&q->cs);
:修改完成后,释放锁。
3. dequeue
函数:若队列为空,则阻塞等待
// 出队操作:若队列为空,则阻塞等待
Message* dequeue(MessageQueue *q) {
Message *msg = NULL; // 初始化为NULL
// 进入临界区,保护对队列的修改操作
EnterCriticalSection(&q->cs);
// 循环检查队列是否为空。如果为空,则线程会在此处阻塞等待
// SleepConditionVariableCS会原子性地释放临界区,并使当前线程休眠
// 当条件变量被唤醒时(例如,有新消息入队),线程会重新获取临界区并继续执行
while (q->head == NULL) {
printf("[队列] 队列为空,线程进入等待... (等待队列不为空)\n");
SleepConditionVariableCS(&q->cv, &q->cs, INFINITE);
printf("[队列] 线程被唤醒,重新检查队列...\n");
}
// 队列不为空,取出头部消息
msg = q->head;
// 更新队列头部指针为下一个消息
q->head = msg->next;
// 如果头部更新后变为NULL,说明队列现在为空,需要将尾部指针也置为NULL
if (q->head == NULL) {
q->tail = NULL;
}
// 离开临界区,释放锁
LeaveCriticalSection(&q->cs);
// 打印日志,记录出队操作
printf("[队列] 消息出队成功。请求ID: %d, 类型: %d, 发送者: %d\n", msg->request_id, msg->type, msg->sender);
return msg; // 返回取出的消息
}
代码分析:
-
EnterCriticalSection(&q->cs);
:同样,先获取锁。 -
while (q->head == NULL)
:这是一个while
循环,而不是if
。这是条件变量的经典用法,被称为**“虚假唤醒(Spurious Wakeup)”**。线程被唤醒后,需要再次检查条件(队列是否真的不为空),因为可能存在其他线程抢先取走了消息,或者被其他原因唤醒了。 -
SleepConditionVariableCS(&q->cv, &q->cs, INFINITE);
:这是核心!它会原子性地释放q->cs
并让当前线程休眠。这意味着在线程休眠期间,其他线程可以进入临界区并修改队列。当有新消息入队并调用WakeConditionVariable
时,这个线程会被唤醒,并尝试重新获取q->cs
。 -
msg = q->head; q->head = msg->next;
:经典的链表出队操作。 -
if (q->head == NULL) { q->tail = NULL; }
:如果取走消息后队列变空了,记得把tail
也设为NULL
。 -
LeaveCriticalSection(&q->cs);
:释放锁。
1.5 代码实战:PBFT模拟的骨架
现在,我们把前面介绍的组件组合起来,看看PBFT模拟程序的基本骨架是如何搭建的。
1.5.1 模拟网络延迟 simulate_delay
为了让我们的模拟更真实,我们引入了网络延迟。虽然只是一个简单的随机数,但它能让我们感受到分布式系统中时间同步的挑战。
// 模拟网络延迟(简单随机延迟,单位毫秒)
DWORD simulate_delay() {
// 生成一个0到49毫秒之间的随机延迟
// rand() % 50 会生成0到49之间的整数
return (DWORD)(rand() % 50);
}
代码分析:
-
rand() % 50
:生成0到49之间的随机整数。 -
DWORD
:Windows API中常用的无符号32位整数类型,这里用于返回Sleep
函数所需的毫秒数。
1.5.2 全局消息队列数组 nodeQueues
每个节点都有自己的消息队列。为了方便管理,我们使用一个全局数组来存储所有节点的消息队列。
// 全局消息队列数组:每个节点一个队列
// nodeQueues[0] 是主节点(编号0)的队列
// nodeQueues[1] 是副本1(编号1)的队列,以此类推
MessageQueue nodeQueues[NODE_COUNT];
代码分析:
-
NODE_COUNT
:定义了系统中的节点总数。 -
nodeQueues[i]
:表示编号为i
的节点的消息队列。
1.5.3 非主节点(副本)的线程函数 node_thread_proc
这是所有副本节点(除了主节点0之外的节点)运行的逻辑。它们的主要任务就是不断地从自己的消息队列中取出消息,然后根据消息类型进行处理并回复。
// 非主节点(副本)的线程函数
// 每个副本节点都会运行这个函数,独立处理自己的消息
DWORD WINAPI node_thread_proc(LPVOID param) {
// 从传入的参数中获取当前节点的ID
// param 是一个LPVOID类型指针,需要强制转换为int*再解引用
int node_id = *(int*)param;
// 打印日志,表示当前副本节点已启动
printf("[节点 %d] 副本节点线程已启动。\n", node_id);
// 无限循环,持续处理消息,直到收到终止信号
while (1) {
// 从当前节点的消息队列中取出消息
// 如果队列为空,dequeue函数会阻塞等待,直到有新消息到来
Message *msg = dequeue(&nodeQueues[node_id]);
// 检查终止信号:当 request_id 和 type 均为 -1 时退出循环
// 这是一个约定好的特殊消息,用于安全地关闭线程
if (msg->request_id == -1 && msg->type == (MsgType)-1) {
printf("[节点 %d] 收到终止信号,线程即将退出。\n", node_id);
free(msg); // 释放消息内存
break; // 退出循环,线程结束
}
// 模拟网络延迟:处理消息前等待一小段时间
Sleep(simulate_delay());
// 根据消息类型进行处理:
// 副本节点主要处理两种消息:MSG_PREPREPARE 和 MSG_COMMIT
if (msg->type == MSG_PREPREPARE) {
// 收到主节点发送的Pre-Prepare消息
printf("[节点 %d] 收到 Pre-Prepare 消息 (请求ID: %d)。\n", node_id, msg->request_id);
// 分配内存创建回复消息(Prepare消息)
Message *reply = (Message*)malloc(sizeof(Message));
if (reply == NULL) { // 简单的内存分配错误检查
fprintf(stderr, "[错误] 节点 %d: 内存分配失败。\n", node_id);
// 实际应用中可能需要更复杂的错误处理
free(msg);
continue;
}
reply->request_id = msg->request_id; // 回复消息的请求ID与收到的消息相同
reply->type = MSG_PREPARE; // 回复类型为Prepare
reply->sender = node_id; // 回复发送者是当前副本节点
// 将Prepare消息发送给主节点(节点0)
// 模拟网络通信,将消息放入主节点的消息队列
enqueue(&nodeQueues[0], reply);
printf("[节点 %d] 发送 Prepare 消息 (请求ID: %d) 给主节点。\n", node_id, msg->request_id);
} else if (msg->type == MSG_COMMIT) {
// 收到主节点发送的Commit消息
printf("[节点 %d] 收到 Commit 消息 (请求ID: %d)。\n", node_id, msg->request_id);
// 分配内存创建回复消息(Commit消息)
Message *reply = (Message*)malloc(sizeof(Message));
if (reply == NULL) { // 简单的内存分配错误检查
fprintf(stderr, "[错误] 节点 %d: 内存分配失败。\n", node_id);
free(msg);
continue;
}
reply->request_id = msg->request_id; // 回复消息的请求ID与收到的消息相同
reply->type = MSG_COMMIT; // 回复类型为Commit
reply->sender = node_id; // 回复发送者是当前副本节点
// 将Commit消息发送给主节点(节点0)
// 模拟网络通信,将消息放入主节点的消息队列
enqueue(&nodeQueues[0], reply);
printf("[节点 %d] 发送 Commit 消息 (请求ID: %d) 给主节点。\n", node_id, msg->request_id);
} else {
// 收到未知类型的消息,打印警告
printf("[节点 %d] 收到未知消息类型: %d (请求ID: %d)。\n", node_id, msg->type, msg->request_id);
}
free(msg); // 释放已处理消息的内存,避免内存泄漏
}
return 0; // 线程正常退出
}
代码分析:
-
int node_id = *(int*)param;
:通过LPVOID
参数获取线程的唯一ID。 -
while (1)
:无限循环,线程会一直运行,直到收到终止信号。 -
Message *msg = dequeue(&nodeQueues[node_id]);
:从自己的队列中取消息。如果队列为空,线程会在这里阻塞,直到有消息到来。 -
终止信号:
if (msg->request_id == -1 && msg->type == (MsgType)-1)
是一个非常重要的设计模式,用于优雅地关闭线程。当主线程希望所有副本线程退出时,会发送这样一个特殊的消息。 -
Sleep(simulate_delay());
:模拟处理消息的延迟。 -
消息处理逻辑:
-
MSG_PREPREPARE
:收到主节点的预准备消息,副本节点会生成一个MSG_PREPARE
消息,并将其放入主节点(nodeQueues[0]
)的队列。 -
MSG_COMMIT
:收到主节点的提交消息,副本节点会生成一个MSG_COMMIT
消息,并将其放入主节点(nodeQueues[0]
)的队列。
-
-
free(msg);
:非常重要! 每次dequeue
出来的消息都是通过malloc
动态分配的,处理完后必须free
掉,否则会导致内存泄漏。
1.5.4 main
函数:程序的入口与主导者
main
函数是整个模拟程序的“大脑”,它负责初始化、启动线程、模拟主节点行为以及最终的清理工作。
// 主函数:程序的入口
int main() {
// 设置控制台输出编码为UTF-8,以便正确显示中文字符
SetConsoleOutputCP(CP_UTF8);
// 设置程序运行的区域(locale)为简体中文,影响printf等函数的格式化输出
setlocale(LC_ALL, "zh_CN.UTF-8");
// 使用当前时间作为随机数生成器的种子,确保每次运行的随机数序列不同
srand((unsigned int)time(NULL));
printf("====== PBFT 算法模拟开始 ======\n");
printf("总节点数: %d (主节点: 0, 副本节点: 1-%d)\n", NODE_COUNT, NODE_COUNT - 1);
printf("模拟请求数: %d\n", REQUEST_COUNT);
// 初始化每个节点的消息队列
// 为每个节点(包括主节点和所有副本节点)创建一个独立的消息队列
for (int i = 0; i < NODE_COUNT; i++) {
init_queue(&nodeQueues[i]);
printf("[主线程] 节点 %d 的消息队列已准备。\n", i);
}
// 创建非主节点线程(节点1到 NODE_COUNT-1)
// 每个副本节点都在一个独立的线程中运行,模拟并发处理
HANDLE nodeThreads[NODE_COUNT - 1]; // 用于存储副本线程的句柄
int nodeIds[NODE_COUNT - 1]; // 用于存储副本节点的ID,作为参数传递给线程函数
for (int i = 1; i < NODE_COUNT; i++) {
nodeIds[i - 1] = i; // 将节点ID存入数组,因为CreateThread需要一个指针
// 创建线程:
// NULL: 默认安全属性
// 0: 默认栈大小
// node_thread_proc: 线程函数的地址
// &nodeIds[i - 1]: 传递给线程函数的参数(当前节点的ID)
// 0: 立即运行线程
// NULL: 不需要获取线程ID
nodeThreads[i - 1] = CreateThread(NULL, 0, node_thread_proc, &nodeIds[i - 1], 0, NULL);
if (nodeThreads[i - 1] == NULL) { // 检查线程创建是否成功
fprintf(stderr, "[错误] 无法创建节点 %d 的线程。错误码: %lu\n", i, GetLastError());
// 实际应用中可能需要更复杂的错误处理,例如退出程序
return 1;
}
printf("[主线程] 已创建节点 %d 的线程。\n", i);
}
// 定义 PBFT 共识阈值
// 对于 n 个节点,f 是可以容忍的拜占庭节点数量,f = floor((n-1)/3)
// 达成共识需要 2f+1 个消息
// 在我们这个简化版(4个节点),f = floor((4-1)/3) = floor(1) = 1
// 所以理论上需要 2*1+1 = 3 个消息。
// 但为了简化演示,我们这里设置为 2,即主节点收到 2 个 Prepare/Commit 回复就认为达成共识
// 这在没有拜占庭节点或拜占庭节点数量少于 f 的情况下是可行的。
int threshold = 2;
printf("[主线程] 共识阈值设置为: %d (需要至少 %d 个 Prepare/Commit 回复)\n", threshold, threshold);
// 模拟 PBFT 共识过程,每个请求依次执行
for (int req = 0; req < REQUEST_COUNT; req++) {
printf("\n====== 请求 %d 开始 ======\n", req);
// --- 阶段1: Pre-Prepare 阶段 ---
// 主节点(节点0)发送 Pre-Prepare 消息给所有副本
printf("[主节点 0] 发送 Pre-Prepare 消息 (请求ID: %d) 给所有副本。\n", req);
for (int i = 1; i < NODE_COUNT; i++) {
Message *msg = (Message*)malloc(sizeof(Message));
if (msg == NULL) {
fprintf(stderr, "[错误] 主节点: 内存分配失败。\n");
// 实际应用中需要更复杂的错误处理
return 1;
}
msg->request_id = req;
msg->type = MSG_PREPREPARE;
msg->sender = 0; // 主节点是发送者
enqueue(&nodeQueues[i], msg); // 将消息放入对应副本的队列
}
// 主节点自身模拟处理 Pre-Prepare 延迟
Sleep(simulate_delay());
// --- 阶段2: 收集 Prepare 消息 ---
// 主节点从自己的队列中收集副本回复的 Prepare 消息
int prepareCount = 0;
printf("[主节点 0] 等待收集 Prepare 消息 (请求ID: %d)...\n", req);
while (prepareCount < threshold) {
Message *reply = dequeue(&nodeQueues[0]); // 从主节点的队列中取消息
// 确保收到的消息是当前请求的Prepare回复
if (reply->request_id == req && reply->type == MSG_PREPARE) {
prepareCount++;
printf("[主节点 0] 收到 Prepare 消息,发送者: %d (当前已收到 %d/%d)\n", reply->sender, prepareCount, threshold);
} else {
// 如果收到了不属于当前请求或类型不符的消息,打印警告并处理
printf("[主节点 0] 警告: 收到非预期的消息 (请求ID: %d, 类型: %d, 发送者: %d)。\n", reply->request_id, reply->type, reply->sender);
}
free(reply); // 释放消息内存
}
printf("[主节点 0] Pre-Prepare 阶段完成,收到 %d 个 Prepare 回复,达到阈值!\n", prepareCount);
// --- 阶段3: Commit 阶段 ---
// 主节点发送 Commit 消息给所有副本
printf("[主节点 0] 发送 Commit 消息 (请求ID: %d) 给所有副本。\n", req);
for (int i = 1; i < NODE_COUNT; i++) {
Message *msg = (Message*)malloc(sizeof(Message));
if (msg == NULL) {
fprintf(stderr, "[错误] 主节点: 内存分配失败。\n");
return 1;
}
msg->request_id = req;
msg->type = MSG_COMMIT;
msg->sender = 0; // 主节点是发送者
enqueue(&nodeQueues[i], msg); // 将消息放入对应副本的队列
}
// 主节点自身模拟处理 Commit 延迟
Sleep(simulate_delay());
// --- 阶段4: 收集 Commit 消息 ---
// 主节点从自己的队列中收集副本回复的 Commit 消息
int commitCount = 0;
printf("[主节点 0] 等待收集 Commit 消息 (请求ID: %d)...\n", req);
while (commitCount < threshold) {
Message *reply = dequeue(&nodeQueues[0]);
// 确保收到的消息是当前请求的Commit回复
if (reply->request_id == req && reply->type == MSG_COMMIT) {
commitCount++;
printf("[主节点 0] 收到 Commit 消息,发送者: %d (当前已收到 %d/%d)\n", reply->sender, commitCount, threshold);
} else {
// 如果收到了不属于当前请求或类型不符的消息,打印警告并处理
printf("[主节点 0] 警告: 收到非预期的消息 (请求ID: %d, 类型: %d, 发送者: %d)。\n", reply->request_id, reply->type, reply->sender);
}
free(reply); // 释放消息内存
}
printf("[主节点 0] Commit 阶段完成,收到 %d 个 Commit 回复,请求 %d 共识达成!\n", commitCount, req);
printf("====== 请求 %d 结束 ======\n", req);
}
// --- 阶段5: 终止和清理 ---
// 发送终止信号给所有非主节点线程,让他们安全退出
printf("\n[主线程] 发送终止信号给所有副本节点线程...\n");
for (int i = 1; i < NODE_COUNT; i++) {
Message *term = (Message*)malloc(sizeof(Message));
if (term == NULL) {
fprintf(stderr, "[错误] 主节点: 分配终止信号内存失败。\n");
// 即使失败,也要尝试等待已创建的线程
break;
}
term->request_id = -1; // 特殊请求ID表示终止
term->type = (MsgType)-1; // 特殊消息类型表示终止
term->sender = -1; // 无需发送者
enqueue(&nodeQueues[i], term); // 将终止消息放入副本节点的队列
}
// 等待所有非主节点线程结束
// WaitForMultipleObjects 会阻塞主线程,直到所有指定句柄的线程都退出
printf("[主线程] 等待所有副本节点线程结束...\n");
WaitForMultipleObjects(NODE_COUNT - 1, nodeThreads, TRUE, INFINITE);
printf("[主线程] 所有副本节点线程已安全退出。\n");
// 清理:删除每个队列的互斥锁和条件变量,并关闭线程句柄
// 释放同步原语资源
for (int i = 0; i < NODE_COUNT; i++) {
DeleteCriticalSection(&nodeQueues[i].cs);
// 条件变量不需要单独的Delete函数,它们是基于临界区实现的
}
printf("[主线程] 已清理所有消息队列的同步资源。\n");
// 关闭线程句柄,释放系统资源
for (int i = 0; i < NODE_COUNT - 1; i++) {
CloseHandle(nodeThreads[i]);
}
printf("[主线程] 已关闭所有副本节点线程句柄。\n");
printf("\n====== PBFT 仿真结束!======\n");
return 0; // 程序正常退出
}
代码分析:
-
初始化:
-
SetConsoleOutputCP(CP_UTF8);
和setlocale(LC_ALL, "zh_CN.UTF-8");
:这两行是为了确保控制台能够正确显示中文字符,避免乱码。对于技术博客来说,良好的输出体验是必须的! -
srand((unsigned int)time(NULL));
:初始化随机数生成器,让每次运行的延迟模拟更真实。 -
循环调用
init_queue
为每个节点初始化消息队列。
-
-
创建副本线程:
-
HANDLE nodeThreads[NODE_COUNT - 1];
和int nodeIds[NODE_COUNT - 1];
:用于存储线程句柄和传递给线程的ID。 -
CreateThread
:为每个副本节点(从1到NODE_COUNT-1
)创建一个独立的线程,并让它们执行node_thread_proc
函数。这里还加入了简单的错误检查,如果线程创建失败会打印错误信息。
-
-
共识阈值
threshold
:-
这里我们简单地将阈值设为2。在PBFT中,通常需要
2f+1
个诚实节点的投票才能达成共识,其中f
是可容忍的拜占庭节点数量。对于4个节点,f = floor((4-1)/3) = 1
,所以理论上需要3个投票。我们这里为了简化演示,设为2,这意味着我们假设系统是“友好的”,或者只关注最少需要多少个回复来推进流程。在更严谨的模拟中,需要严格遵循2f+1
原则。
-
-
主节点模拟共识过程:
-
外层循环
for (int req = 0; req < REQUEST_COUNT; req++)
:模拟处理多个客户端请求。 -
Pre-Prepare阶段: 主节点(节点0)
malloc
一个MSG_PREPREPARE
消息,并enqueue
到所有副本节点的队列中。然后主节点自己也Sleep
一下,模拟处理延迟。 -
收集Prepare消息: 主节点进入
while (prepareCount < threshold)
循环,不断从自己的队列中dequeue
消息。它会检查消息是否是当前请求的MSG_PREPARE
类型,如果是,就增加prepareCount
。一旦达到threshold
,就认为Pre-Prepare阶段完成。这里加入了对非预期消息的警告处理,增强了健壮性。 -
Commit阶段: 逻辑与Pre-Prepare阶段类似,主节点
malloc
并enqueue
MSG_COMMIT
消息给所有副本。 -
收集Commit消息: 同样,主节点循环
dequeue
并收集MSG_COMMIT
消息,直到达到threshold
。
-
-
终止与清理:
-
发送终止信号: 在所有请求处理完毕后,主节点会向每个副本节点的队列发送一个特殊的“终止消息”(
request_id = -1
,type = (MsgType)-1
)。这是非常优雅地关闭线程的方式,避免了强制终止可能导致的资源泄漏。 -
WaitForMultipleObjects
: 主线程会在这里阻塞,等待所有副本线程都收到终止信号并安全退出。TRUE
表示等待所有句柄都发出信号,INFINITE
表示无限期等待。 -
清理资源:
-
DeleteCriticalSection
:释放每个消息队列的互斥锁资源。 -
CloseHandle
:关闭所有线程句柄,释放系统资源。
-
-
1.6 小结与展望
恭喜你!到这里,我们已经完成了PBFT算法模拟的第一部分:破冰与基石。
我们一起:
-
了解了PBFT算法的基本概念、为什么它在分布式系统中如此重要,以及它的核心“三板斧”:Pre-Prepare、Prepare、Commit。
-
搭建了C语言和Windows多线程的开发环境,并深入理解了
CreateThread
、HANDLE
、DWORD WINAPI
、LPVOID
等Windows API的用法。 -
掌握了多线程编程中至关重要的同步原语:
CRITICAL_SECTION
(互斥锁)和CONDITION_VARIABLE
(条件变量),并通过生动的例子理解了它们的工作原理。 -
构建了PBFT模拟的核心数据结构:
Message
和MessageQueue
,并详细分析了enqueue
和dequeue
这两个线程安全的消息队列操作。 -
最后,我们通过一个完整的C语言代码示例,看到了PBFT模拟的整体骨架,包括节点线程的启动、主节点如何发起和收集消息,以及如何优雅地终止线程和清理资源。
这只是万里长征的第一步!在接下来的部分中,我们将在此基础上,逐步引入更复杂的PBFT机制,比如:
-
视图变更(View Change):当主节点失效时,如何选举新的主节点?
-
消息认证和签名:如何确保消息的真实性和完整性,防止恶意篡改?
-
状态机复制:如何让所有节点对执行的命令序列达成一致?
-
更完善的性能统计:除了简单的共识达成,我们还需要衡量吞吐量、延迟等指标。
敬请期待我的下一次更新!如果你在阅读或实践过程中有任何疑问,或者有任何想法和建议,都欢迎随时交流。咱们一起把这个PBFT模拟项目搞得更酷、更强大!
------------------------------------------------------------------------------------------------更新于2025.6.21 下午5:40