Programación de solicitudes
Las activaciones de grano tienen un modelo de ejecución de un solo subproceso y procesan cada solicitud de principio a fin antes de que la siguiente solicitud pueda comenzar a procesarse de forma predeterminada. En algunas circunstancias, puede ser conveniente que la activación procese otras solicitudes mientras una solicitud está esperando a que se complete una operación asincrónica. Por este y otros motivos, Orleans proporciona al desarrollador cierto control sobre el comportamiento de intercalación de solicitudes, como se describe en la sección Reentrada. A continuación, se muestra un ejemplo de programación de solicitudes no reentrantes, que es el comportamiento predeterminado en Orleans.
Considere la siguiente definición de PingGrain
:
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");
}
}
Dos granos de tipo PingGrain
están implicados en nuestro ejemplo, A y B. Un autor de llamada invoca la siguiente llamada:
var a = grainFactory.GetGrain("A");
var b = grainFactory.GetGrain("B");
await a.CallOther(b);
El flujo de ejecución es el siguiente:
- La llamada llega a A, que registra
"1"
y, a continuación, emite una llamada a B. - B vuelve inmediatamente de
Ping()
a A. - A registra
"2"
y vuelve al autor de la llamada original.
Mientras A está esperando la llamada a B, no puede procesar ninguna solicitud entrante. Como resultado, si A y B se llamaran el uno al otro de forma simultánea, pueden interbloquearse mientras están esperando a que se completen esas llamadas. Este ejemplo se basa en el cliente que emite la siguiente llamada:
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: las llamadas no se interbloquean
En este ejemplo:
- La llamada
Ping()
de A llega a B antes de que la llamadaCallOther(a)
llegue a B. - Por tanto, B procesa la llamada
Ping()
antes de la llamadaCallOther(a)
. - Dado que B procesa la llamada
Ping()
, A puede volver al autor de la llamada. - Cuando B emite la llamada
Ping()
a A, A sigue ocupado registrando su mensaje ("2"
), por lo que la llamada tiene que esperar un poco, pero pronto se podrá procesar. - A procesa la llamada
Ping()
y vuelve a B, que vuelve al autor de la llamada original.
Considere una serie de eventos menos afortunados: uno en el que el mismo código produce un interbloqueo debido a una sincronización ligeramente diferente.
Caso 2: interbloqueo de llamadas
En este ejemplo:
- Las llamadas
CallOther
llegan a sus respectivos granos y se procesan de forma simultánea. - Ambos granos registran
"1"
y continúan conawait other.Ping()
. - Dado que ambos granos siguen ocupados (procesando la solicitud
CallOther
, que aún no ha finalizado), las solicitudesPing()
esperan. - Al cabo de un tiempo, Orleans determina que la llamada ha agotado el tiempo de espera y cada llamada a
Ping()
produce una excepción. - El cuerpo del método
CallOther
no controla la excepción y aparece en el autor de la llamada original.
En la sección siguiente se describe cómo evitar interbloqueos al permitir que varias solicitudes intercalen su ejecución entre sí.
Reentrada
Orleans tiene como valor predeterminado elegir un flujo de ejecución seguro: uno en el que el estado interno de un grano no se modifica simultáneamente durante varias solicitudes. La modificación simultánea del estado interno complica la lógica y supone una mayor carga para el desarrollador. Esta protección frente a esos tipos de errores de simultaneidad conlleva un coste que ya hemos mencionado, principalmente la actividad: ciertos patrones de llamada pueden provocar interbloqueos. Una forma de evitar interbloqueos consiste en asegurarse de que las llamadas de grano nunca provoquen un ciclo. A menudo, resulta difícil escribir código sin ciclo y que no pueda interbloquearse. Esperar a que cada solicitud se ejecute de principio a fin antes de procesar la siguiente solicitud también puede perjudicar el rendimiento. Por ejemplo, si un método de grano realiza alguna solicitud asincrónica a un servicio de base de datos, el grano pausa de forma predeterminada la ejecución de solicitudes hasta que la respuesta de la base de datos llegue al grano.
Cada uno de esos casos se describe en las secciones siguientes. Por estos motivos, Orleans proporciona a los desarrolladores opciones para permitir que algunas o todas las solicitudes se ejecuten simultáneamente, al intercalar su ejecución entre sí. En Orleans, esto se conoce como reentrada o intercalación. Al ejecutar solicitudes de forma simultánea, los granos que realizan operaciones asincrónicas pueden procesar más solicitudes en un período más corto.
Se podrán intercalar múltiples solicitudes en los siguientes casos:
- La clase de grano se marca con ReentrantAttribute.
- El método de interfaz se marca con AlwaysInterleaveAttribute.
- El predicado MayInterleaveAttribute del grano devuelve
true
.
Con la reentrada, el siguiente caso se convierte en una ejecución válida y se elimina la posibilidad del interbloqueo anterior.
Caso 3: el grano o el método es reentrante
En este ejemplo, los granos A y B se pueden llamar entre sí de forma simultánea sin posibilidad de interbloqueos de programación de solicitudes porque ambos granos son reentrantes. En las siguientes secciones se proporciona más información sobre la reentrada.
Granos reentrantes
Las clases de implementación Grain se pueden marcar con ReentrantAttributepara indicar que las diferentes solicitudes se pueden intercalar libremente.
Es decir, una activación reentrante podría empezar a ejecutar otra solicitud mientras una solicitud anterior no ha terminado de procesarse. La ejecución todavía se limita a un único subproceso, por lo que la activación sigue ejecutándose de un turno a otro y cada turno se ejecuta en nombre de solo una de las solicitudes de activación.
El código de grano reentrante nunca ejecuta varios fragmentos de código de grano en paralelo (la ejecución del código de grano siempre es de un solo subproceso), pero los granos reentrantes podrían ver la ejecución de código para la intercalación de solicitudes diferentes. Es decir, los turnos de continuación de diferentes solicitudes pueden intercalarse.
Por ejemplo, como se muestra en el seudocódigo siguiente, tenga en cuenta que Foo
y Bar
son dos métodos de la misma clase de grano:
Task Foo()
{
await task1; // line 1
return Do2(); // line 2
}
Task Bar()
{
await task2; // line 3
return Do2(); // line 4
}
Si este grano está marcado con ReentrantAttribute, la ejecución de Foo
y Bar
podría intercalarse.
Por ejemplo, es posible el siguiente orden de ejecución:
Línea 1, línea 3, línea 2 y línea 4. Es decir, los turnos de las distintas solicitudes se intercalan.
Si el grano no fuera reentrante, las únicas ejecuciones posibles serían: línea 1, línea 2, línea 3, línea 4, o bien línea 3, línea 4, línea 1, línea 2 (no se puede iniciar una nueva solicitud antes de que finalice la anterior).
La principal desventaja de elegir entre granos reentrantes y no reentrantes es la complejidad del código para que la intercalación funcione correctamente, así como la dificultad para razonar al respecto.
En un caso común, cuando los granos no tienen estado y la lógica es sencilla, en general un número menor de granos reentrantes (pero sin que sean demasiados pocos, de modo que se usen todos los subprocesos de hardware) debería ser ligeramente más eficaz.
Si el código es más complejo, un número mayor de granos no reentrantes (aunque esto sea ligeramente menos eficaz en general) debería ahorrarle mucho trabajo a la hora de detectar problemas de intercalación que no son obvios.
Al final, la respuesta depende de los detalles de la aplicación.
Métodos de intercalación
Los métodos de interfaz de grano marcados con AlwaysInterleaveAttribute siempre intercalarán cualquier otra solicitud y se pueden intercalar con cualquier otra solicitud, incluso las solicitudes de métodos que no son [AlwaysInterleave].
Considere el ejemplo siguiente:
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));
}
}
Tenga en cuenta el flujo de llamadas que inicia la siguiente solicitud de cliente:
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());
Las llamadas a GoSlow
no se intercalan, por lo que el tiempo total de ejecución de las dos llamadas GoSlow
tarda aproximadamente 20 segundos. Por otro lado, GoFast
está marcado como AlwaysInterleaveAttribute, y las tres llamadas se ejecutan de forma simultánea y se completan en aproximadamente 10 segundos en total, en lugar de requerir al menos 30 segundos para completarse.
Métodos de solo lectura
Cuando un método de grano no modifica el estado de grano, es seguro que se ejecute simultáneamente con otras solicitudes. ReadOnlyAttribute indica que un método no modifica el estado de un grano. El hecho de marcar métodos como ReadOnly
permite a Orleans procesar la solicitud simultáneamente con otras solicitudes ReadOnly
, lo que puede mejorar considerablemente el rendimiento de la aplicación. Considere el ejemplo siguiente:
public interface IMyGrain : IGrainWithIntegerKey
{
Task<int> IncrementCount(int incrementBy);
[ReadOnly]
Task<int> GetCount();
}
Reentrada de la cadena de llamadas
Si un grano llama a un método que está en otro grano y luego vuelve a llamar al grano original, la llamada resultará en un punto muerto a menos que la llamada sea reentrante. La reentrada se puede habilitar por sitio de llamada mediante la reentrada de la cadena de llamadas. Para habilitar el reentrada de la cadena de llamadas, llame al método AllowCallChainReentrancy(), que devuelve un valor que permite el reentrada desde cualquier persona que llama más abajo en la cadena de llamadas hasta que se elimine. Esto incluye la reentrada desde el grano llamando al método en sí. Considere el ejemplo siguiente:
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>());
}
}
En el ejemplo anterior, UserGrain.JoinRoom(roomName)
llama a ChatRoomGrain.OnJoinRoom(user)
, que intenta volver a llamar a UserGrain.GetDisplayName()
para obtener el nombre para mostrar del usuario. Dado que esta cadena de llamadas implica un ciclo, se producirá un interbloqueo si el UserGrain
no permite la reentrada mediante ninguno de los mecanismos admitidos descritos en este artículo. En este caso, se usa AllowCallChainReentrancy(), que solo permite a roomGrain
volver a llamar al UserGrain
. Esto le otorga un control detallado sobre dónde y cómo se habilita la reentrada.
Si, en cambio, evitara el interbloqueo anotando la declaración del método GetDisplayName()
en IUserGrain
con [AlwaysInterleave]
, permitiría que cualquier grano intercalara una llamada de GetDisplayName
con cualquier otro método. En su lugar, soloroomGrain
permite llamar a métodos en nuestro grano y solo hasta que scope
se elimine.
Suprimir la reentrada de la cadena de llamadas
La reentrada de la cadena de llamadas también se puede suprimir utilizando el método SuppressCallChainReentrancy(). Esto tiene una utilidad limitada para los desarrolladores finales, pero es importante para el uso interno de las bibliotecas que amplían la funcionalidad de grano Orleans, como los canales de transmisión y difusión, para garantizar que los desarrolladores mantengan el control total sobre cuándo se habilita la reentrada de la cadena de llamadas.
El método GetCount
no modifica el estado de grano, por lo que se marca con ReadOnly
. Los autores de llamadas que esperan esta invocación de método no quedan bloqueados por otras solicitudes ReadOnly
al grano, y el método se devuelve inmediatamente.
Reentrada mediante un predicado
Las clases de grano pueden especificar un predicado para determinar la intercalación llamada por llamada mediante la inspección de la solicitud. El atributo [MayInterleave(string methodName)]
proporciona esta funcionalidad. El argumento del atributo es el nombre de un método estático dentro de la clase de grano que acepta un objeto InvokeMethodRequest y devuelve un valor bool
que indica si la solicitud debe intercalarse o no.
En este ejemplo se permite intercalar si el tipo de argumento de solicitud tiene el atributo [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.
}
}