请求日程安排

粒度激活具有 单线程 执行模型。 默认情况下,在下一个请求开始处理之前,从头到尾处理每个请求。 在某些情况下,可能需要系统在一个请求等待异步操作完成时处理其他请求。 出于此原因和其他原因, 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 记录 ,然后向 B 发出调用。
  2. B 立即从 返回到 A。
  3. A 记录 并返回到原始调用方。

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

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

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

案例 2:通话死锁

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

在此示例中:

  1. CallOther 调用到达各自的 grain 并同时进行处理。
  2. 两个 grain 都记录 "1" 并继续执行 await other.Ping()
  3. 由于两个处理单元仍然 繁忙 (正在处理 CallOther 尚未完成的请求),因此 Ping() 请求处于等待状态。
  4. 过了一会儿, Orleans 确定调用已 超时,每个 Ping() 调用都会导致引发异常。
  5. 方法CallOther体没有处理异常,因此会向上传播到原始调用者。

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

重新进入

Orleans 默认为安全执行流,其中多个请求不会同时修改粒度的内部状态。 并发修改使逻辑复杂化,并给开发人员带来更大的负担。 这种针对并发 bug 的保护具有成本,主要是 实时性:某些调用模式可能会导致死锁,如前所述。 避免死锁的一种方法是确保粒度调用永远不会形成循环。 通常,很难编写无周期且保证不死锁的代码。 等待每个请求从头到尾运行,然后处理下一个请求也会损害性能。 例如,默认情况下,如果粒度方法对数据库服务执行异步请求,则粒度会暂停请求执行,直到数据库响应到达。

以下各节将讨论上述每个情况。 出于这些原因, Orleans 提供了允许一些或所有请求 并发执行的选项,并交错执行。 在 Orleans中,我们指的是 重入交错等问题。 通过并发执行请求,执行异步操作的粒子可以在较短的时间内处理更多请求。

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

在再次进入时,以下情况将变为有效的操作,从而消除了上述死锁的可能性。

案例 3:粒度或方法重新进入

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

在此示例中,单元 AB 可以同时调用,不必担心发生潜在的请求调度死锁,因为两个单元都是 可重入的。 以下部分提供了有关可重入性的更多详细信息。

重入晶粒

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

换句话说,重入激活可能在之前请求尚未完成时开始处理另一个请求。 执行仍然仅限于单个线程,因此激活一次执行一次,每个轮次仅代表激活请求之一执行。

重入粒子代码的执行始终是单线程的,不会并行运行多个粒子代码段,但重入粒子可能会看到不同请求的代码交错执行。 这意味着来自不同请求的延续可能会交错在一起。

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

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

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

如果此粒度被标记为ReentrantAttribute,则FooBar的执行可能会交错。

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

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

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

在重入粒度和非重入粒度之间进行选择时的主要权衡是使交错工作正确且难以推理的代码复杂性。

在一种简单的情况下,如果粒子是无状态的且逻辑简单,使用较少的重入粒子(但不要太少,确保所有硬件线程都能被利用)通常会稍微更高效。

如果代码更为复杂,即使用大量非重入部分,即使整体效率略低,也可能会显著减少调试非明显交错问题的麻烦。

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

交错方法

标有 AlwaysInterleaveAttribute 的粒度接口方法始终可以交错任何其他请求,也始终可以被任何其他请求交错,即使是针对非[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 表明此方法不会修改粒的状态。 将方法标记为 ReadOnly 允许 Orleans 与其他 ReadOnly 请求同时处理请求,这可以显著提高应用的性能。 请考虑以下示例:

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

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

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

调用链重入

如果一个grain在另一个grain上调用方法,然后又调用回原始grain,除非该调用是可重入的,否则调用将导致死锁。 可以在每个调用站点上使用 调用链重入 功能启用重入。 若要启用调用链重新进入,请调用 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。 这样可以精细地控制重新进入的位置和启用方式。

如果改为在IUserGrain上的GetDisplayName()方法声明中使用[AlwaysInterleave]注解以防止死锁,那么可以允许任何方法调用与任何其他方法GetDisplayName调用交错执行。 通过使用 AllowCallChainReentrancy,你只允许对 UserGrain 调用方法,并且仅在 scope 被释放之前。

抑制调用链重新进入

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

使用谓词的可重入性

grain 类可以指定一个谓词,以通过检查请求来按每个调用确定交错。 [MayInterleave(string methodName)] 属性提供此功能。 特性的参数是粒度类中静态方法的名称。 此方法接受一个 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.
    }
}