Condividi tramite


Pianificazione delle richieste

Le attivazioni granulari hanno un modello di esecuzione a thread singolo . Per impostazione predefinita, elaborano ogni richiesta dall'inizio al completamento prima che la richiesta successiva possa iniziare l'elaborazione. In alcune circostanze, potrebbe essere opportuno che un'attivazione elabori altre richieste mentre una richiesta attende il completamento di un'operazione asincrona. Per questo e altri motivi, Orleans offre un certo 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 attende la chiamata a B, non può elaborare le richieste 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 si bloccano

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 in cui lo stesso codice genera un deadlock a causa di tempi leggermente diversi.

Caso 2: Le chiamate causano un deadlock

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 (elaborando la CallOther richiesta, che non è ancora stata completata), le richieste Ping() aspettano.
  4. Dopo un po' di tempo, Orleans determina che la chiamata è scaduta e ogni Ping() chiamata genera un'eccezione.
  5. Il CallOther corpo del metodo non tratta l'eccezione e si propaga fino al chiamante originale.

La sezione seguente descrive come evitare deadlock consentendo a più richieste di interleavere l'esecuzione.

Rientranza

Orleans le impostazioni predefinite prevedono un flusso di esecuzione sicuro in cui lo stato interno di un grain non viene modificato simultaneamente da più richieste. La modifica simultanea complica la logica e comporta un carico maggiore per l'utente, lo sviluppatore. Questa protezione contro i bug di concorrenza ha un costo, principalmente liveness: alcuni modelli di chiamata possono causare deadlock, come illustrato in precedenza. Un modo per evitare deadlock è assicurarsi che le chiamate di grain non formino mai un ciclo. Spesso, è difficile scrivere codice privo di cicli e garantito che non blocchi. L'attesa dell'esecuzione di ogni richiesta dall'inizio al completamento prima dell'elaborazione del successivo può anche compromettere 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 all'arrivo della risposta del database.

Ognuno di questi casi viene illustrato nelle sezioni seguenti. Per questi motivi, Orleans fornisce opzioni per consentire l'esecuzione simultanea di alcune o tutte le richieste, interleavendo l'esecuzione. In Orleans, ci si riferisce a tali preoccupazioni come reentrancy o interleaving. Eseguendo contemporaneamente le richieste, le unità di processamento che svolgono operazioni asincrone possono elaborare più richieste in un periodo di tempo più breve.

Più richieste possono essere interfogliate nei seguenti casi:

Con il concetto di reentrancy, il caso che segue diventa un'esecuzione valida, eliminando la possibilità dello stallo descritto in precedenza.

Caso 3: Il granulo o il metodo è rientrante

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

In questo esempio, i grani A e B possono chiamarsi contemporaneamente senza potenziali deadlock di pianificazione delle richieste perché entrambi i grani sono reentrant. Le sezioni seguenti forniscono altri dettagli sulla ri-entrata.

Grani reentranti

È possibile contrassegnare le classi di implementazione Grain con ReentrantAttribute per indicare che richieste diverse possono essere liberamente intercalate.

In altre parole, un'attivazione rientrante potrebbe iniziare a elaborare un'altra richiesta mentre una richiesta precedente non è stata completata. L'esecuzione è ancora limitata a un singolo thread, quindi l'attivazione esegue un turno alla volta e ogni turno viene eseguito per conto di una sola delle richieste di attivazione.

Il codice "grain" reentrant non esegue mai più parti di codice "grain" in parallelo (l'esecuzione è sempre a thread singolo), ma i "grani" reentranti potrebbero osservare l'esecuzione del codice per richieste diverse in modo intervallato. Cioè, la continuazione delle richieste diverse potrebbe intercalarsi.

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 questa granularità è contrassegnata come ReentrantAttribute, l'esecuzione di Foo e Bar potrebbe interleavere.

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 quando si sceglie tra grani rientranti e non rientranti è la complessità del codice necessaria per fare funzionare correttamente l'interleaving e la difficoltà di ragionare su di esso.

In un caso semplice in cui i grani sono senza stato e la logica è semplice, usando meno grani reentranti (ma non troppo pochi, assicurando che tutti i thread hardware siano utilizzati) dovrebbe in genere essere leggermente più efficiente.

Se il codice è più complesso, l'uso di un numero maggiore di grani non reentranti, anche se leggermente meno efficiente nel complesso, potrebbe risparmiarti problemi significativi nel debug di problemi di interleaving non evidenti.

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

Metodi di interleaving

Metodi di interfaccia grain contrassegnati con AlwaysInterleaveAttribute si intrecciano sempre con qualsiasi altra richiesta e possono sempre essere intrecciati da qualsiasi altra richiesta, anche richieste di 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 alternandosi, quindi il tempo di esecuzione totale delle due chiamate GoSlow è di circa 20 secondi. D'altra parte, GoFast è contrassegnato come AlwaysInterleaveAttribute. Le tre chiamate vengono eseguite simultaneamente, completando in totale circa 10 secondi anziché richiedere almeno 30 secondi.

Metodi di sola lettura

Quando un metodo granulare non modifica lo stato di granularità, è sicuro eseguire contemporaneamente ad altre richieste. Il ReadOnlyAttribute indica che un metodo non modifica lo stato del granulo. Contrassegnare i metodi come ReadOnly consente di Orleans elaborare la richiesta contemporaneamente ad altre ReadOnly richieste, che possono 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 GetCount metodo non modifica lo stato di granularità, quindi è contrassegnato come 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 un grano chiama un metodo su un altro grano, che a sua volta richiama nel grano originale, la chiamata risulta in un deadlock a meno che la chiamata non sia reentrante. È possibile abilitare reentrancy per ogni chiamata usando reentrancy della catena di chiamate. Per abilitare la reentrancy della catena di chiamate, eseguire il metodo AllowCallChainReentrancy(). Questo metodo restituisce un valore che consente la reentrance da qualsiasi chiamante più in basso nella catena di chiamate fino a quando il valore 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) chiama ChatRoomGrain.OnJoinRoom(user), che cerca di richiamare UserGrain.GetDisplayName() per ottenere il nome visualizzato dell'utente. Poiché questa catena di chiamate implica un ciclo, si verifica 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 di richiamare solo roomGrain in UserGrain. In questo modo è possibile avere un controllo dettagliato su dove e come è abilitata la reentrancy.

Se si dovesse impedire il deadlock annotando invece la dichiarazione del GetDisplayName() metodo su IUserGrain con [AlwaysInterleave], sarebbe possibile consentire a qualsiasi componente di interpolare una chiamata GetDisplayName con qualsiasi altro metodo. Usando AllowCallChainReentrancy, consenti di chiamare soloroomGrain i metodi su UserGrain, e solo fino a quando scope non viene eliminato.

Eliminare la reentrancy della catena di chiamate

È anche possibile sopprimere la reentrance della catena di chiamate utilizzando 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 grain, ad esempio i canali di broadcast e streaming, per garantire agli sviluppatori il controllo completo su quando abilitare la rientranza nella catena di chiamate.

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. Questo metodo accetta un InvokeMethodRequest oggetto e restituisce un bool che indica se la richiesta deve essere intercalata.

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