- 介绍
- 想法
- 实际实现
-
DHT
-
Chord 算法介绍
-
我自己实现的节点和数据
-
find_successor
-
创建/加入网络
-
稳定机制
-
fix_fingers()
-
stabilize()
和notify()
-
check_predecessor()
- 完整流程
- 额外资源和参考资料
- 我是谁?
- Hack Club 的 Arcade 🟧
在开始之前,我想先说明一下我的经历。这篇文章既可以被视为我的个人经历记录,也可以被视为用C语言实现Chord协议的指南。希望您喜欢这篇文章!
我最近创建了一个点对点文件分享系统(你可以在这里找到完整的代码库链接here,如果你觉得不错,别忘了给我点个⭐️哦:D),我是用C语言编写的,我打算分享一下我为何开始这个项目,我又是如何实现它的,以及它又是如何使我成为更好的程序员的。
想法是我是一名高中生,同时也是一名网页开发者,特别对云计算感兴趣。有一段时间了,我开始接触C语言,想要深入理解计算机底层工作原理以及如何有效地进行操作。为了真正理解计算机在底层的工作原理以及如何有效地与之交互,因此我决定做一些底层的网络项目,比如我用C语言实现了HTTP协议(https://github.com/alessandrofoglia07/minimalist-http-server)以及一个简单的聊天应用(https://github.com/alessandrofoglia07/chat-application-c)。无疑,这个项目是最具挑战的一个!
最初,我开始实施一个由无序节点组成的分布式网络,就像我在编码这些项目时所学到的那样。然而不久之后,我意识到我无意中选择的方法非常低效,不适合大规模网络。因此,我开始寻找更优化的解决方案,后来我发现了分布式哈希表(DHT)和Chord算法。作为一名高中生,我首先向ChatGPT问道,“在p2p应用程序中实现DHT有哪些好处?”它用诸如“去中心化”、“容错性”、“高效性”和“鲁棒性”这样的专业术语回答了我;然而,它也提到了一个缺点:“实现复杂性”。作为一个几乎不懂如何实现简单数据结构的学生,我可以肯定地说,它确实是事实 😅。
实际操作DHTs (分布式哈希表)
所以,准确地说……分布式哈希表(DHT)到底是什么?
像名字暗示的那样,DHT是一种hash表,它分布在网络中的多个节点上。通过一致性哈希,任意节点都可以找到包含特定键值的节点的位置(例如IP地址和端口),然后联系该节点并获取该值。
关于Chord算法的介绍
那么,Chord是如何有效构建DHT的呢?
Chord(在2001年作为最初的四种DHT协议之一被引入的算法)创建了一个环形网络,在这个网络中,每个节点根据通过哈希函数生成的ID占据一个特定位置(我个人使用的是SHA-1函数)。每个节点都有一个后继和一个前驱:后继是指顺时针方向的下一个节点,前驱是指逆时针方向的前一个节点。环中最多可以有2的M次方个节点,其中M是ID的位数(如果使用SHA-1函数,M就是160位)。
哈希值(Key) = 125
向下
n1 ---------- n2
(100) (200)
| |
| |
| |
n4 ---------- n3
(400) (300)
全屏模式退出全屏
最终目标是让系统实现一个 find_successor(hash(key))
函数,用于查找键在DHT里的位置。这个函数会找到并返回负责该键的节点(也就是对等点)的位置。
将键值进行哈希处理 (Hash(键)) 和找到后续节点 (FindSuccessor(Hash(键))),如图所示:
Hash(键) FindSuccessor(Hash(键))
↓ ↓
n1 ---------- n2
(100) (200)
| |
| |
| |
n4 ---------- n3
(400) (300)
如图所示,n1 和 n2 的哈希值分别为100和200,而它们之间的连线表示数据传输。同样,n4 和 n3 的哈希值分别为400和300,它们之间也有数据传输。
切换到全屏模式,退出全屏
这些酷炫的插图由@arush15june提供。Chord算法建议使用所谓的指针数组,该数组包含M个节点的数据,以避免线性搜索,转而采用更快的查找方法。
我的节点/数据的实现
从这一点起,我将展示伪代码版本,以及其下方我的C语言实现版的简化版。
具体来说,比如这是我如何定义 Node
的。
typedef struct Node {
uint8_t id[20]; // SHA-1 哈希值
char ip[16]; // 例如:127.0.0.1
int port; // 例如:5000
struct Node *successor; // 后继
struct Node *predecessor; // 前驱
struct Node *finger[M]; // 指针表(节点数组)
struct FileEntry *files; // 负责的文件
struct FileEntry *uploaded_files; // 上传的文件
int sockfd; // 套接字文件描述符
} Node;
点击全屏/点击窗口
这里是 FileEntry
结构:
typedef struct 文件条目 {
uint8_t id[20]; // 文件的哈希值(用于查找)
char filename[256];
char filepath[512];
char owner_ip[16];
int owner_port; // 所有者的端口
struct 文件条目 *next;
} 文件条目;
切换到全屏 切换回正常模式
在我的实现里,每个节点只存储文件的元数据,这样,寻找文件的对等方可以直接从最初分享该文件的节点下载。我的查询流程如下:
- 节点A 请求负责节点B 提供文件
- B 用 FileEntry 对象回应(回传给 A)
- A 联系 C(通过 file.owner_ip 和 file.owner_port 这两个地址)并下载文件。
进入全屏 退出全屏
查找后继
函数
如我之前所说,算法最重要的功能是find_successor
函数。该函数首先检查是否是本地节点,通过将其ID与后继节点的ID以及给定的ID进行比较。如果不是,节点然后在其本地指纹表中查找最接近的前驱的ID。
下面的代码是 find_successor
函数的代码。
n.find_successor(id)
// 这是一个半闭区间。
如果 id 属于 (n, successor] 区间,
返回 successor
否则
// 将查询沿着环转发
n0 := n.closest_preceding_node(id)
返回 n0.find_successor(id)
n.closest_preceding_node(id)
对于 i 从 m 到 1 递减循环,
如果 finger[i] 属于 (n, id) 区间,
返回 finger[i]
返回 n
点击进入全屏 点击退出全屏
// 检查 id 是否在半开区间 (a, b)
int is_in_interval(const uint8_t *id, const uint8_t *a, const uint8_t *b) {
if (memcmp(a, b, HASH_SIZE) < 0) {
return memcmp(id, a, HASH_SIZE) > 0 && memcmp(id, b, HASH_SIZE) < 0;
}
return memcmp(id, a, HASH_SIZE) > 0 || memcmp(id, b, HASH_SIZE) < 0;
}
Node *find_successor(Node *n, const uint8_t *id) {
if (memcmp(id, n->id, HASH_SIZE) > 0 && memcmp(id, n->successor->id, HASH_SIZE) <= 0) {
return n->successor;
}
const Node *n0 = closest_preceding_node(n, id);
// 避免向自己发送消息
if (n0 == n) {
return n->successor;
}
// 向 n0 发送 FIND_SUCCESSOR
send_message(n, n0->ip, n0->port, "FIND_SUCCESSOR");
// 接收响应
const Node response = receive_message();
Node *successor = n->successor;
memcpy(successor->id, response->id, HASH_SIZE);
strcpy(successor->ip, response->ip);
successor->port = response->port;
return successor;
}
Node *closest_preceding_node(Node *n, const uint8_t *id) {
// 从后向前搜索手指表,找到最接近的节点
for (int i = M - 1; i >= 0; i--) {
if (is_in_interval(n->finger[i]->id, n->id, id)) {
return n->finger[i];
}
}
return n;
}
进入全屏模式,退出全屏模式
创建网络/加入网络
节点可以将其前驱设为nil/NULL,后继设为自己来创建一个新环,如下:
在初始化时,我们调用 n.create()
,并设置前驱节点为 nil
,后继节点为 n
。
点击全屏按钮进入全屏模式,再点击退出按钮。
// 创建一个环,使节点的前驱和后继都指向自身
void create_ring(Node *n) {
n->前驱 = NULL;
n->后继 = n;
}
点击这里全屏观看,点击这里退出全屏
如果一个节点想加入现有的环,它需要知道至少一个其他节点的位置。
n.join(n0);
predecessor := 空;
successor := n0.find_successor(n);
切换到全屏模式,退出全屏
void join_ring(Node *n, const char *existing_ip, const int existing_port) {
// 给现有节点发 JOIN 消息
send_message(n, existing_ip, existing_port, "JOIN");
// 响应的消息中包含新节点的后继
Message response = receive_message();
// 创建一个新的节点作为后继
Node *successor = create_node(response->ip, response->port);
// 设置新节点的后继为返回的节点
n->predecessor = NULL;
n->successor = successor;
}
点击进入全屏/退出全屏
稳定过程
此时,算法在这里遇到了一个难题。我们需要一种方法来确保在网络节点加入或离开后,网络结构保持不变。网络需要动态更新以允许节点随意加入,同时移除不再使用的节点,重新稳定环形结构。我们可以通过一个稳定程序来解决这个问题。我们需要定期检查这些内容。
- 当前的继任者依然没有变化,并且在这两者之间没有新的节点加入;
- 前驱节点仍然存活;
- finger table已经被更新。
我们可以用这些函数来完成这个,比如 fix_fingers()
、stabilize()
、notify()
和 check_predecessor()
:
修复手指() # 技术术语,意指解决问题或调试
// 定期调用。刷新指针表中的条目。
// next 存储需要修复的指针的索引
n.fix_fingers()
next := next + 1
如果 next > m,则
next := 1
finger[next] := find_successor(n+2*next-1); // 找到并更新相应的指针条目
点击全屏 点击退出全屏
void 修正指针(Node *n, int *下一个指针) {
*下一个指针 = (*下一个指针 + 1) % 模数;
// 找到下一个指针指向的节点
Node *finger = find_successor(n, n->finger[*下一个指针]->id);
// 更新指针指向新的节点
n->finger[*下一个指针] = finger;
}
全屏模式 退出全屏
稳定状态()
和 通知变化()
// 定期执行。n 查询其后继者关于其前驱者,验证 n 的直接后继者是否一致,并将 n 的信息告知后继者
n.stabilize()
x = 后继者的前驱者
if x ∈ (n, successor) 且 x 不等于 n then
successor := x
后继者通知 n
n 通知 n0
if predecessor 不存在或 n0 在 predecessor 和 n 之间 then
predecessor := n0
进入全屏模式。退出全屏模式。
/*
* 定期调用。向后继节点请求其前驱节点,
* 验证前驱节点是否为自身。如果不是,则说明新节点位于自身和该后继之间。
* 因此,将新节点设为新的后继节点,并通知新节点其新的前驱。
*/
void stabilize(Node *n) {
// 向后继节点发送一个 STABILIZE 消息
send_message(n, n->successor->ip, n->successor->port, "STABILIZE");
// 接收并验证后继节点的前驱节点信息
Message response = receive_message();
// x 是后继节点的前驱节点
Node *x = create_node(response->ip, response->port);
// 如果 x 在区间 (n, 后继) 内,则更新后继
if (is_in_interval(x->id, n->id, n->successor->id)) {
n->successor = x;
}
// 通知新后继更新其前驱
notify(n->successor, n);
}
void notify(Node *n, Node *n_prime) {
// 向 n 发送一个 NOTIFY 消息
// 通知 n_prime 可能成为其新的前任节点
send_message(n_prime, n->ip, n->port, "NOTIFY");
if (n->predecessor == NULL || is_in_interval(n->id, n->predecessor->id, n->id)) {
n->predecessor = n_prime;
}
}
点击全屏/退出全屏
检查前驱()
// 检查前驱节点的状态
// 定期检查。
n.check_predecessor()
// 检查前驱是否已失效
if 前驱已失效 then
// 将前驱设置为 nil
predecessor := nil
全屏模式,退出全屏
void 检查前继(Node *n) {
if (!n->前继) {
return;
}
// 向前继发送心跳
// 如果前继没回应,说明它挂了 -> 设为 NULL
if (发送心跳(n, n->前继->ip, n->前继->port, "HEARTBEAT") < 0) {
n->前继 = NULL;
}
}
全屏模式 退出全屏
完整日常
我建立了一个多线程环境(使用pthread
API接口),并创建了一个每15秒运行一次此例程的线程,如官方Chord论文中的建议。
void *node_thread(void *arg) {
Node *n = (Node *) arg;
while (1) {
stabilize(n); // 稳定节点
fix_fingers(n, &(int){0}); // 修复手指
check_predecessor(n); // 检查前驱
sleep(15); // 暂停15秒
}
}
全屏模式,退出全屏。
附加资源和其他参考资料如果你想更深入地了解DHT和Chord算法是如何工作的,我可以给你推荐一些我发现特别有用的资源:
- 我的点对点文件共享系统项目 (请给我点个 ⭐ 哦,我会非常感激 :D)
- Chord(点对点)- 维基百科
- Chord 官方论文
- Golang 实现的 Chord 协议 - 实用文章
我是对 Web 开发感兴趣的意大利的高中生 🧙♂️。如果你想支持我,可以在这里和在我的 GitHub 关注我,我会非常感激你的支持 💜
Hack Club 的 Arcade 🟧我想感谢Hack Club和GitHub Education,他们激励我们这些高中生编写代码,学习新事物,并一起创造令人惊叹的项目。如果你是一名高中生编程爱好者,我强烈建议你加入Hack Club,找到志同道合的人,并申请GitHub Education套餐,提升你的编程水平。我确信你不会后悔的!🚀
共同学习,写下你的评论
评论加载中...
作者其他优质文章