Condividi tramite


Pianificazione delle richieste

Le attivazioni granulari hanno un modello di esecuzione a thread singolo e, per impostazione predefinita, elaborano ogni richiesta dall'inizio al completamento prima che la richiesta successiva possa iniziare l'elaborazione. In alcuni casi, potrebbe essere opportuno che l'attivazione elabori altre richieste mentre una richiesta è in attesa del completamento di un'operazione asincrona. Per questo e altri motivi, Orleans fornisce allo sviluppatore un controllo sul comportamento di interleaving della richiesta, come descritto nella sezione Reentrancy. Di seguito è riportato un esempio di pianificazione delle richieste non rientranti, ovvero il comportamento predefinito in Orleans.

Si consideri la definizione di PingGrain seguente:

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

Due tipi di grani PingGrain sono coinvolti nel nostro esempio, A e B. Un chiamante esegue la seguente chiamata:

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

Diagramma di pianificazione del ricalco.

Il flusso di esecuzione è il seguente:

  1. La chiamata arriva ad A, che registra "1" e quindi invia una chiamata a B.
  2. B torna immediatamente da Ping() a A.
  3. A registra "2" e torna al chiamante originale.

Mentre A è in attesa della chiamata a B, non può elaborare alcuna richiesta in ingresso. Di conseguenza, se A e B dovevano chiamarsi contemporaneamente, potrebbero bloccarsi durante l'attesa del completamento di tali chiamate. Ecco un esempio, in base al client che esegue la chiamata seguente:

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

Caso 1: le chiamate non causano deadlock

Diagramma di pianificazione della reentrancy senza deadlock.

In questo esempio:

  1. La chiamata Ping() da A arriva a B prima che la chiamata CallOther(a) arrivi a B.
  2. Pertanto, B elabora la chiamata Ping() prima della chiamata CallOther(a).
  3. Poiché B elabora la chiamata Ping(), A è in grado di tornare al chiamante.
  4. Quando B emette la chiamata Ping() a A, A è ancora occupato a registrare il messaggio ("2"), quindi la chiamata deve attendere per un breve periodo di tempo, ma è presto in grado di essere elaborata.
  5. A elabora la chiamata Ping() e torna a B, che torna al chiamante originale.

Si consideri una serie meno fortunata di eventi: una in cui lo stesso codice genera un deadlock a causa di tempi leggermente diversi.

Caso 2: il deadlock delle chiamate

Diagramma di programmazione della ri-entrata con stallo.

In questo esempio:

  1. Le chiamate CallOther arrivano ai rispettivi grani e vengono elaborate contemporaneamente.
  2. Entrambi le granularità registrano "1" e procedono a await other.Ping().
  3. Poiché entrambi i grani sono ancora occupati (elaborazione della richiesta CallOther, che non è ancora stata completata), le richieste Ping() sono in attesa
  4. Dopo un po' di tempo, Orleans determina che la chiamata è scaduta e ogni chiamata Ping() genera un'eccezione.
  5. Il corpo del metodo CallOther non gestisce l'eccezione, che risale fino al chiamante originale.

La sezione seguente descrive come evitare i deadlock consentendo a più richieste di intercalare la loro esecuzione fra di loro.

Rientranza

Orleans per impostazione predefinita sceglie un flusso di esecuzione sicuro: uno in cui lo stato interno di un grano non viene modificato simultaneamente durante più richieste. La modifica simultanea dello stato interno complica la logica e comporta un carico maggiore per lo sviluppatore. Questa protezione contro questi tipi di bug di concorrenza ha un costo, che è stato descritto in precedenza, principalmente liveness: alcuni modelli di chiamata possono causare deadlock. Un modo per evitare deadlock consiste nel garantire che le chiamate granulari non comportino mai un ciclo. Spesso è difficile scrivere codice privo di cicli e che non possa incorrere in deadlock. L'attesa dell'esecuzione di ogni richiesta dall'inizio al completamento prima dell'elaborazione della richiesta successiva può compromettere anche le prestazioni. Ad esempio, per impostazione predefinita, se un metodo granulare esegue una richiesta asincrona a un servizio di database, la granularità sospende l'esecuzione della richiesta fino a quando la risposta dal database non arriva al livello di granularità.

Ognuno di questi casi viene illustrato nelle sezioni seguenti. Per questi motivi, Orleans fornisce agli sviluppatori opzioni per consentire l'esecuzione simultanea di alcune o tutte le richieste, interfogliando l'esecuzione tra loro. In Orleans, tali preoccupazioni vengono definite reentrancy o interleaving. Eseguendo le richieste contemporaneamente, le unità che compiono operazioni asincrone possono elaborare più richieste in un periodo di tempo più breve.

Più richieste possono essere interfogliate nei seguenti casi:

Con la reentrancy, il caso seguente diventa un'esecuzione valida e viene eliminata la possibilità del deadlock precedente.

Caso 3: la granularità o il metodo è rientrante

Diagramma di pianificazione della reentrancy con granularità o metodo rientrante.

In questo esempio, i grani A e B possono scambiarsi chiamate contemporaneamente senza rischio di deadlock nella pianificazione perché entrambi i grani sono rientranti. Le sezioni seguenti forniscono altri dettagli sulla ri-entrata.

Grani rientranti

Le classi di implementazione Grain possono essere contrassegnate con ReentrantAttribute per indicare che le diverse richieste possono essere liberamente interfogliate.

In altre parole, un'attivazione rientrante può iniziare a eseguire un'altra richiesta mentre una richiesta precedente non ha terminato l'elaborazione. L'esecuzione è ancora limitata a un singolo thread, quindi l'attivazione continua a eseguire un turno alla volta e ogni turno viene eseguito per conto di una sola delle richieste di attivazione.

Il codice dei grani rientrante non esegue mai più parti di codice dei grani in parallelo (l'esecuzione del codice dei grani è sempre a thread singolo), ma i grani rientranti possono vedere l'esecuzione del codice di richieste diverse in modo intercalato. Ovvero, i turni di continuazione di richieste diverse possono intrecciarsi.

Ad esempio, come illustrato nello pseudo-codice seguente, considerare che Foo e Bar sono due metodi della stessa classe granulare:

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

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

Se questo grain è contrassegnato come ReentrantAttribute, l'esecuzione di Foo e Bar può interleaversi.

Ad esempio, è possibile il seguente ordine di esecuzione:

Riga 1, riga 3, riga 2 e riga 4. Ovvero, i turni delle diverse richieste si intrecciano.

Se il grano non fosse rientrante, le uniche esecuzioni possibili sarebbero: riga 1, riga 2, riga 3, riga 4 O: riga 3, riga 4, riga 1, riga 2 (una nuova richiesta non può iniziare prima del completamento di quella precedente).

Il compromesso principale nella scelta tra grani rientranti e non rientranti è la complessità del codice nel far funzionare correttamente l’interleaving e la difficoltà di ragionarci sopra.

In un caso semplice, quando i grani sono senza stato e la logica è semplice, un numero minore di grani rientranti (ma non troppo pochi, in modo che tutti i thread hardware vengano usati) dovrebbe, in generale, essere leggermente più efficiente.

Se il codice è più complesso, un numero maggiore di grani non rientranti, anche se leggermente meno efficienti nel complesso, dovrebbe far risparmiare molta fatica nel risolvere problemi di interleaving non ovvi.

Alla fine, la risposta dipende dalle specifiche dell'applicazione.

Metodi di interleaving

I metodi dell'interfaccia grain contrassegnati con AlwaysInterleaveAttribute, intercalano sempre qualsiasi altra richiesta e possono sempre essere intercalati con qualsiasi altra richiesta, anche con richieste per metodi non [AlwaysInterleave].

Si consideri l'esempio seguente:

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

Si consideri il flusso di chiamata avviato dalla richiesta client seguente:

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

Le chiamate a GoSlow non sono sovrapposte, quindi l'esecuzione totale delle due chiamate a GoSlow impiega circa 20 secondi. D'altra parte, GoFast è contrassegnato AlwaysInterleaveAttribute, e le tre chiamate vengono eseguite contemporaneamente, completandosi in circa 10 secondi in totale invece di richiedere almeno 30 secondi per il completamento.

Metodi di sola lettura

Quando un metodo granulare non modifica lo stato di granularità, è sicuro eseguire contemporaneamente ad altre richieste. ReadOnlyAttribute indica che un metodo non modifica lo stato di un grain. Contrassegnare i metodi come ReadOnly consente a Orleans di elaborare la richiesta contemporaneamente ad altre richieste ReadOnly, che potrebbero migliorare significativamente le prestazioni dell'app. Si consideri l'esempio seguente:

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

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

Il metodo GetCount non modifica lo stato di granularità, quindi è contrassegnato con ReadOnly. Chi attende l'invocazione di questo metodo non viene bloccato da altre richieste ReadOnly al grain, e il metodo restituisce immediatamente.

Rientranza della catena di chiamate

Se una granularità chiama un metodo che si basa su un altro tipo di granularità che esegue il richiamo alla granularità originale, la chiamata genererà un deadlock a meno che la chiamata non venga richiamata. La reentrancy può essere abilitata per ogni sito di chiamata tramite la reentrancy nelle catene di chiamate. Per abilitare la reentrancy della catena di chiamate, chiamare il metodo AllowCallChainReentrancy(), che restituisce un valore che consente la reentrance da qualsiasi chiamante verso il basso nella catena di chiamate fino a quando non viene eliminato. Ciò include il rientro dal livello di dettaglio che chiama il metodo stesso. Si consideri l'esempio seguente:

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

Nell'esempio precedente, UserGrain.JoinRoom(roomName) richiama ChatRoomGrain.OnJoinRoom(user), che tenta di richiamare nuovamente UserGrain.GetDisplayName() per ottenere il nome visualizzato dell'utente. Poiché questa catena di chiamate implica un ciclo, questo comporterà un deadlock se UserGrain non consente la reentrance usando uno dei meccanismi supportati descritti in questo articolo. In questa istanza viene usato AllowCallChainReentrancy(), che consente solo a roomGrain di richiamare in UserGrain. In questo modo è possibile effettuare un controllo granulare su dove e come viene abilitata la reentrancy.

Se invece si dovesse impedire il deadlock annotando la dichiarazione del metodo GetDisplayName() su IUserGrain con [AlwaysInterleave], si consentirebbe a qualsiasi grain di intercalare una chiamata GetDisplayName con qualsiasi altro metodo. Al contrario, stai consentendo soloroomGrain di chiamare i metodi sul nostro grain e solo fino a quando scope non viene eliminato.

Eliminare la reentrancy della catena di chiamate

La ri-entrata della catena di chiamate può anche essere soppressa usando il metodo SuppressCallChainReentrancy(). Ciò ha un'utilità limitata per gli sviluppatori finali, ma è importante per l'uso interno da parte delle librerie che estendono la funzionalità del Orleans grain, come lo streaming e i canali di trasmissione, per garantire che gli sviluppatori mantengano il controllo completo su quando viene abilitata la rientranza delle catene di chiamata.

Reentrancy usando un predicato

Le classi granulari possono specificare un predicato per determinare l'interleaving chiamata per chiamata esaminando la richiesta. L'attributo [MayInterleave(string methodName)] fornisce questa funzionalità. L'argomento dell'attributo è il nome di un metodo statico all'interno della classe grain che accetta un oggetto InvokeMethodRequest e restituisce un bool che indica se la richiesta debba essere intrecciata.

Di seguito è riportato un esempio che consente l'interleaving se il tipo di argomento della richiesta ha l'attributo [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.
    }
}