超硬核区块链算法仿真:联盟链PBFT多线程仿真实现 :c语言完全详解版 共5篇 从零开始,手把手教你用C语言模拟PBFT算法(第1部分-共5篇):小白也能看懂的分布式共识魔法!

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语言来搞这个项目呢?

  1. 性能与底层: C语言以其卓越的性能和对系统底层的直接操控能力而闻名。用C语言实现,能让我们更深刻地理解PBFT算法在资源管理、并发控制上的细节。

  2. 硬核挑战: 对于刷过牛客、力扣100热题榜的你来说,C语言的多线程、内存管理可能已经驾轻就熟。但将这些知识应用于复杂的分布式算法模拟,无疑是一次更高级的挑战,能让你在嵌入式、操作系统等领域走得更远。

  3. “裸奔”的快感: 没有花哨的框架,没有复杂的库依赖,我们用最纯粹的C语言来构建,就像亲手搭建一个精密的机械装置,那种掌控一切的快感,是其他语言难以比拟的。

  4. 面试加分项: 懂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

副本节点

所有节点

请求内容、视图编号、序列号、摘要

副本节点确认已准备好执行请求。

简单来说:

  1. Pre-Prepare(预准备):客户端发出请求后,主节点(Primary)收到请求,它会给这个请求分配一个序列号,然后把请求内容和序列号打包成一个Pre-Prepare消息,广播给所有其他副本节点(Replica)。这就像班长(主节点)收到老师的作业(请求)后,把作业内容和编号告诉所有同学(副本节点)。

  2. Prepare(准备):副本节点收到Pre-Prepare消息后,会验证消息的合法性(比如序列号是否正确、是否重复等)。如果验证通过,它会向所有其他节点(包括主节点自己)广播一个Prepare消息,表示“我收到了这个请求,并且我已经准备好处理它了!”。这就像同学们收到作业后,互相告诉一声:“我收到作业了,准备开始写了!”

  3. 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语言的开发环境。我推荐以下两种:

  1. Visual Studio (推荐):如果你在Windows上,Visual Studio是首选。它集成了编译器、调试器和IDE,对Windows API的支持也最好。

  2. 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 多线程编程的基石:CreateThreadHANDLEDWORD WINAPILPVOID

在我们的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_SECTIONCONDITION_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唤醒一个等待的线程。

表格:线程同步原语对比

原语名称

类型

主要作用

典型使用场景

CRITICAL_SECTION

互斥锁

保护共享资源,确保同一时间只有一个线程访问

访问共享变量、修改数据结构(如链表、队列)

CONDITION_VARIABLE

条件变量

线程等待特定条件满足,避免忙等待

生产者-消费者模型、任务队列、线程间协作

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 结构体详解

成员变量

类型

描述

作用

request_id

int

客户端请求的唯一标识符。

确保每个消息都与特定的请求关联,避免混淆。

type

MsgType

枚举类型,表示消息是Pre-Prepare、Prepare还是Commit。

决定接收节点如何处理这个消息,是进入Prepare阶段还是Commit阶段。

sender

int

发送此消息的节点的编号(例如,0是主节点,1、2、3是副本节点)。

标识消息来源,用于验证和统计,比如主节点需要知道哪个副本回复了Prepare。

next

Message *

指向下一个Message结构体的指针。

将消息连接成链表,作为消息队列的基础数据结构。

1.4.2 消息类型 MsgType

MsgType枚举清晰地定义了PBFT协议中的三种核心消息类型。它们是协议流程的“信号灯”,指示着当前处于哪个阶段。

表格:MsgType 枚举值

枚举值

对应PBFT阶段

描述

MSG_PREPREPARE

Pre-Prepare

主节点向副本节点发送的初始请求消息。

MSG_PREPARE

Prepare

副本节点收到Pre-Prepare后,向所有节点发送的确认消息。

MSG_COMMIT

Commit

节点收到足够Prepare消息后,向所有节点发送的提交消息。

1.4.3 消息队列 MessageQueue

每个节点都需要一个独立的“收件箱”来接收来自其他节点的消息。这个“收件箱”就是我们的MessageQueue。由于多个线程(发送者)可能会同时往一个队列里放消息,而只有一个线程(接收者)从队列里取消息,所以它必须是线程安全的。

// 消息队列结构体:每个节点拥有一个独立的队列
typedef struct {
    Message *head;          // 队列头部指针
    Message *tail;          // 队列尾部指针
    CRITICAL_SECTION cs;    // 互斥锁,保证队列操作的线程安全
    CONDITION_VARIABLE cv;  // 条件变量,用于等待队列不为空
} MessageQueue;

表格:MessageQueue 结构体详解

成员变量

类型

描述

作用

head

Message *

指向队列中第一个消息的指针。

用于出队操作,总是从队列头部取出消息。

tail

Message *

指向队列中最后一个消息的指针。

用于入队操作,总是将新消息添加到队列尾部。

cs

CRITICAL_SECTION

Windows提供的临界区对象。

保护headtail指针以及队列的链表结构,防止多线程同时修改导致数据损坏。

cv

CONDITION_VARIABLE

Windows提供的条件变量对象。

当队列为空时,让dequeue线程进入等待状态;当有新消息入队时,唤醒等待线程。

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阶段类似,主节点mallocenqueue 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多线程的开发环境,并深入理解了CreateThreadHANDLEDWORD WINAPILPVOID等Windows API的用法。

  • 掌握了多线程编程中至关重要的同步原语:CRITICAL_SECTION(互斥锁)和CONDITION_VARIABLE(条件变量),并通过生动的例子理解了它们的工作原理。

  • 构建了PBFT模拟的核心数据结构:MessageMessageQueue,并详细分析了enqueuedequeue这两个线程安全的消息队列操作。

  • 最后,我们通过一个完整的C语言代码示例,看到了PBFT模拟的整体骨架,包括节点线程的启动、主节点如何发起和收集消息,以及如何优雅地终止线程和清理资源。

这只是万里长征的第一步!在接下来的部分中,我们将在此基础上,逐步引入更复杂的PBFT机制,比如:

  • 视图变更(View Change):当主节点失效时,如何选举新的主节点?

  • 消息认证和签名:如何确保消息的真实性和完整性,防止恶意篡改?

  • 状态机复制:如何让所有节点对执行的命令序列达成一致?

  • 更完善的性能统计:除了简单的共识达成,我们还需要衡量吞吐量、延迟等指标。

敬请期待我的下一次更新!如果你在阅读或实践过程中有任何疑问,或者有任何想法和建议,都欢迎随时交流。咱们一起把这个PBFT模拟项目搞得更酷、更强大!














------------------------------------------------------------------------------------------------更新于2025.6.21 下午5:40







 




 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值