远程引用协议¶
本说明介绍了远程引用协议的设计细节,并逐步介绍了不同场景下的消息流。在继续之前,请确保您熟悉分布式 RPC 框架。
背景¶
RRef 代表远程引用。它是位于本地或远程工作器上的对象的引用,并在后台透明地处理引用计数。从概念上讲,它可以被视为一个分布式共享指针。应用程序可以通过调用 remote()
来创建一个 RRef。每个 RRef 都由 remote()
调用的被调用工作器拥有(即,所有者),并且可以被多个用户使用。所有者存储实际数据并跟踪全局引用计数。每个 RRef 都可以由全局 RRefId
唯一标识,该标识是在 remote()
调用者的创建时分配的。
在所有者工作器上,只有一个 OwnerRRef
实例,其中包含实际数据,而在用户工作器上,可以有尽可能多的 UserRRefs
,并且 UserRRef
不保存数据。所有者上的所有使用都将使用全局唯一的 RRefId
检索唯一的 OwnerRRef
实例。当 rpc_sync()
、rpc_async()
或 remote()
调用中使用 RRef 作为参数或返回值时,将创建一个 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 在 UserRRef
上调用 to_here()
,那么至少在 Z 被删除时,所有者知道 A,因为否则,to_here()
不会完成。如果 Z 没有调用 to_here()
,则所有者可能在收到来自 A 和 Y 的任何消息之前收到来自 Z 的所有消息。在这种情况下,由于 OwnerRRef
的实际数据尚未创建,因此也没有任何内容要删除。这与 Z 根本不存在相同。因此,这仍然是可以的。
实现¶
G1 是通过在 UserRRef
析构函数中发送删除消息来实现的。为了提供 G2,父 UserRRef
在分叉时被放入一个上下文中,并根据新的 ForkId
进行索引。只有在收到来自子节点的确认消息(ACK)时,父 UserRRef
才会从上下文中删除,而子节点只有在被所有者确认后才会发送 ACK。
协议场景¶
现在让我们讨论上述设计如何在四种场景中转化为协议。