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);
Il flusso di esecuzione è il seguente:
- La chiamata arriva ad A, che registra
"1"
e quindi invia una chiamata a B. -
B torna immediatamente da
Ping()
a A. -
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
In questo esempio:
- La chiamata
Ping()
da A arriva a B prima che la chiamataCallOther(a)
arrivi a B. - Pertanto, B elabora la chiamata
Ping()
prima della chiamataCallOther(a)
. - Poiché B elabora la chiamata
Ping()
, A è in grado di tornare al chiamante. - 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. -
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
In questo esempio:
- Le chiamate
CallOther
arrivano ai rispettivi grani e vengono elaborate contemporaneamente. - Entrambi le granularità registrano
"1"
e procedono aawait other.Ping()
. - Poiché entrambi i grani sono ancora occupati (elaborazione della richiesta
CallOther
, che non è ancora stata completata), le richiestePing()
sono in attesa - Dopo un po' di tempo, Orleans determina che la chiamata è scaduta e ogni chiamata
Ping()
genera un'eccezione. - 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:
- La classe granulare è contrassegnata con ReentrantAttribute.
- Il metodo di interfaccia è contrassegnato con AlwaysInterleaveAttribute.
- Il predicato MayInterleaveAttribute della granularità restituisce
true
.
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
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.
}
}