请求日程安排

Grain 激活具有单线程执行模型,默认情况下,会在下一个请求开始处理之前从头到尾处理每个请求。 在某些情况下,可能需要在一个请求正在等待异步操作完成的同时,通过激活来处理其他请求。 由于此原因和其他原因,Orleans 为开发人员提供了对请求交错行为的一些控制度,如重新进入部分中所述。 下面是不可重入请求计划的示例,这是 Orleans 的默认行为。

请考虑下面的 PingGrain 定义:

public interface IPingGrain : IGrainWithStringKey
{
    Task Ping();
    Task CallOther(IPingGrain other);
}

public class PingGrain : Grain, IPingGrain
{
    private readonly ILogger<PingGrain> _logger;

    public PingGrain(ILogger<PingGrain> logger) => _logger = logger;

    public Task Ping() => Task.CompletedTask;

    public async Task CallOther(IPingGrain other)
    {
        _logger.LogInformation("1");
        await other.Ping();
        _logger.LogInformation("2");
    }
}

示例中涉及 PingGrain 类型的两个 grain:A 和 B。调用方调用以下调用:

var a = grainFactory.GetGrain("A");
var b = grainFactory.GetGrain("B");
await a.CallOther(b);

重新进入计划的示意图。

执行流如下:

  1. 调用到达 A,A 记录 "1",然后向 B 发出调用。
  2. B 立即从 Ping() 返回到 A。
  3. A 记录 "2" 并返回到原始调用方。

在 A 等待对 B 的调用时,它无法处理任何传入请求。 因此,如果 A 和 B 同时相互调用,则它们可能会在等待这些调用完成时死锁。 以下示例基于发出以下调用的客户端:

var a = grainFactory.GetGrain("A");
var b = grainFactory.GetGrain("B");

// A calls B at the same time as B calls A.
// This might deadlock, depending on the non-deterministic timing of events.
await Task.WhenAll(a.CallOther(b), b.CallOther(a));

案例 1:调用不会死锁

无死锁重新进入计划的示意图。

在此示例中:

  1. 来自 A 的 Ping() 调用先到达 B,然后 CallOther(a) 调用到达 B。
  2. 因此,B 先处理 Ping() 调用,再处理 CallOther(a) 调用。
  3. 由于 B 处理 Ping() 调用,A 能够返回到调用方。
  4. 当 B 向 A 发出 Ping() 调用时,A 仍在忙于记录其消息 ("2"),因此调用必须等待一段较短的时间,但很快就可以处理。
  5. A 处理 Ping() 调用并返回到 B,后者返回到原始调用方。

考虑一系列不太幸运的事件:在其中一个事件中,相同的代码由于计时略有不同而导致死锁。

案例 2:调用死锁

有死锁重新进入计划的示意图。

在此示例中:

  1. CallOther 调用到达各自的 grain 并同时进行处理。
  2. 两个 grain 都记录 "1" 并继续执行 await other.Ping()
  3. 由于两个 grain 仍然忙碌(处理尚未完成的 CallOther 请求),Ping() 请求将等待
  4. 一段时间后,Orleans 将确定调用已超时,并且每个 Ping() 调用都会导致引发异常。
  5. CallOther 方法主体不会处理此异常,并且此异常会冒泡到原始调用方。

以下部分介绍如何通过允许多个请求相互交错执行来防止死锁。

重新进入

Orleans 默认会选择一个安全的执行流:其中一个 grain 的内部状态不会在多个请求期间并发修改。 内部状态的并发修改会使逻辑变得复杂,并增加开发人员的负担。 这种针对此类并发 bug 的保护需要付出前面讨论过的代价,主要是存活状态:某些调用模式可能导致死锁。 避免死锁的一种方法是确保 grain 调用永远不会导致循环。 通常,很难编写无循环且不会死锁的代码。 在处理下一个请求之前等待每个请求从开始运行到完成也会损害性能。 例如,默认情况下,如果 grain 方法对数据库服务执行一些异步请求,则 grain 将暂停请求执行,直到来自数据库的响应到达 grain。

后续部分将讨论其中每个案例。 出于这些原因,Orleans 为开发人员提供了允许并发执行部分或全部请求的选项,它们的执行相互交错。 在 Orleans 中,这种情况指重新进入或交错。 通过并发执行请求,执行异步操作的 grain 可以在更短时间内处理更多请求。

在以下情况下,可能会交错多个请求:

使用可重入性,以下案例将变为有效执行,并消除上述死锁的可能性。

案例 3:grain 或方法是可重入的

使用可重入 grain 或方法的重新进入计划的示意图。

在此示例中,grain A 和 B 可以同时相互调用,而不会出现任何请求计划死锁的可能性,因为这两个 grain 都是可重入的。 以下部分提供了有关可重入性的更多详细信息。

可重入 grain

Grain 实现类可以用 ReentrantAttribute 标记,以指示不同的请求可以自由交错。

换言之,可重入激活可以在前一个请求尚未完成处理时开始执行另一个请求。 执行仍限制为单个线程,因此激活仍然每次执行一个轮次,并且每一轮次仅代表激活的一个请求执行。

可重入 grain 代码永远不会并行运行多个 grain 代码片段(grain 代码的执行始终是单线程的),但可重入 grain 可能看到不同请求交错的代码执行。 即,来自不同请求的延续轮次可能交错。

例如,如下面的伪代码所示,请考虑 FooBar 是同一 grain 类的两种方法:

Task Foo()
{
    await task1;    // line 1
    return Do2();   // line 2
}

Task Bar()
{
    await task2;   // line 3
    return Do2();  // line 4
}

如果此 grain 标记为 ReentrantAttribute,则 FooBar 的执行可以交错。

例如,以下执行顺序是可行的:

第 1 行、第 3 行、第 2 行和第 4 行。 也就是说,来自不同请求的轮次交错。

如果 grain 是不可重入的,唯一可能的执行顺序是:第 1 行、第 2 行、第 3 行、第 4 行,或:第 3 行、第 4 行、第 1 行、第 2 行(新请求需在前一个请求完成之后才能开始)。

在可重入和不可重入 grain 之间进行选择时,主要的权衡是使交错正常工作所涉及的代码复杂性,及其推理难度。

在 grain 是无状态且逻辑较简单的微妙情况下,较少(但不是过少,以便能够使用所有硬件线程)可重入 grain 的效率通常略高一点。

如果代码较复杂,则更大数量的不可重入 grain(即使整体效率略低)可以省去你解决不明显的交错问题而面临的烦恼。

最后,答案取决于应用程序的具体情况。

交错方法

标记有 AlwaysInterleaveAttribute 的 grain 接口方法始终交错任何其他请求,并且始终可以与任何其他请求交错,甚至是非 [AlwaysInterleave] 方法的请求。

请考虑以下示例:

public interface ISlowpokeGrain : IGrainWithIntegerKey
{
    Task GoSlow();

    [AlwaysInterleave]
    Task GoFast();
}

public class SlowpokeGrain : Grain, ISlowpokeGrain
{
    public async Task GoSlow()
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
    }

    public async Task GoFast()
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
    }
}

请考虑由以下客户端请求发起的调用流:

var slowpoke = client.GetGrain<ISlowpokeGrain>(0);

// A. This will take around 20 seconds.
await Task.WhenAll(slowpoke.GoSlow(), slowpoke.GoSlow());

// B. This will take around 10 seconds.
await Task.WhenAll(slowpoke.GoFast(), slowpoke.GoFast(), slowpoke.GoFast());

GoSlow 的调用不会交错,因此两个 GoSlow 调用的总执行时间大约需要 20 秒。 另一方面,GoFast 标记为 AlwaysInterleaveAttribute,并且对它的三个调用并发执行,总共将在大约 10 秒内完成,而不是至少需要 30 秒才能完成。

Readonly 方法

当 grain 方法不修改 grain 状态时,可以安全地与其他请求并发执行。 ReadOnlyAttribute 指示方法不修改 grain 的状态。 将方法标记为 ReadOnly 允许 Orleans 将你的请求与其他 ReadOnly 请求并发处理,这可能会显著提高应用的性能。 请考虑以下示例:

public interface IMyGrain : IGrainWithIntegerKey
{
    Task<int> IncrementCount(int incrementBy);

    [ReadOnly]
    Task<int> GetCount();
}

调用链重入

如果粒度调用了另一个粒度上的方法,后者又向回调用原始的粒度,那么除非该调用是重新进入的,否则调用将导致死锁。 可以使用调用链重新进入,按每个调用站点启用重新进入。 要启用调用链重新进入,请调用 AllowCallChainReentrancy() 方法,它会返回一个值,允许调用链往下的任何调用方重新进入,直到调用链被释放为止。 这包括从调用方法本身的粒度重新进入。 请考虑以下示例:

public interface IChatRoomGrain : IGrainWithStringKey
{
    ValueTask OnJoinRoom(IUserGrain user);
}

public interface IUserGrain : IGrainWithStringKey
{
    ValueTask JoinRoom(string roomName);
    ValueTask<string> GetDisplayName();
}

public class ChatRoomGrain : Grain<List<(string DisplayName, IUserGrain User)>>, IChatRoomGrain
{
    public async ValueTask OnJoinRoom(IUserGrain user)
    {
        var displayName = await user.GetDisplayName();
        State.Add((displayName, user));
        await WriteStateAsync();
    }
}

public class UserGrain : Grain, IUserGrain
{
    public ValueTask<string> GetDisplayName() => new(this.GetPrimaryKeyString());
    public async ValueTask JoinRoom(string roomName)
    {
        // This prevents the call below from triggering a deadlock.
        using var scope = RequestContext.AllowCallChainReentrancy();
        var roomGrain = GrainFactory.GetGrain<IChatRoomGrain>(roomName);
        await roomGrain.OnJoinRoom(this.AsReference<IUserGrain>());
    }
}

在前面的示例中,UserGrain.JoinRoom(roomName) 调用了 ChatRoomGrain.OnJoinRoom(user),后者尝试回调 UserGrain.GetDisplayName() 以获取用户的显示名称。 由于此调用链涉及一个周期,因此如果 UserGrain 不允许使用本文中讨论的任何受支持的机制重新进入,这将导致死锁。 在此实例中,我们使用了 AllowCallChainReentrancy(),它只允许 roomGrain 回调 UserGrain。 这会授予你对重新进入的启用位置和方式的精细控制。

如果要改为通过使用 [AlwaysInterleave] 注释 IUserGrain 上的 GetDisplayName() 方法声明来阻止死锁,则会允许任何粒度将 GetDisplayName 调用与任何其他方法交错。 相反,你允许 roomGrain 在我们的粒度上调用方法,并且到 scope 被释放为止。

抑制调用链重新进入

还可以使用 SuppressCallChainReentrancy() 方法抑制调用链重新输入。 这对最终开发人员的用处有限,但对于扩展 Orleans 粒度功能的库(如流式处理广播通道)的内部使用非常重要,因为它可以确保开发人员在启用调用链重新进入时保持完全控制。

GetCount 方法不会修改 grain 状态,因此它标记有 ReadOnly。 等待调用此方法的调用方不会被对 grain 的其他 ReadOnly 请求阻止,并且该方法会立即返回。

使用谓词的可重入性

grain 类可以指定一个谓词,以通过检查请求来按每个调用确定交错。 [MayInterleave(string methodName)] 属性提供此功能。 该属性的参数是 grain 类中的静态方法的名称,该方法接受 InvokeMethodRequest 对象,并返回 bool 用于指示是否应交错请求。

在以下示例中,如果请求参数类型具有 [Interleave] 属性,则允许交错:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class InterleaveAttribute : Attribute { }

// Specify the may-interleave predicate.
[MayInterleave(nameof(ArgHasInterleaveAttribute))]
public class MyGrain : Grain, IMyGrain
{
    public static bool ArgHasInterleaveAttribute(IInvokable req)
    {
        // Returning true indicates that this call should be interleaved with other calls.
        // Returning false indicates the opposite.
        return req.Arguments.Length == 1
            && req.Arguments[0]?.GetType()
                    .GetCustomAttribute<InterleaveAttribute>() != null;
    }

    public Task Process(object payload)
    {
        // Process the object.
    }
}