分布式算法(一) 基础遍历

253 阅读21分钟

写在最前面

本系列文章基于《distributed algorithms for message-passing systems》。这本书非常不错,介绍了分布式系统的基础算法,是那些高大上的分布式应用框架的基石。

更新进度

  • 第一章节 基础(更新中)
    • 第一章 遍历(本篇)
    • 第二章 分布式图算法
    • 第三章 以轮次为基础的分布式计算框架
    • 第四章 选举算法
    • 第五章 可移动独占资源
  • 第二章节 分布式系统的状态和时间
    • 第六章 全局状态
    • 第七章 逻辑时间
    • 第八章 检查点
    • 第九章 同步器
  • 第三章节 分布式资源分配
    • 第十章 互斥
    • 第十一章 资源分配
  • 第四章节 高级通信抽象
    • 第十二章 有序收发
    • 第十三章 约定通信
  • 第五章节 分布式探测
    • 第十四章 终止探测
    • 第十五章 死锁探测
  • 第六章节 分布式共享内存
    • 第十六章 原子一致性
    • 第十七章 序列一致性

1.1 基础定义与网络遍历算法

1.1.1 定义

  • 进程:分布式系统最小工作单元
  • 信道:进程通信基础,在可靠性、方向性,容纳量方面有假设
  • 端口:信道的本地名
  • 结构视图:环状、树状或者强联通图
  • 同步算法:整个分布式系统运行在全局时钟的时间片内,每个时间片内进程至多向邻居发出一条信息,且发送和接收信息都在同一时间片内完成
  • 异步算法:没有全局时钟,进程可以随时收发信息,信息的发送和抵达可能乱序。
  • 初始知识:算法运行前,系统的输入信息
  • 系统直径/宽度:所有进程组之间最短距离的最大值,用D表示
  • 本地算法:进程只知道自己和邻居的身份标识,不感知全局拓扑,而且邻居间的信息交换是有限的。

1.1.2 假设

本章所有算法都假设双向信道和全连通图。要求每个进程知晓自己和邻居的ID。

1.1.3 先导算法:全节点遍历

本地变量

  • neighbor集合定值,保存本进程的所有邻居
  • proc_known集合:存储所有已知进程的ID
  • channels_known集合:存储所有已知的直连信道。本集合元素为形如<i, j>的进程ID对,表明进程i和j之间有直连的双向信道。
  • part布尔变量:标记本进程是否已经开始遍历算法

算法描述

  • 本地调用
    • START(): 进程向自己的所有邻居广播POSITION(id,neighbors)信息。将part置为true,标记自己开始遍历算法
  • 信息处理
    • 收到START()信息: 如果自己没有开始遍历,则调用START()
    • 从进程j处接收到POSITION(id_j,neighbors_j)信息
      • 如果id_j已知则丢弃信息,否则将id_j加入proc_known集合。遍历neighbors_j集合,将进程j的所有直连信道加入channels_known集合,并向除进程j以外的其他邻居广播POSITION(id,neighbors)信息。
      • 检查channels_known集合里信道涉及到的所有进程是否都已知,若是,则判定已经完成全节点遍历(如果尚未完成全节点遍历,则必然有节点出现channels_known集合中,但没有出现在proc_known集合中

复杂度分析
e为信道总数,D为系统直径。每条信息最多被广播两次,信息量复杂度为O(2eD),时间复杂度为O(2D)

算法伪代码
image.png

1.2 并行遍历

1.2.1 广播与聚播

  • 广播:某特定进程将信息播送到其他进程。
  • 聚播:其他进程将信息播送到某特定进程。

1.2.2 朴素泛洪算法

算法分析
进程收到信息后就向所有其他邻居广播。效率不高。

复杂度分析
e为信道总数,D为系统直径。信息量复杂度O(2e),时间复杂度为O(D)。对于全连通图,信息量复杂度高达O(2N^2)

算法伪代码
image.png

1.2.3 基于生成树的广播/聚播算法

算法分析
信息沿着树的链路或自顶向下、或由下向上单向流动,效率比1.2.2的朴素泛洪算法高很多。但前提是需要依赖已有的生成树拓扑,以及只有根节点才能发起广播。在分布式系统上建立生成树的算法将在后续章节详述。

复杂度分析
N为信道总数,D为系统直径。信息量复杂度O(N),时间复杂度为O(D)。可以看出,相比朴素泛洪算法,对于全连通图,信息量复杂度也能降到O(N)

算法伪代码
image.png

1.2.4 朴素生成树算法

算法原理
此算法通过发送信息,在分布式系统上建立起固定的树形拓扑。1.2.3的广播/聚播算法可以在此树形拓扑上正确运行。

本地变量

  • parent变量存储父节点ID,初始为空。此值一旦赋值就不可更改。
  • children集合存储子节点ID,初始为∅。
  • expect_msg整数变量:本节点向邻居发送信息后,需要等待每个邻居的回复信息。此集合存储尚未回复信息的邻居总数

算法描述

  • 本地调用
    • START(): 根进程pa触发算法,向自己的所有邻居发送GO(data)信息
  • 信息处理
    • 从进程j处接收到GO(data_j)信息
      • 如果本进程首次接收到GO消息,设置parent变量(父进程)为进程j、设置expect_msg整数变量为其他邻居总数。
        • 如果进程没有其他邻居,则向父节点j发送BACK(data_i)信息(表示自己已经完成了生成树建立
        • 否则,向其他邻居发送GO(data_j)信息。
      • 如果本进程非首次接收到GO消息,则向进程j回复BACK(∅)信息(表明进程j没有被选为父节点
    • 从进程j处接收到BACK(data)信息:
      • 收到新回复,将expect_msg减1
      • 如果data不是∅,则表明这是子节点的回复,需要将进程k加入children子节点集合
      • 检查expect_msg=0(是否从自己的所有邻居处收齐BACK信息),如果是,则向父进程发送BACK(data_j ∪ data_i)信息并退出算法

复杂度分析
e为信道总数,N为节点总数,则建立生成树的信息复杂度为2(2e-n+1),也就是O(n^2)

算法性质

  • 容易看出,生成树形状取决于GO信息的发送速度,因此多次运行算法可能导致效率迥然不同的生成树,极端情况下,甚至可能生成单链树。本算法可以通过加入最大ID竞争来创建确定性生成树:
    • 进程发送的GO信息应携带本进程收到的最大进程ID
    • 进程应该丢弃所有携带ID更小的GO信息
    • 进程发现携带更大ID的GO信息时,应该更新本地存储ID并重新设置父节点、重新向邻居发送带有最新、最大ID的GO信息
  • 每个需要广播的节点都必须独立运行算法,生成以自己为根的树。这会导致本地计数器拓展为数组,以及每条信息都需要携带根节点的身份标识
  • 非FIFO信道不影响算法正确性

算法伪代码
image.png

1.3 广度优先生成树

1.3.1 去中心化的广度优先生成树算法

算法原理
假设根节点进程为pa,GO信息现在携带发送进程与pa的距离。每个节点通过感知GO信息附带的与根节点的距离,不断动态优化自己跟根节点的最短距离,最终收敛到以pa为根的广度优先生成树

本地变量

  • parent变量存储当前已知的、离根最近的父节点ID,初始为空。与1.2.4朴素算法的不同之处在于,此变量会不停地动态更新,直至收敛到最优值。
  • children集合:存储子节点ID,初始为∅。与1.2.4朴素算法一致
  • expect_msg整数变量:本节点向邻居发送信息后,需要等待每个邻居的回复信息。此集合存储尚未回复信息的邻居总数。与1.2.4朴素算法一致
  • level整数变量存储当前本节点到根的最短距离

算法描述

  • 本地调用
    • START(): 根进程pa触发算法,向自己的所有邻居发送GO(0)信息
  • 信息处理
    • 从进程j处接收到GO(d)信息
      • 如果进程首次接收到GO(d)消息,或者d+1<本地level变量(说明进程j距离根更近,选择进程j作为父节点更优),则设置parent变量为进程j,将level变量设置为d+1,设置expect_msg变量为邻居总数。如果进程没有其他邻居,则向父节点发送BACK(yes, d+1)信息(表明自己已经完成了生成树建立),否则,向其他邻居群发GO(d+1)信息
      • 如果进程非首次接收到GO(data)消息,并且d+1>=本地level(说明进程j不是更优选择),则直接向进程j回复BACK(no, d+1)信息(表明进程j没有被选为父节点
    • 从进程j处接收到BACK(resp, d)信息,resp的值可以是yes或者no:
      • 如果d!=level+1,说明此信息并非本轮GO信息群发的回复(可能是上一次群发的慢回复),直接丢弃。
      • 如果d=level+1,判定收到了本轮群发的新回复,将expect_msg减1
      • 如果resp=yes,表明这是子节点的回复,将进程j设置为子节点。
      • 检查expect_msg=0(是否从自己的所有邻居处收齐BACK信息),则向自己的父节点发送BACK(yes, level)信息;
      • 如果自身为根节点,则算法结束,根节点后续可以广播特定消息,向其他节点宣告算法结束

复杂度分析
设N为节点总数,最差情况为全连通图,并且根距离为d的每个节点都更新了d次才收敛到最优值。由于全联通图的每一次更新都需要O(n^2)条信息,此时建立生成树的信息复杂度为O(N^3),时间复杂度为O(N)

算法伪代码
image.png

1.3.2 中心化的广度优先生成树算法

算法原理
根节点进程pa会发起一轮又一轮的GO信息探测,逐层进行广度优先搜索,建立自身为根的广度优先生成树。相比于1.3.1去中心化算法,中心化算法的优点是信息复杂度降低,非根节点只更新一次就能获得最优距离;缺点是每一轮探测只能向外多访问一层节点,时间复杂度提高

本地变量

  • parent变量:存储父节点ID,初始为空。此值一旦赋值就不可更改。
  • children集合:存储子节点ID,初始为∅。与1.3.1中心化算法一致
  • waiting_from集合:每一轮探测本节点向邻居发送信息后,都需要等待每个邻居的回复信息,收齐回复之后才能开始下一轮探测。此集合存储本轮探测中尚未回复信息的邻居ID
  • to_send集合:存储后续轮次需要发送GO信息继续探测的邻居进程集合。在一轮探测结束后,停留在这个集合中的邻居进程会在下一轮被发送GO信息。
  • distance整数变量:存储本节点到根的最短距离。与1.3.1中心化算法的不同之处在于,此变量只写入一次,之后不可变

算法描述

  • 本地调用
    • START(): 根进程pa触发算法,pa设置自己为根节点,把自己的所有邻居加入to_send集合和waiting_from集合,并发送GO(0)信息开始第一轮波浪探测
  • 信息处理
    • 从进程j处接收到GO(d)信息
      • 如果进程首次接收到GO(d)消息,则设置parent父节点变量为进程j,将distance置为d+1,把自己的所有其他邻居加入to_send集合。在下一轮由根进程pa发起的探测中,本进程也会开始向邻居发送GO信息。
        • 如果to_send集合为空,则向父节点回复BACK(stop)信息表明作为叶子节点,自身广度优先探测已结束;否则,回复BACK(continue)信息表明自己仍有下层未知节点,需要在下一波GO信息继续探索
      • 如果进程从父节点处再次收到GO(d)信息,则把waiting_from集合置为to_send集合,并且向to_send集合中的进程广播GO(distance)信息,开始对自己的邻居进行探索
      • 如果进程从非父节点接收到GO(d)消息,则向进程j回复BACK(no)信息(表明进程j没有被选为父节点
    • 从进程j处接收到BACK(resp, d)消息
      • 将进程j移出waiting_from(无论BACK信息的内容为何,只要收到回复就应该移出集合
      • 如果resp是continue或者stop,则将进程j加入children子节点集合(分别对应子节点未完成探测和已完成探测两种情况
      • 如果resp是stop或者no,则将进程j移出to_send集合(stop意味着子节点j已经完成了自身的广度优先探测,no表示进程j不是自己的子节点;无论何种情况,下一轮探测都不应该向进程j发送GO信息
      • 当to_send为∅时,向父节点发送BACK(stop)信息,表明自身广度优先探测已结束;当to_send不为∅,但waiting_from为空时,则需要分情况讨论
        • 非根进程:向父节点发送BACK(continue)信息(本轮探测结束,但自己仍有下层未知节点需要在下一轮探测中继续
        • 根进程pa:如果to_send集合也为∅,则广度优先生成树算法完毕;否则,本轮探索完毕但仍有子节点尚未探明,需要继续探索。pa向to_send集合的进程广播GO(0)信息,并把waiting_from集合置为to_send集合,开始下一轮探测

复杂度分析
N为节点总数,最差情况为所有节点以单线链接,此时建立广度优先生成树的信息复杂度为O(n^2),时间复杂度为O(n^2)

算法伪代码
image.png image.png

1.4 深度优先生成树

1.4.1 朴素算法

算法原理
进程会随机选择自己的邻居进行深度优先的探测。比起广度优先生成树算法,本算法的信息复杂度更低,但深度优先算法本质上不可并行,因此同一时刻只能有一个进程进行探测,时间复杂度更高,而且在全联通图场景下,会生成效率最差的单链树。

本地变量

  • parent变量:存储父节点ID,与广度优先算法一致
  • children集合:存储子节点ID,与广度优先算法一致
  • visited集合:存储本节点已经访问过的邻居节点。

算法描述

  • 本地调用
    • START(): 根进程pa触发算法,pa设置自己为根节点,随机向一个未被访问的邻居发送GO()信息开始算法
  • 信息处理
    • 从进程j处接收到GO()信息
      • 如果进程首次接收到GO()消息,则设置parent父节点变量为进程j,并将进程j加入visited集合
        • 如果visited集合与邻居集合相等,则向父节点发送BACK(yes)信息表明作为叶子节点,自身深度优先探测已结束;否则,随机向一个未被访问的邻居发送GO()信息深度优先探测。
      • 如果进程非首次接收到GO()消息,则向进程j回复BACK(no)信息表明进程j没有被选为父节点
    • 从进程j处接收到BACK(resp)消息
      • 将进程j加入visited集合(无论是什么回复,进程j都已经被访问了,无需再访问
      • 如果resp是yes,则将进程j加入children子节点集合
      • 检查visited集合与邻居集合是否相等。如果相等,则需要分情况讨论
        • 非根进程:说明自身所有邻居都被访问了,本节点的深度优先生成树算法结束,向父节点发送BACK(yes)信息
        • 根进程pa:算法运行完毕;
      • 如果两集合不相等,说明仍有节点需要探测。进程随机向一个未被访问的邻居发送GO()信息开始下一次深度优先探测。

复杂度分析
N为节点总数,最差情况为全联通图,此时建立深度优先生成树的信息复杂度为O(n^2),时间复杂度为O(n^2),而且会生成效率最差的单链树。但是对于直径为O(lgN)的全联通图而言,广度优先搜索算法可以通过并行遍历将时间复杂度压缩到O(NlgN),但朴素深度优先算法因为无法并行,其时间复杂度依然是O(N^2)

算法伪代码
image.png

1.4.1.1 朴素算法优化一

算法原理
本进程在第一次向随机邻居发送GO()信息之前,会进入一个额外的本地-邻居并行信息交换阶段,让其他邻居得知自己已经被其他进程访问过,那么其他邻居进行深度优先搜索时,就无需再次访问本进程。实质是发送更多条数信息,换取时间复杂度降低

本地变量
与1.4.1朴素算法一致,没有变化

算法描述

  • 本地调用
    • START(): 没有变化
  • 信息处理
    • 从进程j处接收到GO()信息:
      • 如果进程接收到GO()消息,则设置parent父节点变量为进程j,并将进程j加入visited集合
        • 进入本地信息交换环节,进程向自己的所有邻居发送VISITED(),并且等待所有邻居返回KNOWN()
        • 【无变化】如果visited集合与邻居集合相等,则向父节点发送BACK(yes)信息表明作为叶子节点,自身深度优先探测已结束;否则,随机向一个未被访问的邻居发送GO()信息深度优先探测。
      • 由于存在本地信息交换环节,进程只会收到1次来自父节点的GO消息
    • 从进程j接收到VISITED()消息
      • 将进程j加入本进程visited集合,并返回KNOWN()信息
    • 从进程j处接收到BACK(resp, d)消息
      • 无变化

复杂度分析
建立生成树的信息复杂度仍为O(n^2),但常数系数因为本地信息交换阶段而增加。因为每个节点只会收到1次GO信息,时间复杂度降为O(n)

1.4.1.2 朴素算法优化二

算法原理
GO信息可以携带拥有全局信息的visited集合来避免本地信息交换,实质是发送更大容量的信息,换取信息复杂度和时间复杂度的双重降低

本地变量

  • visited集合:改为随GO信息一起传播的全局变量,移除进程的本地副本
  • 其他变量与1.4.1朴素算法一致,没有变化

算法描述

  • 本地调用
    • START(): 没有变化
  • 信息处理
    • 从进程j处接收到GO(visited)信息
      • 设置parent父节点变量为进程j,并将进程j加入visited集合。如果visited集合与邻居集合相等,则向父节点发送BACK(visited)信息表明作为叶子节点,自身深度优先探测已结束;否则,随机向一个未被访问的邻居进程k发送GO(visited)信息开始深度优先探测,并将进程k加入children子节点集合。
    • 从进程j处接收到BACK(visited)消息
      • 进程j必然已经被加入到visited集合,所以不用再加入。
      • 检查visited集合与邻居集合是否相等。如果相等,则需要分情况讨论
        • 非根进程:说明自身所有邻居都被访问了,本节点的深度优先生成树算法结束,向父节点发送BACK(visited)信息
        • 根进程pa:算法运行完毕;
      • 如果两集合不相等,说明仍有节点需要探测。进程随机向一个未被访问的邻居进程k发送GO(visited)信息开始下一次深度优先探测,并将进程k加入children子节点集合。

复杂度分析
每个节点至多收到一次GO(visited)信息,建立生成树的信息复杂度降为O(n),时间复杂度降为O(n)

算法伪代码
image.png

1.5 逻辑环

在环拓扑上广播信息是非常简明直观的:每个进程获取消息后,直接将信息转发给自己的逻辑环后继进程即可。本节将会介绍如何建立起环拓扑,向上层应用屏蔽分布式系统的实际结构。

注意:现实中分布式系统可能是任意视图结构,所以进程的逻辑环后继很可能不是自己的物理邻居,甚至与自己相隔很远。因此,我们需要在每个进程引入路由表的概念。各节点通过路由表让信息按照逻辑环正确流动。

1.5.1 逻辑环生成算法

算法原理
生成逻辑环算法与1.4.1.2深度优先生成树优化二非常相似,但生成环的方向与深度优先搜索相反。逻辑环的简单路径可能由更长的实际路由决定,但是算法保证整个环的长度固定为2(n-1)

image.png

本地变量

  • parent变量:存储父节点ID,初始为空。此值一旦赋值就不可更改。
  • succ变量:存储本地逻辑后继,注意逻辑后继不一定是自己的物理邻居
  • routing路由表数组:存储信息下一跳投递去向。routing[k]=j,意味着如果信息是从进程k处收到的,那么本进程应该将此信息转发给进程j

算法描述

  • 本地调用
    • START(): 特殊进程pa触发算法,pa设置自己为根节点,向自己随机邻居发送GO(visited, pa)信息
  • 信息处理
    • 从进程j处接收到GO(visited, last)信息
      • 设置parent父节点变量为进程j,设置succ变量为last(表明进程last是进程i的逻辑后继),将自身id放入visited集合。
        • 需要注意的是last不一定是进程j:假设进程j有2个子节点i和j(假设进程i和进程k都是叶子结点,没有其他邻居),那么i收到的GO信息的last变量是j,而k收到的GO信息的last变量则是i,这样就形成了k->i->j的逻辑环,而实际信息传递路径为k->j->i->j
      • 检查GO信息携带的visited集合是否包括了自己的所有邻居
        • 是,则向父节点j回复BACK(visited, i)信息,并设置routing路由表[j]值为j(说明自己的其他邻居都已经进入了逻辑环,无需继续探测,也不可能从其他邻居处收到信息;收到来自进程j的信息后,应该原路转发回去);
        • 否则向随机邻居k发送GO(visited, i)信息,并设置routing路由表[k]的值为j(进程k的逻辑后继必然是本进程,而本进程的逻辑后继是进程last,而last通过进程j可达,因此从进程k处收到的信息应该转发给进程j
    • 从进程j处接收到BACK(visited, last)消息
      • 检查GO信息携带的visited集合是否包括了自己的所有邻居
        • 如果是,则需要进一步检查自身是否为根节点:
          • 是,则将last设置为自己的后继,生成环算法完毕
          • 否则,向父节点发送BACK(visited, last)信息,并设置routing路由表[parent]值为j(parent或者parent上层的某个节点,其逻辑后继是进程last,而last通过进程j可达,因此从parent处收到的信息应该转发给进程j
      • 否则继续向随机未被访问的邻居发送GO(visited, last)信息,并设置routing路由表[k]的值为j

复杂度分析
N为节点总数,建立逻辑环的信息复杂度为O(N),时间复杂度为O(N)。在环上广播信息的时间复杂度为O(N)。

算法伪代码
image.png

1.5.2 逻辑环广播算法

算法描述
进程接收到TOKEN(dest)消息,如果dest是自身,则消费token,并将dest置为本节点的逻辑后继。随后查询路由表并将TOKEN信息投递出去

复杂度分析
N为节点总数,在环上广播信息的时间复杂度为O(N),信息复杂度为O(N)。

image.png