跳转到内容

Lesson 56: 向量时钟与分布式 Happens-Before

练习任务

难度:中 【标杆题】

实现一个 3 节点分布式系统中的向量时钟 (Vector Clock) 机制,按固定事件序列更新时钟,并编写 happens_before() 函数判断两个事件的因果关系。

你需要完成 6 个核心函数和 main 事件循环分:

  1. init_clocks() — 初始化所有节点的向量时钟为 [0, 0, 0]
  2. local_event(node_id) — 本地事件:递增自己分量的计数器
  3. send_event(from, to, msg_clock) — 发送事件:递增自己,复制整个向量到消息快照
  4. recv_event(to, msg_clock) — 接收事件:逐元素取 max 合并,再递增自己
  5. print_clocks(event_num, desc) — 格式化输出所有节点的向量时钟
  6. happens_before(a, b) — 判断两个向量时钟的 Happens-Before 关系
  7. main 事件循环 — 按固定序列执行 6 个事件,保存快照,运行 9 组 HB 测试

程序输出分为两部分:时钟追踪(每次事件后打印 P0/P1/P2 的向量时钟)和 Happens-Before 测试(9 组事件对的 YES/NO 判断)。

固定事件序列(3 节点 P0/P1/P2,6 个事件,无需处理输入):

E1: P0 SEND to P1 P0 P1 发送消息
E2: P1 RECV from P0 P1 接收 P0 的消息
E3: P1 LOCAL P1 执行本地计算
E4: P2 RECV from P1 P2 接收 P1 的消息
E5: P2 LOCAL P2 执行本地计算
E6: P0 RECV from P2 P0 接收 P2 的消息

提示:向量时钟的核心洞察是——每个节点不维护单一计数器,而是为每个节点维护一个计数器向量。VC[i][j] 表示节点 i 所知的"节点 j 上发生的事件数"。通过消息交换合并知识,可以精确判定因果关系(充要条件),而不仅仅是必要条件。


核心知识点

  • 分布式时间难题 — 物理时钟漂移、NTP 误差、闰秒导致物理时间戳无法可靠判定分布式事件顺序
  • Lamport 逻辑时钟 — 规则:本 │ C++ / 接收 C=max(C,C_msg)+1;保证 a→b ⇒ C(a)<C(b),但逆命题不成立(无法检测并发)
  • 向量时钟核心思想 — 为每个节点维护计数器向量 VC[0..N-1],VC[i][j] = 节点 i 所知节点 j 的事件数
  • 向量时钟三大操作 — 本地:VC[self]++;发送:VC[self]++ 后复制整个向量;接收:逐元素 max 合并后 VC[self]++
  • Happens-Before 判定 — a→b 当且仅当 ∀k: VC_a[k]≤VC_b[k] 且 ∃k: VC_a[k]<VC_b[k];需要 lt 标志避免自反
  • 因果历史的传递性 — 一次消息传递可携带多跳之前的因果历史,接收方通过合并获知从未直接交互的节点的事件
  • Lamport vs 向量时钟 — Lamport 是必要条件(a→b ⇒ C(a)<C(b)),向量时钟是充要条件(a→b ⇔ VC(a)<VC(b))
  • 向量时钟 vs 版本向量 — 向量时钟每次事件都递增,版本向量仅在写入时递增;前者追踪因果关系,后者追踪版本冲突
  • O(N) 代价与优化 — 空间和消息开销均为 O(N),大规模系统中可用稀疏向量时钟、Dotted Version Vectors 或 HLC 优化

代码框架

56_vector_clocks.c
c
#include <stdio.h>

#define N_NODES 3
#define N_EVENTS 6

typedef struct {
    int id;
    int clock[N_NODES];
} Node;

typedef struct {
    int type;         /* 0=send, 1=recv, 2=local */
    int from;
    int to;
    const char *desc;
} Event;

static Node nodes[N_NODES];

/* ---------- 初始化所有节点的向量时钟为 0 ---------- */
static void init_clocks(void) {
    // ① 遍历所有节点: nodes[i].id = i; clock 数组全置 0
}

/* ---------- 本地事件:递增自己分量的时钟 ---------- */
static void local_event(int node_id) {
    // ② nodes[node_id].clock[node_id]++
}

/* ---------- 发送事件:递增自己,复制时钟快照 ---------- */
static void send_event(int from, int to, int *msg_clock) {
    (void)to;
    // ③ nodes[from].clock[from]++
    // ④ for i=0..N-1: msg_clock[i] = nodes[from].clock[i]
}

/* ---------- 接收事件:合并后递增 ---------- */
static void recv_event(int to, const int *msg_clock) {
    // ⑤ for i=0..N-1: if msg_clock[i] > nodes[to].clock[i]: nodes[to].clock[i] = msg_clock[i]
    // ⑥ nodes[to].clock[to]++
}

/* ---------- 打印所有节点的向量时钟 ---------- */
static void print_clocks(int event_num, const char *desc) {
    // ⑦ 格式: "E%d: %s\n" "  P%d: [%d, %d, %d]\n" (每个节点一行)
}

/* ---------- Happens-Before 判断 ---------- */
static int happens_before(const int *a, const int *b) {
    // ⑧ lt=0; for k: if a[k]>b[k] return 0; if a[k]<b[k] lt=1; return lt
}

static void test_hb(const char *label, const int *c1, const int *c2, int expected) {
    int result = happens_before(c1, c2);
    printf("  %s: %s (expected %s)\n", label, result ? "YES" : "NO", expected ? "YES" : "NO");
}

int main(void) {
    int snapshots[N_EVENTS + 1][N_NODES][N_NODES];

    Event events[N_EVENTS] = {
        {0, 0, 1, "P0 SEND to P1"},   {1, 0, 1, "P1 RECV from P0"},
        {2, -1, 1, "P1 LOCAL"},       {1, 1, 2, "P2 RECV from P1"},
        {2, -1, 2, "P2 LOCAL"},       {1, 2, 0, "P0 RECV from P2"},
    };

    init_clocks();

    printf("=== Vector Clocks: 3 Nodes (P0, P1, P2) ===\n\n");

    /* 保存初始快照 */
    for (int i = 0; i < N_NODES; i++)
        for (int j = 0; j < N_NODES; j++)
            snapshots[0][i][j] = nodes[i].clock[j];

    // ⑨ 事件循环: for e=0; e<N_EVENTS; e++:
    //     switch events[e].type:
    //       case 0: msg_clock[N_NODES]; send_event(...);
    //       case 1: msg_clock[N_NODES]; 从发送方取当前时钟; recv_event(...);
    //       case 2: local_event(...);
    //     每次事件后: print_clocks(...); 保存快照

    /* Happens-Before 测试 */
    printf("\n=== Happens-Before Tests ===\n\n");
    printf("Rule: A→B iff for all k: A[k]<=B[k] AND exists k: A[k]<B[k]\n\n");

    int *c1 = snapshots[1][0], *c2 = snapshots[2][1], *c3 = snapshots[3][1];
    int *c4 = snapshots[4][2], *c5 = snapshots[5][2], *c6 = snapshots[6][0];

    test_hb("E1→E2 (send→recv)", c1, c2, 1);
    test_hb("E2→E3 (same node)", c2, c3, 1);
    test_hb("E1→E3 (transitive)", c1, c3, 1);
    test_hb("E3→E4 (send→recv)", c3, c4, 1);
    test_hb("E3→E5 (transitive)", c3, c5, 1);
    test_hb("E1→E5 (chain)", c1, c5, 1);
    test_hb("E5→E1 (reverse)", c5, c1, 0);
    test_hb("E3→E6 (full chain)", c3, c6, 1);
    test_hb("E4→E6 (send→recv)", c4, c6, 1);

    return 0;
}

阅读骨架后,尝试自己填充 // ①// ⑨ 标记的部分。核心挑战在于:向量时钟的三个操作规则如何协作?happens_before 中的 lt 标志为什么必不可少?事件循环中为什么 RECV 的消息快照来自发送方"当前"的时钟而不是之前 SEND 保存的旧值?

TIP

先不要往下翻看参考解答。用纸笔完整追踪一遍 E1 到 E6 的向量时钟变化。特别注意 E4(P2 RECV from P1)——为什么 P2 在接收一条消息后能获知 P0 的 E1 和 P1 的 E2、E3?这揭示了向量时钟的什么核心性质?


深度讲解

1. 分布式系统中的"时间"难题

1.1 为什么物理时钟不可靠?

在单机上,你可以信任 gettimeofday()——调用 A 返回的时间戳小于调用 B,那么 A 一定发生在 B 之前。但在分布式系统中,这完全失效:

┌──────────────┐     ┌──────────────┐     ┌────────────────┐
   Node P0   Node P1   Node P2

 clock: 10:00 clock: 10:02 clock: 09:58
   (fast)     │     │   (correct)  │     │   (slow)     │
└──────────────┘     └──────────────┘     └──────────────┘

三个基本问题:

  • NTP 同步误差:局域网 1-10ms,广域网可达 100ms+
  • 时钟漂移 (clock drift)t):石英晶体振荡器频率偏差,每天可达数秒
  • 闰秒 (leap second):全球统一插入的额外一秒,不同系统处理方式不同
  • 虚拟机暂停/恢复:导致时间跳跃

结论:物理时间戳无法可靠地判定分布式事件顺序。

1.2 Happens-Before 关系的形式定义

Leslie Lamport 在 1978 年的经典论文中定义了 Happens-Before 关系(记作 →),它是最小的传递关系,满足:

1. 同一进程内: a b 之前发生,则 a b
2. 消息传递: a 是发送消息事件,b 是该消息的接收事件,则 a b
3. 传递闭包: a b b c,则 a c

Happens-Before 是一个严格的偏序关系 (partial order):

  • 传递性: a→b 且 b→c ⇒ a→c
  • 非对称性: a→b ⇒ ¬(b→a)
  • 非自反性: ¬(a→a)

如果两个事件既非 a→b 也非 b→a,则称它们是并发的 (concurrent),记作 a ∥ b。


2. Lamport 逻辑时钟及其根本局限

2.1 Lamport 时钟规则

每个进程维护一个整数计数器 C:

规则 1: 本地事件或发送消息前 C := C + 1
规则 2: 接收消息时 C := max(C_local, C_msg) + 1
lamport_clock_rules.c
c
/* Lamport 逻辑时钟示例 */
int C = 0;

void local_or_send(void) {
    C = C + 1;           // 规则 1
}

void recv(int msg_clock) {
    C = (C > msg_clock ? C : msg_clock) + 1;  // 规则 2
}

2.2 核心性质与局限

Lamport 时钟保证了如果 a → b 则 C(a) < C(b)(Happens-Before 蕴含时钟递增),但其逆命题不成立

场景: P0 本地事件 (C=1) 和 P1 本地事件 (C=1)
  C(事件1) < C(事件2)?  不能确定,因为两者都是 1

即使人为错开:
  P0 本地: C=1
  P1 本地: C=2
  C(1) < C(2),但这两个事件完全是并发的!
    它们之间没有任何因果联系。

这就是 Lamport 时钟的核心局限——它只能给出"可能因果",无法检测并发。向量时钟正是为解决此问题而设计。

┌─────────────────────────────────────────────────────────────────┐

   Lamport 时钟: a→b C(a) < C(b)     (必要条件, )              │
   向量时钟:    a→b VC(a) < VC(b)   (充要条件, )              │

   充要条件意味着: 你可以仅通过比较向量时钟确定两个事件之间
   是否有因果关系——不需要知道它们的具体内容或通信历史。

└─────────────────────────────────────────────────────────────────┘

3. 向量时钟的核心思想与三大操作

3.1 数据结构与直觉

不是维护单个计数器,而是为每个节点维护一个计数器向量

VC[i] = 节点 i 的向量时钟,长度为 N (节点总数)
VC[i][j] = 节点 i 所知的"节点 j 上发生的事件数"
┌──────────────────────────────────────────────────┐
   P0 的向量时钟: [P0事件数, P1事件数, P2事件数]

   例: P0.VC = [3, 1, 0] 表示:
     - P0 知道自己在这次因果历史中发生了 3 个事件
     - P0 知道 P1 发生了 1 个事件 (通过消息获知)     │
     - P0 还不知道 P2 有任何事件
└───────────────────────────────────────────────────┘

每个节点只递增自己分量的计数器,但通过消息交换获知其他节点的进展。

3.2 三大操作规则

规则 1 本地事件 (local_event):
  VC[self] += 1

  只有自己分量的计数器递增。
  含义: "我在我的因果历史中又向前走了一步。"

规则 2 发送消息 (send_event):
  VC[self] += 1 发送本身是一次事件
  将整个 VC 向量附在消息上 消息携带发送方的"因果历史快照"

  含义: "我把我所知道的一切因果历史都告诉你了。"

规则 3 接收消息 (recv_event):
  for k in 0..N-1:
    VC[k] = max(VC[k], msg.VC[k])   逐元素合并 (merge)
  VC[self] += 1 接收本身是一次事件

  含义: "我把你的知识和我的知识合并,再加上接收这个事实。"
vector_clock_operations.c
c
void local_event(Node *nodes, int self) {
    nodes[self].clock[self] += 1;
}

void send_event(Node *nodes, int from, int *msg_clock) {
    nodes[from].clock[from] += 1;
    for (int i = 0; i < N_NODES; i++)
        msg_clock[i] = nodes[from].clock[i];
}

void recv_event(Node *nodes, int to, const int *msg_clock) {
    for (int i = 0; i < N_NODES; i++)
        if (msg_clock[i] > nodes[to].clock[i])
            nodes[to].clock[i] = msg_clock[i];
    nodes[to].clock[to] += 1;
}

3.3 为什么是逐元素取 max 而不是求和?

因为向量时钟的分量是计数而非版本号。如果你知道我发生了 3 个事件,而我知道自己发生了 5 个事件,那么真实值是 5(取 max),而不是 8(求和)。求和会导致计数膨胀,破坏偏序关系。

反例: 若用求和
  P0 时钟: [3, 0]   P1 时钟: [0, 3]
  P1 发给 P0 后: P0 用求和 [6, 3]?  实际上应该是 [3, 3]!

  VC[0]=6 意味着"P0 知道 P0 发生了 6 个事件"——这显然是错的。
  P0 只发生了 3 个事件,取 max(3, 3) = 3 才是正确的。

3.4 接收事件中"先合并再递增"的顺序

为什么先合并再递增?

  接收事件本身是一个"发生"——它必须发生在消息到达之后。
  如果先递增再合并:
    nodes[to].clock[to]++   // 递增到 1
    合并 msg_clock           // msg_clock 可能覆盖掉 clock[to]
    
  递增丢失! 接收方自己的事件计数不准确。

  正确顺序: 先合并 (学习发送方的知识),再递增 (记再加上接收这个事件)。

4. Happens-Before 判定算法

4.1 判定条件

happens_before(VC_a, VC_b):
  输入: 两个事件的向量时钟
  输出: 1 表示 a→b, 0 表示不成立

  lt = 0  // 是否存在严格小于的分量
  for k in 0..N-1:
    if VC_a[k] > VC_b[k]:
      return 0   // 发现逆序,绝不可能是 a→b
    if VC_a[k] < VC_b[k]:
      lt = 1     // 记录至少一个严格小于

  return lt  // 全部 且至少一个 <

4.2 三种可能的结果

┌──────────┬──────────────────────────────────────┐
 关系 向量时钟特征
├──────────┼──────────────────────────────────────┤
 a b ∀k: VC_a[k] VC_b[k]
 ∃k: VC_a[k] < VC_b[k]
├──────────┼──────────────────────────────────────┤
 b a ∀k: VC_b[k] VC_a[k]
 ∃k: VC_b[k] < VC_a[k]
├──────────┼──────────────────────────────────────┤
 a b 既非 a→b 也非 b→a
 (并发)   │ ∃i,j: VC_a[i] < VC_b[i] 且           │
        VC_a[j] > VC_b[j]
└──────────┴──────────────────────────────────────┘

4.3 lt 标志的必要性

如果所有分量都相等 (VC_a[k] == VC_b[k] for all k),说明两个事件具有相同的因果历史——它们可能是同一个事件,或者是同一节点上连续发生的两个事件中较晚的那个。自己不能 Happens-Before 自己,所以 lt 标志是必需的:

例: 同一事件与自身比较
  VC_a = [1, 1, 0], VC_b = [1, 1, 0]
  
  遍历所有分量: 每个 k 都满足 a[k] <= b[k]
  但没有任何 k 满足 a[k] < b[k] lt 始终为 0
  
  return lt = 0 happens_before(a, a) = 0

5. 事件序列完整时间线追踪

5.1 消息流拓扑

═══════════════════════════════════════════════════════════════════
                       时间方向
═══════════════════════════════════════════════════════════════════

P0: ──[SEND]────────────────────────────────────[RECV]──→
       E1                                         E6
       [1,0,0]                                   [2,2,2]

  消息(VC=[1,0,0])                         消息(VC=[1,2,2])

P1: ──[RECV]──[LOCAL]───────────────────────────────│──→
       E2      E3
       [1,1,0] [1,2,0]

 消息(VC=[1,2,0])

P2: ─────────────[RECV]──[LOCAL]────────────────────│──→
                  E4      E5
                  [1,2,1] [1,2,2]

 消息(VC=[1,2,2])
                           └────────────────────────┘

消息流链:P0 → P1 → P2 → P0,形成完整的环形因果链。E1 的因果影响通过消息传递最终回到 P0(E6),使得 P0 在 E6 后的时钟 [2,2,2] 包含了所有 6 个事件的因果历史。

5.2 逐事件追踪表

下表展示每个事件执行后,所有 3 个节点的点的向量时钟状态。注意只有执行事件的节点时钟发生变化——其他节点的时钟保持不变。

┌──────┬───────────────┬──────────────┬──────────────┬──────────────┐
 事件 描述 P0 时钟 P1 时钟 P2 时钟
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
 初始 (所有时钟为0) │ [0, 0, 0]    │ [0, 0, 0]    │ [0, 0, 0]    │
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
  E1 P0 SEND→P1 [1, 0, 0] [0, 0, 0] [0, 0, 0]
 P0自增1 P1不知道 P2不知AL
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
  E2 P1 RECV←P0 [1, 0, 0] [1, 1, 0] [0, 0, 0]
 不变 合并[1,0,0] 不变
 +自增→[1,1,0]│
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
  E3 P1 LOCAL [1, 0, 0] [1, 2, 0] [0, 0, 0]
 不变 P1自增→2 不变
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
  E4 P2 RECV←P1 [1, 0, 0] [1, 2, 0] [1, 2, 1]
 不变 不变 合并[1,2,0]
 +自增→[1,2,1]│
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
  E5 P2 LOCAL [1, 0, 0] [1, 2, 0] [1, 2, 2]
 不变 不变 P2自增→2
├──────┼───────────────┼──────────────┼──────────────┼──────────────┤
  E6 P0 RECV←P2 [2, 2, 2] [1, 2, 0] [1, 2, 2]
 合并[1,2,2] 不变 不变
 +自增→[2,2,2]│
└──────┴───────────────┴──────────────┴──────────────┴──────────────┘

关键观察

  • E1 后 P0 知道"我发了一个消息",P1/P2 对此一无所知
  • E2 后 P1 通过消息获知了 P0 的事件(合并使得 P1.VC[0]=1),同时记录自己的接收事件
  • E4 后 P2 通过消息息获知了 P0 和 P1 的所有事件——**一次消息传递携带了全部因果历史
  • E6 后 P0 终于知道了 P1 和 P2 之间发生的事情——因果因果闭环

6. 对比分析:三种时钟方案

6.1 Lamport 时钟 vs 向量时钟

┌──────────────┬──────────────────────┬──────────────────────────────┐
 特性 Lamport 逻辑时钟 向量时钟
├──────────────┼──────────────────────┼──────────────────────────────┤
 数据结构 单个整数 C 整数数组 VC[0..N-1]
├──────────────┼──────────────────────┼──────────────────────────────┤
 空间复杂度 O(1)                  O(N)                          
├──────────────┼──────────────────────┼──────────────────────────────┤
 消息开销 1 个整数 N 个整数
├──────────────┼──────────────────────┼──────────────────────────────┤
 本地事件 C++ VC[self]++
├───────────────┼──────────────────────┼──────────────────────────────┤
 接收事件 C=max(C,C_msg)+1 VC[k]=max(VC[k],msg[k])      
 VC[self]++
├──────────────┼──────────────────────┼──────────────────────────────┤
 并发检测 不能 (偏序比较)                  │
├──────────────┼──────────────────────┼──────────────────────────────┤
 因果精确度 必要条件 (→)          │ 充要条件 ()                  │
 a→b C(a)<C(b)        a→b VC(a)<VC(b)            
├──────────────┼──────────────────────┼──────────────────────────────┤
 典型应用 事件排序、调试 冲突检测、版本控制
 分布式互斥 Dynamo、Riak、Voldemort
└──────────────┴──────────────────────┴──────────────────────────────┘

6.2 向量时钟 vs 版本向量 (Version Vector)

┌──────────────┬──────────────────────┬──────────────────────────────┐
 特性 向量时钟 版本向量
├──────────────┼──────────────────────┼──────────────────────────────┤
 递增时机 每次事件都递增 仅在写入 (更新更新数据) 时递增    │
├──────────────┼──────────────────────┼──────────────────────────────┤
 目的 追踪事件因果关系 追踪数据版本冲突
├──────────────┼──────────────────────┼──────────────────────────────┤
 粒度 每个事件 每次写操作
├──────────────┼──────────────────────┼──────────────────────────────┤
 关系 向量时钟是版本向量的超集 (计数所有事件 vs 仅写事件)   │
└──────────────┴──────────────────────┴──────────────────────────────┘

向量时钟和版本向量使用相同的数学结构,但用途不同——前者关注事件并发,后者关注数据冲突。在 Dynamo 等系统中,两者几乎可以互换使用。


7. 常见错误

┌──────────────────────┬──────────────────────┬──────────────────────────────┐
 错误 后果 正确做法
├───────────────────────┼──────────────────────┼──────────────────────────────┤
 忘记 happens_before 所有分量相等时也 必必须用 lt 标志跟踪
 中的 lt 标志 返回 1 (自己→自己)    │ 至少一个严格小于              │
├──────────────────────┼──────────────────────┼──────────────────────────────┤
 recv 时先递增 合并后递增丢失了 先合并 (逐元素 max)           │
 再合并 接收事件的正确位置 再递增自己分量
├──────────────────────┼──────────────────────┼──────────────────────────────┤
 消息携带错误的 接收方合并错误的 RECV 时取发送方"当前"时钟
 时钟快照 因果历史 而非之前 SEND 保存的旧值
├──────────────────────┼──────────────────────┼──────────────────────────────┤
 混淆 send local send 忘记递增自己 send: VC[self]++
 的时钟 再复制整个向量到消息
├──────────────────────┼──────────────────────┼──────────────────────────────┤
 逐元素 max 写成求和 计数膨胀胀,破坏 if (msg[i] > VC[i])        │
 偏序关系 VC[i] = msg[i]
├────────────────────────┼──────────────────────┼──────────────────────────────┤
 print_clocks 格式 diff 测试失败 严格匹配格式:
 不匹配 "  P%d: [%d, %d, %d]\n"
├──────────────────────┼──────────────────────┼──────────────────────────────┤
 快照保存时机错误 HB 测试用错数据 print_clocks 之后
 立即保即保存所有节点时钟
└──────────────────────┴──────────────────────┴──────────────────────────────┘

WARNING

快照保存的时机非常关键。必须在 print_clocks 之后立即保存——而不是在事件执行之前。HB 测试中 snapshots[1][0] 表示"E1 执行后 P0 的时钟"。如果保存时机不对,HB 测试结果会全部错乱。


参考解答

练习1-4: 核心函数实现
solution_56_init_local_send_recv.c
c
#include <stdio.h>

#define N_NODES 3

typedef struct {
    int id;
    int clock[N_NODES];
} Node;

static Node nodes[N_NODES];

/* 初始化所有节点的向量时钟为 0 */
static void init_clocks(void) {
    for (int i = 0; i < N_NODES; i++) {
        nodes[i].id = i;
        for (int j = 0; j < N_NODES; j++)
            nodes[i].clock[j] = 0;
    }
}

/* 本地事件:只递增自己的分量 */
static void local_event(int node_id) {
    nodes[node_id].clock[node_id]++;
}

/* 发送事件:递增自己,然后复制整个向量作为消息快照 */
static void send_event(int from, int to, int *msg_clock) {
    (void)to;  /* 本题中 to 仅用于文档,算法中只用到 from */
    nodes[from].clock[from]++;
    for (int i = 0; i < N_NODES; i++)
        msg_clock[i] = nodes[from].clock[i];
}

/* 接收事件:逐元素取 max 合并,再递增自己 */
static void recv_event(int to, const int *msg_clock) {
    /* 先合并 */
    for (int i = 0; i < N_NODES; i++)
        if (msg_clock[i] > nodes[to].clock[i])
            nodes[to].clock[i] = msg_clock[i];
    /* 再递增自己——接收本身是一个事件 */
    nodes[to].clock[to]++;
}

要点:

  • init_clocks 使用双重循环:外层遍历节点,内层遍历分量
  • send_event 先递增再复制——消息携带的是一份"我刚完成了发送"的快照
  • recv_event 先合并再递增——保证接收事件的正确位置在因果链中
  • local_event 最简单——只需一行
练习5: print_clocks 格式化输出
solution_56_print_clocks.c
c
static void print_clocks(int event_num, const char *desc) {
    printf("E%d: %s\n", event_num, desc);
    for (int i = 0; i < N_NODES; i++)
        printf("  P%d: [%d, %d, %d]\n",
               i, nodes[i].clock[0], nodes[i].clock[1], nodes[i].clock[2]);
}

格式必须与预期输出完全一致:

  • 事件头:E<N>: <描述>\n
  • 每个节点: P<id>: [c0, c1, c2]\n(注意前面有两个空格)
练习6: happens_before 判定
solution_56_happens_before.c
c
static int happens_before(const int *a, const int *b) {
    int lt = 0;  /* 记录是否发现严格小于的分量 */

    for (int k = 0; k < N_NODES; k++) {
        if (a[k] > b[k])
            return 0;   /* 发现逆序 → 绝不可能是 a→b */
        if (a[k] < b[k])
            lt = 1;     /* 至少一个分量为严格小于 */
    }

    return lt;  /* 全部 ≤ 且至少一个 < → a→b */
}

核心逻辑:

  1. a[k] > b[k] → 立即返回 0(违反全 ≤ 条件)
  2. a[k] < b[k] → 记录 lt=1(后面可能遇到逆序,此时返回 0)
  3. 循环结束后返回 lt——所有分量都满足 ≤ 且至少一个 <
练习7: main 事件循环
solution_56_main_loop.c
c
int main(void) {
    int snapshots[N_EVENTS + 1][N_NODES][N_NODES];

    Event events[N_EVENTS] = {
        {0, 0, 1, "P0 SEND to P1"},   {1, 0, 1, "P1 RECV from P0"},
        {2, -1, 1, "P1 LOCAL"},       {1, 1, 2, "P2 RECV from P1"},
        {2, -1, 2, "P2 LOCAL"},       {1, 2, 0, "P0 RECV from P2"},
    };

    init_clocks();
    printf("=== Vector Clocks: 3 Nodes (P0, P1, P2) ===\n\n");

    /* 保存初始快照 */
    for (int i = 0; i < N_NODES; i++)
        for (int j = 0; j < N_NODES; j++)
            snapshots[0][i][j] = nodes[i].clock[j];

    /* 事件循环 */
    for (int e = 0; e < N_EVENTS; e++) {
        switch (events[e].type) {
        case 0: { /* SEND */
            int msg_clock[N_NODES];
            send_event(events[e].from, events[e].to, msg_clock);
            break;
        }
        case 1: { /* RECV */
            int msg_clock[N_NODES];
            /* 取发送方当前的时钟快照(不是之前 SEND 存的旧值) */
            for (int i = 0; i < N_NODES; i++)
                msg_clock[i] = nodes[events[e].from].clock[i];
            recv_event(events[e].to, msg_clock);
            break;
        }
        case 2: { /* LOCAL */
            local_event(events[e].to);
            break;
        }
        }

        print_clocks(e + 1, events[e].desc);

        /* 立即保存快照 */
        for (int i = 0; i < N_NODES; i++)
            for (int j = 0; j < N_NODES; j++)
                snapshots[e + 1][i][j] = nodes[i].clock[j];
    }

    /* Happens-Before 测试... */
    return 0;
}

关键设计点:

  • RECV 时的 msg_clock 来自发送方当前的时钟——不是之前某个 SEND 的旧值。因为发送方可能在 SEND 之后又执行了其他事件
  • 事件循环中用 switch 分发三种事件类型
  • msg_clockcase 内部声明为局部变量
  • 快照保存紧随 print_clocks 之后

课堂讨论

  1. 如果 P0 在 E1 之后又发送了另一个消息给 P1,向量时钟会怎样变化?
  2. 向量时钟能检测到"同时"发生的事件吗?能区分"并发"和"同时"吗?
  3. 为什么 E4 (P2 RECV from P1) 后 P2.VC[1]=2 而不是 1?P2 之前没有 后 P1 通信过啊?
  4. 如果节点数量增长到 1000,向量时钟有什么问题?有什么解决方案?
  5. 在实际系统中,节点故障和重启如何影响向量时钟?

讨论答案

Q1: 如果 P0 又发送了一个消息给 P1

P0.VC[0] 会递增到 2。当 P1 收到第二条消息时,合并会使 P1.VC[0] 从 1 更新到 2。这展示了向量时钟如何累积知识——即使中间没有直接通信,后续的消息也能"补上"遗漏的信息。这是向量时钟比 Lamport 时钟更强大的另一个方面:Lamport 时钟的计数器只能递增,收到第二条消息后不能从 max 中恢复"之前漏掉的"信息;而向量时钟可以——因为消息携带了完整的因果历史快照。

具体推演:

初始:  P0=[1,0,0], P1=[1,1,0]
P0 再次 SEND→P1:
  P0=[2,0,0]  (P0自增)  消息携带 [2,0,0]
P1 RECV←P0:
  合并前: P1=[1,1,0]
        msg=[2,0,0]
  合并: P1=[max(1,2), max(1,0), max(0,0)] = [2,1,0]
        P1.VC[0] 1 跳到 2——P1 获知了 P0 的第二个事件!
Q2: 向量时钟能区分"并发"和"同时"吗?

向量时钟不能区分物理上的"同时"和逻辑上的"并发"。两个在不同节点上同时发生的本地事件,它们的向量时钟会显示为并发关系(互相不 Happens-Before)。但从分布式系统的角度看,这种区分没有意义——如果两个事件不能因果影响对方,它们就是并发的,无论物理时间如何。

例: P0 12:00:00.000 执行本地事件 VC = [1, 0]
     P1 12:00:00.000 执行本地事件 VC = [0, 1]

比较: VC_P0[0]=1 > VC_P1[0]=0 (不满足全≤)
       VC_P0[1]=0 < VC_P1[1]=1 (不满足全≤)
 并发

从系统角度看,这两个事件确实不能因果影响对方——
一个在 P0 的因果历史里,一个在 P1 的因果关系。
它们"物理同时"但逻辑并发——对系统行为来说这就够了。
Q3: 为什么 P2.VC[1]=2 而不是 1?

这正是向量时钟的强大之处!P1 发给 P2 的消息携带了 P1 的完整向向量时钟 [1, 2, 0],其中包括 P1 知道的关于 P0 的信息(P1.VC[0]=1)和关于自己的信息(P1.VC[1]=2,因为 P1 经历了 E2 和 E3 两个事件)。P2 通过一次消息接收就获知了 P0 的 E1 和 P1 的 E2、E3——因果历史是传递的

E4: P2 RECV from P1
  消息携带: msg_clock = P1.clock = [1, 2, 0]
  P2 合并前: [0, 0, 0]
  P2 合并:   max(0,1)=1, max(0,2)=2, max(0,0)=0 [1, 2, 0]
  P2 自增:   [1, 2, 1]

P2.VC[0]=1 —— P2 知道了 P0 E1(从未和 P0 通信过!)
P2.VC[1]=2 —— P2 知道了 P1 E2 E3(通过这一次消息传递!)

这就是"知识传递"的本质——就像你收到一封信,信里不仅写了发信人的事,还写了发信人听说的其他人的事。

Q4: 节点数 1000 的扩展性问题

每个时钟占用 N 个整数(N=1000 时为 4KB),每条消息携带 4KB 的时钟开销。解决方案包括:

  1. 稀疏向量时钟:只包含有交互的节点。例如在 1000 节点系统中,如果某个节点只与 5 个节点交互,只需维护 5 个分量
  2. 服务器端版本向量:在 Dynamo-style 系统中,只有少数副本节点才需要维护向量(通常 3-5 个副本)
  3. Dotted Version Vectors:每个客户端只维护一个"点状版本"(dot),服务器端合并成完整版本向量
  4. 间隔树时钟 (Interval Tree Clocks):动态分配和回收 ID,避免无限增长
  5. Hybrid Logical Clocks (HLC):结合物理时钟和逻辑时钟,O(1) 空间

选择哪种方案取决于系统特性——交互模式(稀疏/密集)、节点角色(客户端/副本/协调者)、一致性要求。

Q5: 节点故障和重启的影响

节点重启后时钟归零会导致因果历史丢失——新事件可能被误判为并发或"更早"。三种解决方案:

  1. 持久化:将向量时钟写入磁盘或日志,重启后恢复。最简单但引入 I/O 延迟
  2. 向其他节点查询:重启后向至少一个其他节点请求当前状态,用响应中的时钟作为起点。代价是启动延迟
  3. 全局唯一 ID + epoch:每个节点重启时递增 epoch 编号,虽然时钟计数归零,但 epoch 保证新旧事件的因果顺序不会被混淆
Practical 方案: 持久化 + epoch 组合
  正常运行时: 时钟在内存中,定期持久化到磁盘
  重启时: 
    1. 如果持久化文件存在且有效 恢复
    2. 否则 epoch++,时钟归零,向其他节点查询
      查询到的时钟也携带旧 epoch 合并时可以区分新旧事件

课后练习

  1. 实现 Lamport 时钟对比。在相同的事件序列上运行 Lamport 逻辑时钟,对比输出与向量时钟的差异。找出 Lamport 时钟无法判断因果关系而向量时钟能够判断的具体场景。

    知识点提示:实现三个函数 lamport_locallamport_sendlamport_recv,规则为 C++C = max(C, C_msg) + 1。重点观察哪些事件对的 Lamport 时间戳比较与向量时钟的结果不一致。

    参考解答
    ex1_lamport_vs_vector.c
    c
    #include <stdio.h>
    
    /* Lamport 逻辑时钟 */
    static int lamport_clocks[3] = {0, 0, 0};
    
    static void lamport_local(int node) {
        lamport_clocks[node]++;
    }
    
    static int lamport_send(int from) {
        lamport_clocks[from]++;
        return lamport_clocks[from];  /* 消息携带发送方的时钟 */
    }
    
    static void lamport_recv(int to, int msg_clock) {
        lamport_clocks[to] = (lamport_clocks[to] > msg_clock
                              ? lamport_clocks[to] : msg_clock) + 1;
    }
    
    int main(void) {
        int lc1, lc2, lc3;
        int msg;
    
        /* E1: P0 SEND */
        lamport_local(0);  /* 等价于 send 中的递增 */
        lc1 = lamport_clocks[0]; /* C0 = 1 */
    
        /* E2: P1 RECV (msg carries C0=1) */
        lamport_recv(1, 1);
        lc2 = lamport_clocks[1]; /* C1 = max(0,1)+1 = 2 */
    
        /* E3: P1 LOCAL */
        lamport_local(1);
        lc3 = lamport_clocks[1]; /* C1 = 3 */
    
        printf("Lamport: E1=%d, E2=%d, E3=%d\n", lc1, lc2, lc3);
        printf("Vector:  [1,0,0] → [1,1,0] → [1,2,0]\n\n");
    
        /* Lamport 只能知道 C1(E1)=1 < C1(E3)=3,但不知道
         * 这是因果关系还是巧合(两个并发事件恰好顺序递增)。
         * 向量时钟精确判断 E1→E3 因为有全≤且至少一个<。 */
        return 0;
    }
  2. 实现并发检测函数。编写 int is_concurrent(const int *a, const int *b) 函数,判断两个向量时钟是否表示并发关系(a ∥ b)。

    知识点提示:a ∥ b 当且仅当既非 a→b 也非 b→a。利用已有的 happens_before 函数即可简洁实现:return !happens_before(a, b) && !happens_before(b, a)

    参考解答
    ex2_is_concurrent.c
    c
    #include <stdio.h>
    
    static int happens_before(const int *a, const int *b) {
        int lt = 0;
        for (int k = 0; k < N_NODES; k++) {
            if (a[k] > b[k]) return 0;
            if (a[k] < b[k]) lt = 1;
        }
        return lt;
    }
    
    /* 并发: 既不是 a→b 也不是 b→a */
    static int is_concurrent(const int *a, const int *b) {
        return !happens_before(a, b) && !happens_before(b, a);
    }
    
    int main(void) {
        int a[] = {1, 0, 0};  /* P0 本地事件 */
        int b[] = {0, 1, 0};  /* P1 本地事件 */
        int c[] = {1, 1, 0};  /* a 之后 P1 接收到 P0 的消息 */
    
        printf("a∥b: %s (expected YES)\n",  is_concurrent(a, b) ? "YES" : "NO");
        printf("a∥c: %s (expected NO)\n",   is_concurrent(a, c) ? "YES" : "NO");
        printf("a∥a: %s (expected NO)\n",   is_concurrent(a, a) ? "YES" : "NO");
        return 0;
    }

    注意:同一事件与自己不是并发关系(a→a 不成立,但 a∥a 也不成立——它们就是同一个事件)。

  3. 动态消息快照验证。修改程序,在 RECV 时故意使用 SEND 时保存的旧消息快照(而不是发送方当前时钟),观察输出差异。解释为什么后果是错误的。

    知识点提示:在 SEND 事件中将 msg_clock 保存到全局数组,然后在对应的 RECV 中使用该旧值。由于本事件序列的特殊性(每个 SEND 后接收方立即 RECV,发送方没有中间事件),旧快照碰巧能给出正确结果。添加额外事件打破这种"巧合"来演示错误。

    参考解答
    ex3_stale_message_bug.c
    c
    #include <stdio.h>
    
    /* 如果在 E1 SEND 时保存 msg_clock,然后在 E2 RECV 时使用该旧值:
     *
     * E1: SEND: P0.clock = [1,0,0], save msg = [1,0,0]
     * E2: RECV: P1 用 msg=[1,0,0] 合并 → P1.clock = [1,1,0] ✓ 巧合正确
     *
     * 但如果 P0 在 SEND 后又执行了事件:
     * E1: P0 SEND    → P0 = [1,0,0], save msg = [1,0,0]
     * E1.5: P0 LOCAL → P0 = [2,0,0]  (P0 的因果历史前进了!)
     * E2: P1 RECV 用旧 msg=[1,0,0] → P1 = [1,1,0]
     *     但正确值应该是: P1 取 P0 当前时钟 [2,0,0] → P1 = [2,1,0]
     *
     * P1 丢失了 P0 在 E1.5 中产生的事件信息!
     * 这就是使用"发送方当前时钟"而非"SEND 时保存的快照"的原因。
     * 消息一旦到达,接收方看到的应该是发送方此刻的因果历史状态。
     */
  4. 稀疏向量时钟实现。修改 Node 结构,使用动态数组或链表只存储非零分量。在三个节点上测试,并分析稀疏表示的优缺点。

    知识点提示:对于 3 节点系统,稀疏表示的开销(指针、动态分配)可能大于稠密表示。但对于大规模系统(如 N=1000 但每个节点只与 5 个节点交互),稀疏表示显著减少空间和通信开销。权衡:稠密 O(N)、访问 O(1);稀疏 O(degree)、访问需查找。

    参考解答
    ex4_sparse_vector.c
    c
    #include <stdio.h>
    #include <stdlib.h>
    
    typedef struct {
        int node_id;
        int count;
    } VCEntry;
    
    typedef struct {
        VCEntry *entries;
        int size;
        int capacity;
    } SparseVC;
    
    /* 添加或更新一个分量 */
    static void sparse_set(SparseVC *vc, int node_id, int count) {
        for (int i = 0; i < vc->size; i++) {
            if (vc->entries[i].node_id == node_id) {
                vc->entries[i].count = count;
                return;
            }
        }
        /* 未找到,添加新条目 */
        if (vc->size >= vc->capacity) {
            vc->capacity = vc->capacity ? vc->capacity * 2 : 4;
            vc->entries = realloc(vc->entries, vc->capacity * sizeof(VCEntry));
        }
        vc->entries[vc->size].node_id = node_id;
        vc->entries[vc->size].count = count;
        vc->size++;
    }
    
    /* 获取分量值(未存储的默认为 0) */
    static int sparse_get(const SparseVC *vc, int node_id) {
        for (int i = 0; i < vc->size; i++)
            if (vc->entries[i].node_id == node_id)
                return vc->entries[i].count;
        return 0;
    }
    
    /* 稀疏合并 */
    static void sparse_merge(SparseVC *vc, const SparseVC *msg) {
        for (int i = 0; i < msg->size; i++) {
            int cur = sparse_get(vc, msg->entries[i].node_id);
            if (msg->entries[i].count > cur)
                sparse_set(vc, msg->entries[i].node_id, msg->entries[i].count);
        }
    }
    
    /* 优缺点分析:
     * 优点:
     *   - 大规模系统中空间节省显著 (O(degree) vs O(N))
     *   - 消息只携带交互过的节点信息
     * 缺点:
     *   - 访问从 O(1) 变为此处 O(size)(可用哈希优化为 O(1))
     *   - 动态内存管理增加复杂度
     *   - 3 节点系统中不如稠密表示简洁
     */

参考资料

  • Lamport, L. (1978). "Time, Clocks, and the Ordering of Events in a Distributed System." Communications of the ACM, 21(7), 558-565.
  • Fidge, C. J. (1988). "Timestamps in Message-Passing Systems That Preserve the Partial Ordering." Proceedings of the 11th Australian Computer Science Conference, 56-66.
  • Mattern, F. (1989). "Virtual Time and Global States of Distributed Systems." Parallel and Distributed Algorithms, 215-226.
  • DeCandia, G., et al. (2007). "Dynamo: Amazon's Highly Available Key-value Store." SOSP '07. — 向量时钟在工业界的经典应用
  • Wikipedia: "Vector clock", "Happens-before", "Lamport timestamps"

"The concept of time is fundamental to our way of thinking. It is derived from the more basic concept of the order in which events occur." — Leslie Lamport, 1978

Released under the MIT License.