远程引用协议
本文档详细描述了远程引用协议的设计,并展示了不同场景下的消息流程。请在继续阅读前确保你已经熟悉了分布式RPC框架。
背景
RRef 代表远程 REFerence。它是指位于本地或远程工作者上的对象引用,并且在内部透明地处理引用计数。概念上,它可以被视为分布式共享指针。应用程序可以通过调用 remote()
创建 RRef。每个 RRef 由 remote()
调用的被调用者工作者(即所有者)拥有,并且可以被多个用户使用。所有者存储实际数据并跟踪全局引用计数。每个 RRef 可以通过一个全局 RRefId
唯一标识,该 ID 在创建 remote()
调用时分配。
在拥有者工作者中,只有一个包含实际数据的OwnerRRef
实例。而在用户工作者上,可以创建任意数量的UserRRefs
实例,并且这些UserRRef
不持有任何数据。所有在拥有者上的使用都会通过全局唯一的RRefId
来获取该唯一的OwnerRRef
实例。当作为rpc_sync()
、rpc_async()
或remote()
调用中的参数或返回值使用时,会创建一个UserRRef
实例,并且拥有者将根据更新引用计数的通知。当全局没有UserRRef
实例并且在拥有者上也没有对OwnerRRef
的任何引用时,该OwnerRRef
及其数据将会被删除。
假设
RRef协议的设计基于以下假设。
-
临时网络故障:RRef 设计通过重试消息来处理临时网络故障。它无法处理节点崩溃或永久性网络分区。当这些情况发生时,应用程序应关闭所有工作者,回滚到之前的检查点,并继续训练。
-
非幂等 UDF: 我们假设提供给
rpc_sync()
、rpc_async()
或者remote()
的用户定义函数(UDF)是非幂等的,因此不能重试。然而,内部 RRef 控制消息是幂等的,并且在消息失败时会自动重试。 -
消息乱序交付:由于发送方和接收方都使用了多个线程,我们不假设任何一对节点之间的消息交付顺序。因此,无法保证哪条消息会先被处理。
RRef 生命周期
该协议的目标是在适当的时间删除一个OwnerRRef
。删除OwnerRRef
的最佳时机是当没有存活的UserRRef
实例,并且用户代码也没有引用OwnerRRef
时。难点在于如何判断是否存在任何存活的UserRRef
实例。
设计理由
用户可以在三种不同情况下获取 UserRRef
:
-
接收来自所有者的
UserRRef
。 -
接收来自另一用户的
UserRRef
。 -
创建一个新的由另一个工作者拥有的
UserRRef
。
案例1是最简单的场景:拥有者将其 RRef 传递给用户,并通过调用rpc_sync()
、rpc_async()
或 remote()
,并将 RRef 作为参数传递。在这种情况下,用户端将创建一个新的UserRRef
对象。由于拥有者是调用方,它可以轻松地更新其本地的 OwnerRRef
引用计数。
唯一的要求是,在销毁时,任何 UserRRef
都必须通知其拥有者。因此,我们需要第一个保证:
G1. 当任何用户引用(UserRRef)被删除时,所有者将会收到通知。
由于消息可能会延迟或乱序到达,我们需要额外的保障来确保删除消息不会被过早处理。如果A向B发送一条涉及RRef的消息,我们会在A(父RRef)上调用该RRef,并在B(子RRef)上调用相应的RRef。
G2. 在子级 RRef 获得所有者确认之前,父级 RRef 不会被删除。
在情况 2 和 3 中,所有者可能只对 RRef 分叉图有部分了解或完全不了解。例如,在用户上可以创建一个 RRef,并且在所有者接收到任何 RPC 调用之前,创建该 RRef 的用户可能会将其与其他人共享,而这些人又可以进一步分享该 RRef。一个不变量是,任何 RRef 的分叉图始终是一个树形结构,因为分叉一个 RRef 总是在调用方(除非调用方是所有者)上创建一个新的 UserRRef
实例,并且因此每个 RRef 只有一个父节点。
树中每个 UserRRef
的所有者视角包含三个阶段:
1) unknown -> 2) known -> 3) deleted.
所有者对整个树的视图会不断变化。当所有者认为没有存活的 UserRRef
实例时,它会删除其 OwnerRRef
实例。也就是说,在删除 OwnerRRef
时,所有的 UserRRef
实例可能已经被确实删除或未知。危险的情况是当某些分支的状态未知而其他分支已被删除。
G2 明确保证了在所有子 UserRRef
实例被其所有者知晓之前,没有任何父 UserRRef
被删除。然而,可能存在子 UserRRef
在其所有者知道其父 UserRRef
之前就被删除的情况。
考虑以下示例:从 OwnerRRef
开始分叉到 A,A 再分叉到 Y,最后 Y 分叉到 Z。
OwnerRRef -> A -> Y -> Z
如果 Z 的所有消息(包括删除消息)都在接收者处理 Y 的消息之前由所有者处理,那么所有者会在知道 Y 存在之前得知 Z 已经被删除。然而,这并不会造成任何问题。因为至少有一个 Y 的祖先 A 仍然存在,并且它会阻止所有者删除 OwnerRRef
。更具体地说,如果所有者不知道 Y,则根据G2规则,A 不能被删除,并且由于所有者是 A 的父节点,所以知道 A。
如果 RRef 在用户端创建,情况会变得稍微复杂一些:
OwnerRRef ^ | A -> Y -> Z
如果 Z 调用 to_here()
方法在 UserRRef
上,那么所有者会在 Z 被删除时知道 A 的存在,否则 to_here()
不会完成。 如果 Z 没有调用 to_here()
方法,则有可能所有者在收到任何来自 A 和 Y 的消息之前已经收到了 Z 发送的所有消息。 在这种情况下,由于 OwnerRRef
的实际数据尚未创建,因此没有需要删除的内容。 这与 Z 完全不存在的情况相同。 因此,这种情况仍然可以接受。
实现
G1 通过在 UserRRef
析构函数中发送删除消息来实现。为了提供 G2,每当父 UserRRef
被 fork 时,它会被放入一个由新 ForkId
索引的上下文中。只有当父 UserRRef
收到子节点发送的确认消息(ACK)后,才会从上下文中移除;而子节点仅在收到所有者确认后才会发出 ACK。
协议场景
现在让我们讨论上述设计在四种情况下是如何体现为协议的。