次の方法で共有


要求スケジュール

グレインのアクティブ化の実行モデルは "シングルスレッド" であり、既定では、各要求を最初から完了まで処理してから、次の要求の処理を開始できるようになります。 状況によっては、アクティブ化で、ある要求が非同期操作の完了を待機している間に、他の要求を処理することが望ましい場合があります。 この理由などにより、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 型の 2 つのグレイン AB を使います。呼び出し元が、次の呼び出しを行います。

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

再入スケジュールの図。

実行のフローは次のとおりです。

  1. 呼び出しが A に到着し、"1" をログした後、B への呼び出しを発行します。
  2. B はすぐに Ping() から A に戻ります。
  3. A"2" をログし、元の呼び出し元に戻ります。

AB への呼び出しを待機している間は、受信要求を処理できません。 そのため、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. CallOther(a) 呼び出しが B に到着する前に、A からの Ping() 呼び出しが B に到着します。
  2. したがって、BCallOther(a) 呼び出しの前に Ping() 呼び出しを処理します。
  3. BPing() 呼び出しを処理するため、A は呼び出し元に戻ることができます。
  4. BA への Ping() 呼び出しを発行するとき、A はメッセージ ("2") をログする処理でまだビジー状態であるため、その呼び出しは短期間待機する必要がありますが、すぐに処理できます。
  5. APing() 呼び出しを処理し、B に戻り、元の呼び出し元に戻ります。

恵まれない一連のイベントを考えてみましょう。つまり、少しタイミングが異なるために、同じコードで "デッドロック" が発生する場合です。

ケース 2: 呼び出しがデッドロックになる

デッドロックになる再入スケジュールの図。

この例では、次のように記述されています。

  1. CallOther 呼び出しがそれぞれのグレインに到着し、同時に処理されます。
  2. 両方のグレインが "1" をログし、await other.Ping() に進みます。
  3. 両方のグレインがまだ "ビジー状態" であるため (まだ完了していない Ping() 要求の処理により)、CallOther 要求は待機します
  4. しばらくすると、Orleans によって呼び出しはタイムアウトになったと判断され、その結果各 Ping() 呼び出しで例外がスローされます。
  5. CallOther のメソッド本体はその例外を処理せず、元の呼び出し元までバブリングします。

次のセクションでは、複数の要求が互いに実行をインターリーブできるようにすることで、デッドロックを防ぐ方法について説明します。

再入

Orleans では既定で、安全な実行フローが選択されます。つまり、グレインの内部状態が複数の要求中に同時に変更されないフローです。 内部状態を同時に変更すると、ロジックが複雑になり、開発者の負担が大きくなります。 そのような種類のコンカレンシー バグに対してこの保護を行う場合、コストが伴います。それは主に、前に説明した "活動性" です。つまり、特定の呼び出しパターンによってデッドロックが発生するおそれがあります。 デッドロックを回避する方法の 1 つは、グレインの呼び出しでサイクルが発生しないようにすることです。 多くの場合、サイクルが発生せずデッドロックにならないコードを記述することは困難です。 各要求を最初から完了まで待機してから次の要求を処理すると、パフォーマンスが低下する可能性もあります。 たとえば、既定では、あるグレインのメソッドがデータベース サービスに対して非同期要求を実行すると、グレインはデータベースからの応答がグレインに到着するまで要求の実行を一時停止します。

これらの各ケースについて、以下のセクションで説明します。 このような理由から、Orleans では、一部またはすべての要求を "同時に" 実行し、それらの実行を相互にインターリーブするオプションが開発者に提供されます。 Orleans では、このような懸念事項は "再入" や "インターリーブ" と呼ばれます。 要求を同時に実行することで、非同期操作を実行するグレインがより短い期間でより多くの要求を処理できます。

次の場合、複数の要求をインターリーブすることができます。

再入を使うと、次のケースが有効な実行になり、上記のデッドロックの可能性はなくなります。

ケース 3: グレインまたはメソッドが再入可能

再入可能なグレインまたはメソッドを使った再入スケジュールの図。

この例では、要求スケジュールのデッドロックを発生させずにグレイン AB が相互を同時に呼び出すことができます。両方のグレインが "再入可能" であるためです。 以下のセクションでは、再入について詳しく説明します。

再入可能なグレイン

Grain 実装クラスは ReentrantAttribute でマークして、異なる要求を自由にインターリーブ可能であることを示すことができます。

言い換えると、再入可能なアクティブ化は、前の要求の処理が完了していなくても、別の要求の実行を開始する可能性があります。 実行は引き続き 1 つのスレッドに制限されるため、アクティブ化は一度に 1 ターンずつ実行され、各ターンはアクティブ化の要求の 1 つだけに代わって実行されます。

再入可能なグレイン コードでは、複数のグレイン コードが並列で実行されることはありません (グレイン コードの実行は常にシングルスレッドです)。ただし、再入可能なグレインでは、異なる要求のインターリーブでコードの実行が現れる場合があります。 つまり、異なる要求からの継続ターンはインターリーブする場合があります。

たとえば、次の擬似コードに示すように、FooBar が同じグレイン クラスの 2 つのメソッドであるとします。

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

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

このグレインが ReentrantAttribute とマークされている場合、FooBar の実行はインターリーブする可能性があります。

たとえば、次の実行順序が考えられます。

line 1、line 3、line 2、line 4。 つまり、異なる要求からのターンがインターリーブします。

グレインが再入可能でない場合、考えられる実行は、line 1、line 2、line 3、line 4 または line 3、line 4、line 1、line 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 の呼び出しはインターリーブされないため、2 つの GoSlow 呼び出しの合計実行時間は約 20 秒かかります。 一方、GoFastAlwaysInterleaveAttribute とマークされており、それに対する 3 つの呼び出しは同時に実行され、合計約 10 秒で完了します。完了までに少なくとも 30 秒かかることはありません。

読み取り専用メソッド

グレイン メソッドがグレインの状態を変更しない場合は、他の要求と同時に実行しても安全です。 ReadOnlyAttribute は、メソッドがグレインの状態を変更しないことを示します。 メソッドを 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 で、この記事で説明されるサポートされているメカニズムのいずれかを使用して再入できない場合、デッドロックが発生します。 この例では、roomGrain のみが UserGrain にコールバックできる AllowCallChainReentrancy() を使用します。 これにより、再入を有効にする場所と方法をきめ細かく制御できます。

[AlwaysInterleave] を使用した IUserGrainGetDisplayName() メソッドの宣言に注釈を付けてデッドロックを防ぐ場合は、任意のグレインが他のメソッドを使用した GetDisplayName 呼び出しをインターリーブできるようにします。 代わりに、scope が破棄されるまで、roomGrain に "のみ"、グレインでのメソッドの呼び出しを許可します。

呼び出しチェーンの再入を抑制する

SuppressCallChainReentrancy() メソッドを使用して、呼び出しチェーンの再入を抑制することもできます。 これは、エンド開発者にとっての有用性は限られていますが、ストリーミングおよびブロードキャスト チャンネルなどの Orleans グレイン機能を拡張するライブラリによる内部使用では、呼び出しチェーンの再入が有効になったときに開発者が完全に制御を維持するために重要になります。

GetCount メソッドはグレインの状態を変更しないため、ReadOnly でマークされています。 このメソッド呼び出しを待機している呼び出し元は、グレインに対する他の ReadOnly 要求によってブロックされず、メソッドはすぐに戻ります。

述語を使った再入

グレイン クラスでは、述語を指定して、要求を検査することで呼び出しごとにインターリーブを決定できます。 [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.
    }
}