Freigeben über


Anforderungsplanung

Für Grainaktivierungen gilt ein Singlethread-Ausführungsmodell, bei dem standardmäßig immer jeweils eine Anforderung vom Anfang bis zum Ende verarbeitet wird, bevor mit der Verarbeitung der nächsten Anforderung begonnen wird. Unter bestimmten Umständen ist es jedoch möglicherweise wünschenswert, dass die Aktivierung bereits andere Anforderungen verarbeitet, während eine Anforderung auf den Abschluss eines asynchronen Vorgangs wartet. Aus diesem und anderen Gründen bietet Orleans dem Entwickler eine gewisse Kontrolle über das Interleavingverhalten von Anforderungen, wie im Abschnitt Eintrittsinvarianz beschrieben. Im Folgenden wird ein Beispiel für eine nicht eintrittsinvariante Anforderungsplanung beschrieben, bei der es sich um das Standardverhalten in Orleans handelt.

Betrachten Sie die folgende PingGrain-Definition:

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");
    }
}

In unserem Beispiel werden zwei Grains (A und B) vom Typ PingGrain verwendet. Ein Aufrufer ruft den folgenden Aufruf auf:

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

Reentrancy scheduling diagram.

Der Ausführungsflow sieht wie folgt aus:

  1. Der Aufruf kommt bei A an. Dort wird "1" protokolliert und anschließend ein Aufruf an B ausgegeben.
  2. B kehrt sofort von Ping() zu A zurück.
  3. A protokolliert "2" und kehrt zum ursprünglichen Aufrufer zurück.

Während A auf den Aufruf an B wartet, können keine eingehenden Anforderungen verarbeitet werden. Wenn A und B einander gleichzeitig aufrufen, kann es daher zu einem Deadlock kommen, während beide auf den Abschluss dieser Aufrufe warten. Im folgenden Beispiel führt der Client den folgenden Aufruf aus:

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));

Fall 1: die Aufrufe führen nicht zu einem Deadlock

Reentrancy scheduling diagram without deadlock.

In diesem Beispiel:

  1. Der Ping()-Aufruf von A kommt bei B an, bevor der CallOther(a)-Aufruf bei B ankommt.
  2. Daher verarbeitet B den Ping()-Aufruf vor dem CallOther(a)-Aufruf.
  3. Da B den Ping()-Aufruf verarbeitet, kann A zum Aufrufer zurückkehren.
  4. Wenn B seinen Ping()-Aufruf an A richtet, ist A noch mit dem Protokollieren seiner Nachricht beschäftigt ("2"). Daher muss der Aufruf einen Moment zwar warten, wird jedoch kurz darauf verarbeitet.
  5. A verarbeitet den Ping()-Aufruf und kehrt zu B zurück, während B zum ursprünglichen Aufrufer zurückkehrt.

Lassen Sie uns eine ungünstigere Folge von Ereignissen untersuchen, bei der derselbe Code aufgrund einer geringfügig anderen zeitlichen Abfolge zu einem Deadlock führt.

Fall 2: die Aufrufe führen zu einem Deadlock

Reentrancy scheduling diagram with deadlock.

In diesem Beispiel:

  1. Die CallOther-Aufrufe kommen bei ihren jeweiligen Grains an und werden gleichzeitig verarbeitet.
  2. Beide Grains protokollieren "1" und fahren mit await other.Ping() fort.
  3. Da beide Grains noch (mit der Verarbeitung der CallOther-Anforderung, die noch nicht abgeschlossen ist) beschäftigt sind, warten die Ping()-Anforderungen.
  4. Nach einer Weile stellt Orleans fest, dass beim Aufruf ein Timeout aufgetreten ist und dass für jeden Ping()-Aufruf eine Ausnahme ausgelöst wurde.
  5. Der CallOther-Methodenkörper behandelt die Ausnahme nicht, und sie wird dem ursprünglichen Aufrufer angezeigt.

Im folgenden Abschnitt wird beschrieben, wie Deadlocks durch ein Interleaving der Ausführung mehrerer Anforderungen verhindert werden.

Eintrittsinvarianz

Orleans wählt standardmäßig einen sicheren Ausführungsablauf, bei dem der interne Zustand eines Grains nicht gleichzeitig während mehrere Anforderungen geändert wird. Die gleichzeitige Änderung des internen Zustands verkompliziert die Logik und erschwert die Arbeit des Entwicklers. Dieser Schutz vor dieser Arten von Parallelitätsfehlern hat – wie bereits gesehen – seinen Preis, in erster Linie im Hinblick auf Livetests: Bestimmte Aufrufmuster können zu Deadlocks führen. Eine Möglichkeit, Deadlocks zu vermeiden, besteht darin, sicherzustellen, dass Grain-Aufrufe nicht in einem Zyklus resultieren. Häufig ist es jedoch schwierig, zyklusfreien Code zu schreiben, der nicht zu einem Deadlock führt. Wenn immer gewartet wird, bis jede Anforderung von Anfang bis Ende ausgeführt wurde, bevor die nächste Anforderung verarbeitet wird, kann dadurch die Leistung ebenfalls beeinträchtigt werden. Wenn beispielsweise eine Grain-Methode einige asynchrone Anforderungen für einen Datenbankdienst ausführt, hält das Grain die Anforderungsausführung an, bis es die Antwort von der Datenbank empfangen hat.

Diese Fälle werden in den folgenden Abschnitten erläutert. Daher bietet Orleans Entwicklern Optionen, mit denen einige oder alle Anforderungen gleichzeitig ausgeführt werden können, wobei die Ausführung per Interleaving erfolgt. In Orleans wird dies als Eintrittsinvarianz oder Interleaving bezeichnet. Durch das gleichzeitiges Ausführen von Anforderungen können Grains, die asynchrone Vorgänge ausführen, mehr Anforderungen in kürzerer Zeit verarbeiten.

In folgenden Fällen können mehrere Anforderungen per Interleaving verarbeitet werden:

Mit Eintrittsinvarianz wird aus dem folgenden Fall eine gültige Ausführung und der obige Deadlock kann nicht vorkommen.

Fall 3: Grain und Methode sind eintrittsinvariant

Re-entrancy scheduling diagram with re-entrant grain or method.

In diesem Beispiel können sich die Grains A und B gleichzeitig gegenseitig aufrufen, ohne dass die Gefahr besteht, dass bei der Anforderungsplanung Deadlocks entstehen, da beide Grains eintrittsinvariant sind. Die folgenden Abschnitte enthalten weitere Informationen zur Eintrittsinvarianz.

Eintrittsinvariante Grains

Die Grain-Implementierungsklassen können mit ReentrantAttribute gekennzeichnet werden, was bedeutet, dass unterschiedliche Anforderungen frei per Interleaving verarbeitet werden können.

Eine eintrittsinvariante Aktivierung kann also mit der Ausführung einer anderen Anforderung bereits beginnen, auch wenn eine vorherige Anforderung noch nicht ganz verarbeitet wurde. Die Ausführung erfolgt weiterhin nur threadweise, sodass die Aktivierung weiterhin Runde für Runde ausgeführt wird und die Ausführung in jeder Runde immer nur für eine Aktivierungsanforderung erfolgt.

Eintrittsinvarianter Code führt niemals mehrere Grain-Codeteile parallel aus (Grain-Code wird immer als Singlethreadcode ausgeführt), aber bei eintrittsinvarianten Grains wird der Code für verschiedene Anforderungen möglicherweise per Interleaving ausgeführt. Das bedeutet, dass sich die Fortsetzungsrunden verschiedener Anforderungen überlappen können.

Ein Beispiel hierfür ist der folgende Pseudocode, bei dem Foo und Bar zwei Methoden derselben Grain-Klasse sind:

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

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

Wenn dieses Grain als ReentrantAttribute gekennzeichnet ist, kann die Ausführung von Foo und Bar per Interleaving erfolgen.

So ist beispielsweise die folgende Ausführungsreihenfolge möglich:

Zeile 1, Zeile 3, Zeile 2 und Zeile 4. Das bedeutet, dass sich die Runden unterschiedlicher Anforderungen überlappen.

Wenn das Grain nicht eintrittsinvariant ist, gibt es nur folgende Ausführungsmöglichkeiten: Zeile 1, Zeile 2, Zeile 3, Zeile 4 ODER: Zeile 3, Zeile 4, Zeile 1, Zeile 2 (eine neue Anforderung kann erst nach Abschluss der vorherigen gestartet werden).

Der größte Nachteil bei der Wahl zwischen eintrittsinvarianten und nicht eintrittsinvarianten Grains ist die Codekomplexität, die sich ergibt, wenn Interleaving ordnungsgemäß funktionieren soll, und die Schwierigkeit, darüber nachzudenken.

In einem gewöhnlichen Fall mit zustandslosen Grains und einfacher Logik sollten weniger (aber nicht zu wenige, sodass alle Hardwarethreads genutzt werden können) eintrittsinvariante Grains generell etwas effizienter sein.

Wenn der Code komplexer ist, sollten sich mit einer größeren Anzahl nicht eintrittsinvarianter Grains, die insgesamt sogar etwas weniger effizient sein können, nicht offensichtliche Interleavingprobleme leichter finden lassen.

Am Ende hängt die Antwort von den Besonderheiten der Anwendung ab.

Interleavingmethoden

Grain-Schnittstellenmethoden, die mit AlwaysInterleaveAttribute gekennzeichnet sind, überlappen immer andere Anforderungen und werden möglicherweise von anderen Anforderungen überlappt, auch von Anforderungen für Nicht-[AlwaysInterleave]-Methoden.

Betrachten Sie das folgende Beispiel:

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));
    }
}

Betrachten Sie den durch die folgende Clientanforderung ausgelösten Aufrufablauf:

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());

Aufrufe von GoSlow werden nicht per Interleaving verarbeitet, sodass die Ausführung der beiden GoSlow-Aufrufe etwa 20 Sekunden dauert. Da GoFast jedoch als AlwaysInterleaveAttribute markiert ist, werden die drei entsprechenden Aufrufe gleichzeitig ausgeführt, und die Ausführung wird nach etwa 10 Sekunden statt nach mindestens 30 Sekunden abgeschlossen.

Schreibgeschützte Methoden

Wenn eine Grain-Methode den Grain-Zustand nicht ändert, kann sie sicher gleichzeitig mit anderen Anforderungen ausgeführt werden. ReadOnlyAttribute gibt an, dass eine Methode den Zustand eines Grains nicht ändert. Wenn Sie Methoden mit ReadOnly markieren, kann Orleans Ihre Anforderung gleichzeitig mit anderen ReadOnly-Anforderungen verarbeiten, was die Leistung Ihrer App erheblich verbessern kann. Betrachten Sie das folgende Beispiel:

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

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

Eintrittsinvarianz der Aufrufkette

Wenn ein Grain eine Methode in einem anderen Grain aufruft, die wiederum einen Aufruf in das ursprüngliche Grain durchführt, führt der Aufruf zu einem Deadlock. Dies ist nicht der Fall, wenn der Aufruf eintrittsinvariant ist. Die Eintrittsinvarianz kann pro Aufrufstandort über die Eintrittsinvarianz der Aufrufkette aktiviert werden. Rufen Sie die Methode AllowCallChainReentrancy() auf, um die Eintrittsinvarianz der Aufrufkette zu aktivieren. Die Methode gibt einen Wert zurück, der die Eintrittsinvarianz von jedem Aufrufer entlang der Aufrufkette zulässt, bis er verworfen wird. Dies schließt die Eintrittsinvarianz aus dem Grain ein, das die Methode selbst aufruft. Betrachten Sie das folgende Beispiel:

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>());
    }
}

Beim vorherigen Beispiel führt UserGrain.JoinRoom(roomName) einen Aufruf zu ChatRoomGrain.OnJoinRoom(user) durch. Letztere versucht erneut UserGrain.GetDisplayName() aufzurufen, um den Anzeigenamen von Benutzer*innen abzurufen. Da diese Aufrufkette einen Zyklus umfasst, führt dies zu einem Deadlock, wenn das UserGrain keine Eintrittsinvarianz über einen der in diesem Artikel erläuterten unterstützten Mechanismen zulässt. In dieser Instanz nutzen wir AllowCallChainReentrancy(), was nur roomGrain das Zurückrufen in das UserGrain erlaubt. Dadurch können Sie detailliert steuern, wo und wie die Eintrittsinvarianz aktiviert ist.

Wenn Sie einen Deadlock stattdessen verhindern, indem Sie die Methodendeklaration GetDisplayName() bei IUserGrain mit [AlwaysInterleave] kommentieren, lassen Sie zu, dass jedes Grain einen GetDisplayName-Aufruf mit jeder anderen Methode überlappen kann. Stattdessen erlauben Sie nurroomGrain Methoden auf unserem Grain aufrufen und das nur bis scope verworfen wird.

Eintrittsinvarianz der Aufrufkette unterdrücken

Eintrittsinvarianz der Aufrufkette kann auch mithilfe der Methode SuppressCallChainReentrancy()unterdrückt werden. Für Endentwickler*innen ist dies nur eingeschränkt nützlich. Es ist aber wichtig für die interne Verwendung von Bibliotheken, die die Grain-Funktion Orleans erweitern, wie z. B. Streaming- und Übertragungskanäle, damit Entwickler*innen die volle Kontrolle bei aktivierter Eintrittsinvarianz der Aufrufkette behalten.

Die GetCount-Methode verändert den Grain-Zustand nicht, daher ist sie mit ReadOnly gekennzeichnet. Aufrufer, die auf diesen Methodenaufruf warten, werden nicht durch andere ReadOnly-Anforderungen an das Grain blockiert, und die Methode wird sofort zurückgegeben.

Eintrittsinvarianz mithilfe eines Prädikats

In Grainklassen kann ein Prädikat angegeben werden, sodass das Interleaving nach einer Untersuchung der Anforderung von Aufruf zu Aufruf ausgewählt wird. Diese Funktion wird durch das Attribut [MayInterleave(string methodName)] bereitgestellt. Das Argument zu dem Attribut ist der Name einer statischen Methode innerhalb der Grainklasse, die ein InvokeMethodRequest-Objekt akzeptiert und ein bool zurückgibt, mit dem angegeben wird, ob die Anforderung per Interleaving verarbeitet werden soll.

Im folgenden Beispiel ist Interleaving zulässig, wenn der Anforderungsargumenttyp das [Interleave]-Attribut aufweist:

[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.
    }
}