Partilhar via


Solicitar agendamento

As ativações de grãos têm um modelo de execução de thread único e, por padrão, processam cada solicitação do início à conclusão antes que a próxima solicitação possa começar a ser processada. Em algumas circunstâncias, pode ser desejável que a ativação processe outras solicitações enquanto uma solicitação aguarda a conclusão de uma operação assíncrona. Por esse e outros motivos, Orleans dá ao desenvolvedor algum controle sobre o comportamento de intercalação de solicitações, conforme descrito na seção Reentrancy . O que se segue é um exemplo de agendamento de solicitação não reentrante, que é o comportamento padrão no Orleans.

Considere a seguinte PingGrain definição:

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

Dois grãos do tipo PingGrain estão envolvidos em nosso exemplo, A e B. Um chamador invoca a seguinte chamada:

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

Diagrama de agendamento de reentrância.

O fluxo de execução é o seguinte:

  1. A chamada chega a A, que registra "1" e emite uma chamada para B.
  2. B retorna imediatamente de Ping() volta para A.
  3. Um registra "2" e retorna ao chamador original.

Enquanto A aguarda a chamada para B, ele não pode processar nenhuma solicitação de entrada. Como resultado, se A e B telefonarem um para o outro simultaneamente, eles podem ficar bloqueados enquanto aguardam a conclusão dessas chamadas. Aqui está um exemplo, com base no cliente que emite a seguinte chamada:

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: as chamadas não bloqueiam

Diagrama de agendamento de reentrância sem impasse.

Neste exemplo:

  1. A Ping() chamada de A chega a B antes de a CallOther(a) chamada chegar a B.
  2. Portanto, B processa a Ping() chamada antes da CallOther(a) chamada.
  3. Como B processa a Ping() chamada, A é capaz de retornar ao chamador.
  4. Quando B emite sua Ping() chamada para A, A ainda está ocupado registrando sua mensagem ("2"), então a chamada tem que esperar por uma curta duração, mas logo pode ser processada.
  5. A processa a Ping() chamada e retorna para B, que retorna ao chamador original.

Considere uma série de eventos menos afortunados: um em que o mesmo código resulta em um impasse devido a um tempo ligeiramente diferente.

Caso 2: o impasse das chamadas

Diagrama de agendamento de reentrância com deadlock.

Neste exemplo:

  1. As CallOther chamadas chegam aos seus respetivos grãos e são processadas simultaneamente.
  2. Ambos os grãos registram "1" e prosseguem para await other.Ping().
  3. Como ambos os grãos ainda estão ocupados (processando o CallOther pedido, que ainda não terminou), os Ping() pedidos aguardam
  4. Depois de um tempo, Orleans determina que a chamada atingiu o tempo limite e cada Ping() chamada resulta em uma exceção sendo lançada.
  5. O CallOther corpo do método não manipula a exceção e borbulha até o chamador original.

A seção a seguir descreve como evitar impasses permitindo que várias solicitações intercalem sua execução entre si.

Reentrada

Orleans O padrão é escolher um fluxo de execução seguro: aquele em que o estado interno de um grão não é modificado simultaneamente durante várias solicitações. A modificação simultânea do estado interno complica a lógica e coloca uma carga maior sobre o desenvolvedor. Essa proteção contra esses tipos de bugs de simultaneidade tem um custo, que foi discutido anteriormente, principalmente a vivacidade: certos padrões de chamada podem levar a impasses. Uma maneira de evitar impasses é garantir que as chamadas de grãos nunca resultem em um ciclo. Muitas vezes, é difícil escrever um código que seja livre de ciclos e não possa bloquear. Esperar que cada solicitação seja executada do início à conclusão antes de processar a próxima solicitação também pode prejudicar o desempenho. Por exemplo, por padrão, se um método grain executar alguma solicitação assíncrona para um serviço de banco de dados, o grain pausará a execução da solicitação até que a resposta do banco de dados chegue ao grão.

Cada um desses casos é discutido nas secções que se seguem. Por esses motivos, Orleans fornece aos desenvolvedores opções para permitir que algumas ou todas as solicitações sejam executadas simultaneamente, intercalando sua execução entre si. Na Orleans, tais preocupações são referidas como reentrância ou intercalação. Ao executar solicitações simultaneamente, os grãos que executam operações assíncronas podem processar mais solicitações em um período mais curto.

Vários pedidos podem ser intercalados nos seguintes casos:

Com a reentrância, o caso seguinte torna-se uma execução válida e a possibilidade do impasse acima é removida.

Caso 3: o grão ou método é reentrante

Diagrama de programação de reentrância com grão ou método de reentrada.

Neste exemplo, os grãos A e B podem ligar um para o outro simultaneamente sem qualquer potencial para bloqueios de agendamento de solicitação porque ambos os grãos são reentrantes. As seções a seguir fornecem mais detalhes sobre reentrancy.

Grãos reentrantes

As Grain classes de implementação podem ser marcadas com a indicação de ReentrantAttribute que diferentes pedidos podem ser livremente intercalados.

Em outras palavras, uma ativação de reentrada pode começar a executar outra solicitação enquanto uma solicitação anterior não tiver terminado o processamento. A execução ainda é limitada a um único thread, portanto, a ativação ainda está sendo executada um turno de cada vez, e cada turno é executado em nome de apenas uma das solicitações da ativação.

O código de grão reentrante nunca executa várias partes de código de grão em paralelo (a execução do código de grão é sempre de thread único), mas os grãos reentrantes podem ver a execução de código para diferentes solicitações intercaladas. Ou seja, os turnos de continuação de diferentes pedidos podem intercalar.

Por exemplo, como mostrado no pseudocódigo a seguir, considere que Foo e Bar são dois métodos da mesma classe de grãos:

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

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

Se este grão estiver marcado ReentrantAttribute, a execução de Foo e Bar pode intercalar.

Por exemplo, é possível a seguinte ordem de execução:

Linha 1, linha 3, linha 2 e linha 4. Ou seja, as voltas de diferentes pedidos se intercalam.

Se o grão não fosse reentrante, as únicas execuções possíveis seriam: linha 1, linha 2, linha 3, linha 4 OU: linha 3, linha 4, linha 1, linha 2 (uma nova solicitação não pode começar antes que a anterior termine).

A principal compensação na escolha entre grãos reentrantes e não reentrantes é a complexidade do código de fazer a intercalação funcionar corretamente e a dificuldade de raciocinar sobre isso.

Em um caso trivial, quando os grãos são apátridas e a lógica é simples, menos (mas não muito pouco, de modo que todos os threads de hardware são usados) grãos reentrantes devem, em geral, ser um pouco mais eficientes.

Se o código for mais complexo, então um número maior de grãos não reentrantes, mesmo que um pouco menos eficientes no geral, deve poupar muito sofrimento ao descobrir problemas de intercalação não óbvios.

No final, a resposta depende das especificidades da aplicação.

Métodos de intercalação

Os métodos de interface de grãos marcados com AlwaysInterleaveAttribute, sempre intercalam qualquer outra solicitação e sempre podem ser intercalados com qualquer outra solicitação, mesmo solicitações para métodos não-[AlwaysInterleave].

Considere o seguinte exemplo:

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

Considere o fluxo de chamadas iniciado pela seguinte solicitação do 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());

As chamadas para GoSlow não são intercaladas, pelo que o tempo total de execução das duas GoSlow chamadas demora cerca de 20 segundos. Por outro lado, GoFast é marcado AlwaysInterleaveAttribute, e as três chamadas para ele são executadas simultaneamente, completando em aproximadamente 10 segundos no total, em vez de exigir pelo menos 30 segundos para ser concluída.

Métodos somente leitura

Quando um método de grão não modifica o estado de grão, é seguro executar simultaneamente com outras solicitações. O ReadOnlyAttribute indica que um método não modifica o estado de um grão. Marcar métodos como ReadOnly permite Orleans processar sua solicitação simultaneamente com outras ReadOnly solicitações, o que pode melhorar significativamente o desempenho do seu aplicativo. Considere o seguinte exemplo:

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

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

Reentrância da cadeia de chamadas

Se um grão chama um método que em outro grão que então chama de volta para o grão original, a chamada resultará em um impasse, a menos que a chamada seja reentrante. A reentrância pode ser ativada por local de chamada usando a reentrância da cadeia de chamadas. Para habilitar a reentrância da cadeia de chamadas, chame o método, que retorna um valor que permite a AllowCallChainReentrancy() reentrada de qualquer chamador mais abaixo na cadeia de chamadas até que ele seja descartado. Isso inclui a reentrada do grão chamando o método em si. Considere o seguinte exemplo:

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

No exemplo anterior, UserGrain.JoinRoom(roomName) chama para ChatRoomGrain.OnJoinRoom(user), que tenta chamar de volta UserGrain.GetDisplayName() para obter o nome de exibição do usuário. Como essa cadeia de chamadas envolve um ciclo, isso resultará em um impasse se o não permitir a UserGrain reentrada usando qualquer um dos mecanismos suportados discutidos neste artigo. Neste caso, estamos usando AllowCallChainReentrancy()o , que permite apenas roomGrain chamar de volta para o UserGrain. Isso lhe concede um controle refinado sobre onde e como a reentrância é habilitada.

Se, em vez disso, você evitasse o impasse anotando a declaração de GetDisplayName() método com IUserGrain [AlwaysInterleave], permitiria que qualquer grão intercalasse uma GetDisplayName chamada com qualquer outro método. Em vez disso, você está permitindo apenas roomGrain chamar métodos em nosso grão e apenas até scope que seja descartado.

Suprimir a reentrância da cadeia de chamadas

A reentrada da cadeia de chamadas também pode ser suprimida usando o SuppressCallChainReentrancy() método. Isso tem utilidade limitada para os desenvolvedores finais, mas é importante para uso interno por bibliotecas que estendem Orleans a funcionalidade de grão, como canais de streaming e transmissão, para garantir que os desenvolvedores mantenham controle total sobre quando a reentrância da cadeia de chamadas está habilitada.

O GetCount método não modifica o estado de grão, por isso está marcado com ReadOnly. Os chamadores que aguardam essa invocação de método não são bloqueados por outras ReadOnly solicitações para o grão, e o método retorna imediatamente.

Reentrância usando um predicado

As classes de grãos podem especificar um predicado para determinar a intercalação chamada a chamada, inspecionando a solicitação. O [MayInterleave(string methodName)] atributo fornece essa funcionalidade. O argumento para o atributo é o nome de um método estático dentro da classe grain que aceita um InvokeMethodRequest objeto e retorna uma bool indicação se a solicitação deve ou não ser intercalada.

Aqui está um exemplo que permite a intercalação se o tipo de argumento request tiver o [Interleave] atributo:

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