Udostępnij za pośrednictwem


Harmonogramowanie zgłoszeń

Aktywacje ziarna mają jednowątkowy model wykonywania. Domyślnie przetwarzają każde żądanie od początku do ukończenia, zanim następne żądanie może rozpocząć przetwarzanie. W niektórych okolicznościach może być pożądane, aby aktywacja przetwarzała inne żądania, podczas gdy jedno żądanie czeka na ukończenie operacji asynchronicznej. Z tego i innych powodów Orleans pozwala na pewną kontrolę nad zachowaniem przeplatania żądań, jak opisano w sekcji Reentrancy. Poniżej przedstawiono przykład planowania żądań nie-reentrantnych, co jest zachowaniem domyślnym w Orleans.

Rozważmy następującą PingGrain definicję:

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

W naszym przykładzie biorą udział dwa ziarna typu PingGrain: A i B. Obiekt wywołujący inicjuje następujące wywołanie:

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

Diagram harmonogramu reentrantności.

Przepływ wykonywania jest następujący:

  1. Wywołanie dotrze do A, które rejestruje "1" , a następnie wykonuje wywołanie do B.
  2. B natychmiast wraca z Ping() do A.
  3. Loguje"2" i wraca do oryginalnego wywołującego.

Podczas gdy A oczekuje na wywołanie B, nie może przetworzyć żadnych żądań przychodzących. W rezultacie, jeśli A i B miałyby zadzwonić do siebie jednocześnie, mogą utknąć w martwym punkcie podczas oczekiwania na ukończenie tych połączeń. Oto przykład na podstawie klienta wykonującego następujące wywołanie:

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

Przypadek 1: Wywołania się nie zablokują

Diagram planowania ponownego wejścia bez zakleszczenia.

W tym przykładzie:

  1. Połączenie Ping() z A dociera do B przed przyjściem połączenia CallOther(a) do B.
  2. W związku z tym usługa B przetwarza wywołanie Ping() przed wywołaniem CallOther(a) .
  3. Ponieważ B przetwarza wywołanie Ping(), A może wrócić do elementu wywołującego.
  4. Gdy B wysyła Ping() wywołanie do A, A jest nadal zajęta rejestrowaniem swojego komunikatu ("2"), więc wywołanie musi poczekać przez krótki czas, ale wkrótce może zostać przetworzone.
  5. A przetwarza wywołanie Ping() i wraca do B, który wraca do oryginalnego wywołującego.

Rozważmy mniej fortunny ciąg zdarzeń, w którym ten sam kod powoduje zakleszczenie z powodu nieco innego czasowania.

Przypadek 2: Impas wywołań

Diagram planowania ponownego wejścia z zakleszczeniem.

W tym przykładzie:

  1. Połączenia CallOther docierają do odpowiednich ziaren i są przetwarzane jednocześnie.
  2. Dziennik ziarna "1" i przejdź do .await other.Ping()
  3. Obie ziarna są nadal zajęte, ponieważ przetwarzają CallOther żądanie, które jeszcze nie zostało zakończone, więc Ping() żądania muszą czekać.
  4. Po pewnym czasie określa, Orleans że upłynął limit czasu wywołania, a każde Ping() wywołanie powoduje zgłoszenie wyjątku.
  5. Treść CallOther metody nie obsługuje wyjątku i przechodzi do oryginalnego miejsca wywołania.

W poniższej sekcji opisano, jak zapobiec blokadom, zezwalając wielu żądaniom na przeplatanie się ich wykonywania.

Ponowne wejścia

Orleans domyślnie jest to bezpieczny przepływ wykonywania, w którym stan wewnętrzny ziarna nie jest modyfikowany jednocześnie przez wiele żądań. Współbieżne modyfikacje komplikują logikę i nakładają większe obciążenie na Ciebie, dewelopera. Ochrona przed usterkami współbieżności wiąże się z kosztami, przede wszystkim żywotność: niektóre wzorce wywołań mogą prowadzić do zakleszczenia, o czym wspomniano wcześniej. Jednym ze sposobów uniknięcia zakleszczeń jest zapewnienie, że wywołania ziarna nigdy nie tworzą cyklu. Często trudno jest napisać kod, który jest wolny od zablokowań i nie powoduje zakleszczenia. Oczekiwanie na uruchomienie każdego żądania od początku do ukończenia przed przetworzeniem następnego może również zaszkodzić wydajności. Jeśli na przykład metoda ziarna wykonuje asynchroniczne żądanie do usługi bazy danych, ziarno wstrzymuje wykonywanie żądania do momentu otrzymania odpowiedzi od bazy danych.

Każdy z tych przypadków jest omówiony w poniższych sekcjach. Z tych powodów Orleans udostępnia opcje zezwalania niektórym lub wszystkim żądaniom na wykonywanie współbieżnie, przeplatając ich wykonywanie. W Orleans odnosimy się do takich obaw, jak reentrancy lub interleaving. Wykonując żądania jednocześnie, ziarna wykonujące operacje asynchroniczne mogą przetwarzać więcej żądań w krótszym okresie.

W następujących przypadkach może zostać przeplatonych wiele żądań:

W przypadku ponownego wywołania następujący przypadek staje się prawidłowym wykonaniem, co pozwala usunąć możliwość zakleszczenia opisanego powyżej.

Przypadek 3. Ziarno lub metoda jest reentrantna

Diagram planowania ponownego wejścia z reentrantnym ziarnem lub metodą.

W tym przykładzie ziarna A i B mogą wywoływać siebie jednocześnie bez potencjalnych zakleszczeń planowania żądań, ponieważ oba ziarna są reentrantne. Poniższe sekcje zawierają więcej szczegółów na temat ponownej rejestracji.

Ziarna wklęsłe

Klasy implementacji można oznaczać GrainReentrantAttribute, aby wskazać, że różne żądania mogą być swobodnie przeplatane.

Innymi słowy, aktywacja reentrant może rozpocząć przetwarzanie innego żądania, podczas gdy poprzednie żądanie nie zostało zakończone. Wykonanie jest nadal ograniczone do pojedynczego wątku, więc aktywacja wykonuje jeden obrót naraz, a każdy obrót jest wykonywany w imieniu tylko jednego z żądań aktywacji.

Kod ziarna reentrantnego nigdy nie uruchamia wielu fragmentów kodu ziarna równolegle (wykonywanie zawsze odbywa się jednowątkowo), ale ziarna reentrantne mogą doświadczać przeplatania się wykonywania kodu dla różnych żądań. Oznacza to, że kontynuacje różnych żądań mogą się przeplatać.

Na przykład, jak pokazano w poniższym pseudokodzie, należy wziąć pod uwagę, że Foo i Bar są dwiema metodami tej samej klasy ziarna:

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

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

Jeśli ten wątek jest oznaczony ReentrantAttribute, wykonanie Foo i Bar może się przeplatać.

Na przykład możliwa jest następująca kolejność wykonywania:

Wiersz 1, wiersz 3, wiersz 2 i wiersz 4. Oznacza to, że kolejki z różnych żądań przeplatają się.

Gdyby ziarno nie było ponownie wchodzące, jedynymi możliwymi sposobami wykonania byłoby: wiersz 1, wiersz 2, wiersz 3, wiersz 4 LUB: wiersz 3, wiersz 4, wiersz 1, wiersz 2 (nowe żądanie nie może rozpocząć się przed zakończeniem poprzedniego).

Głównym kompromisem przy wyborze między ziarnami reentrantnymi a niereentrantnymi jest złożoność kodu wymagana do prawidłowego działania przeplatania oraz trudności w rozumowaniu na ten temat.

W trywialnym przypadku, gdy ziarna są bezstanowe, a logika jest prosta, stosowanie mniejszej liczby reentrancyjnych ziaren (ale nie zbyt małej, aby zapewnić wykorzystanie wszystkich wątków sprzętowych) powinno być ogólnie nieco bardziej wydajne.

Jeśli kod jest bardziej złożony, użycie większej liczby niewielokrotnego wejścia, nawet jeśli jest nieco mniej wydajnie, może oszczędzić wielu problemów podczas debugowania trudnych do zidentyfikowania problemów z przeplataniem.

W końcu odpowiedź zależy od specyfiki aplikacji.

Metody przeplatania

Metody interfejsu ziarna oznaczone AlwaysInterleaveAttribute zawsze przeplatają się z wszelkimi innymi żądaniami i zawsze mogą być przeplatane przez dowolne inne żądania, nawet dotyczące metod innych [AlwaysInterleave].

Rozważmy następujący przykład:

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

Rozważ przepływ wywołań zainicjowany przez następujące żądanie klienta:

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

Wywołania do GoSlow nie są przeplatane, więc łączny czas wykonywania dwóch wywołań GoSlow wynosi około 20 sekund. Z drugiej strony GoFast jest oznaczony jako AlwaysInterleaveAttribute. Trzy wywołania są wykonywane jednocześnie, co pozwala na ich zakończenie w około 10 sekund zamiast wymagania co najmniej 30 sekund.

Metody tylko do odczytu

Jeśli metoda ziarna nie modyfikuje jego stanu, można ją bezpiecznie wykonywać równocześnie z innymi żądaniami. Wskazuje ReadOnlyAttribute , że metoda nie modyfikuje stanu ziarna. Metody oznaczania jako ReadOnly umożliwiają Orleans przetwarzanie żądań jednocześnie z innymi ReadOnly żądaniami, co może znacznie zwiększyć wydajność aplikacji. Rozważmy następujący przykład:

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

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

Metoda GetCount nie modyfikuje stanu ziarna, dlatego jest oznaczona jako ReadOnly. Wywołania oczekujące na wywołanie tej metody nie są blokowane przez inne ReadOnly żądania kierowane do grainu, a metoda zwraca wartość natychmiast.

Reentrancja łańcucha wywołań

Jeśli ziarno wywołuje metodę na innym ziarnie, które następnie wywołuje z powrotem do ziarna oryginalnego, wywołanie powoduje zakleszczenie, chyba że wywołanie jest reentrantne. Można włączyć ponowne wywołanie dla poszczególnych lokacji przy użyciu ponownego wywołania łańcucha wywołań. Aby włączyć ponowne wywołania łańcucha wywołań, wywołaj metodę AllowCallChainReentrancy() . Ta metoda zwraca wartość umożliwiającą ponowne wejście z dowolnego wywołującego dalej w dół łańcucha wywołań, aż do usunięcia wartości. Obejmuje to ponowne wejście do ziarna, które samo wywołuje metodę. Rozważmy następujący przykład:

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

W poprzednim przykładzie UserGrain.JoinRoom(roomName) wywołuje ChatRoomGrain.OnJoinRoom(user), która próbuje wywołać UserGrain.GetDisplayName() aby uzyskać nazwę wyświetlaną użytkownika. Ponieważ ten łańcuch wywołań obejmuje cykl, powoduje zakleszczenie, jeśli UserGrain nie zezwala na ponowne wejście przy użyciu jednego z obsługiwanych mechanizmów omówionych w tym artykule. W tym przypadku używamy AllowCallChainReentrancy(), który pozwala tylko roomGrain na przywołanie do UserGrain. Zapewnia to szczegółową kontrolę nad tym, gdzie i jak jest włączona ponowna reentrancyjność.

Gdyby zamiast tego zapobiec zakleszczeniu przez adnotowanie deklaracji metody GetDisplayName() na IUserGrain za pomocą [AlwaysInterleave], umożliwiłoby to każdemu zbożu przeplatanie wywołania GetDisplayName z dowolną inną metodą. Korzystając z AllowCallChainReentrancy, można wywoływać tylkoroomGrain metody na UserGrain, i to tylko do momentu, gdy scope zostanie zlikwidowany.

Pomijanie ponownego wywołania łańcucha wywołań

Można również zablokować ponowne wejście do łańcucha wywołań przy użyciu metody SuppressCallChainReentrancy(). Ma to ograniczoną użyteczność dla deweloperów końcowych, ale jest ważne dla użytku wewnętrznego przez biblioteki rozszerzające Orleans funkcjonalność ziarna, takie jak przesyłanie strumieniowe i kanały emisji, aby deweloperzy mogli mieć pełną kontrolę nad włączeniem reentrantności łańcucha wywołań.

Ponowne stosowanie predykatu

Klasy ziarna mogą określać predykat w celu określenia przeplatania na podstawie wywołania, sprawdzając żądanie. Atrybut [MayInterleave(string methodName)] zapewnia tę funkcję. Argumentem atrybutu jest nazwa metody statycznej w klasie ziarna. Ta metoda przyjmuje obiekt InvokeMethodRequest i zwraca bool, wskazując, czy żądanie powinno zostać przeplatane.

Oto przykład, który umożliwia przeplatanie, jeśli typ argumentu żądania ma atrybut [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.
    }
}