Compartilhar via


Agendamento de solicitação

As ativações de granularidade 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 comece o processamento. 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, o Orleans fornece ao desenvolvedor algum controle sobre o comportamento de intercalação de solicitação, conforme descrito na seção Reentrada. O exemplo a seguir mostra um agendamento de solicitação sem reentrada, que é o comportamento padrão no Orleans.

Considere a definição PingGrain a seguir:

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

Duas granularidades de tipo PingGrain estão envolvidas 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 reentrada.

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() de 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 se ligassem simultaneamente, eles poderão ficar em deadlock enquanto esperam que essas chamadas sejam concluídas. 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 que não são deadlock

Diagrama de agendamento de reentrada sem deadlock.

Neste exemplo:

  1. A chamada Ping() de A chega a B antes que a chamada CallOther(a) chegue a B.
  2. Portanto, B processa a chamada Ping() antes da chamada CallOther(a).
  3. Como B processa a chamada Ping(), A é capaz de retornar ao chamador.
  4. Quando B emite sua chamada Ping() para A, A ainda está ocupado registrando sua mensagem ("2"), portanto, a chamada tem que esperar por um breve período, mas logo poderá ser processada.
  5. A processa a chamada Ping() e retorna para B, que retorna ao chamador original.

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

Caso 2: o deadlock de chamadas

Diagrama de agendamento de reentrada com deadlock.

Neste exemplo:

  1. As chamadas CallOther chegam à respectivas granularidades e são processadas simultaneamente.
  2. As duas granularidades registram "1" e prosseguem para await other.Ping().
  3. Como as duas granularidades ainda estão ocupadas (processando a solicitação CallOther, que ainda não foi concluída), as solicitações Ping() aguardam
  4. Após certo período, o Orleans determina que a chamada atingiu o tempo limite e cada chamada Ping() resulta na geração de uma exceção.
  5. O corpo do método CallOther não lida com a exceção e ele se propaga até o chamador original.

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

Reentrada

O Orleans usa como padrão escolher um fluxo de execução seguro: um em que o estado interno de uma granularidade não é modificado simultaneamente por várias solicitações. A modificação simultânea do estado interno complica a lógica e coloca uma carga maior no desenvolvedor. Essa proteção contra esses tipos de bugs de simultaneidade tem o custo que discutimos anteriormente, principalmente a atividade: determinados padrões de chamada podem levar a deadlocks. Uma maneira de evitar deadlocks é garantir que as chamadas de granularidade nunca resultem em um ciclo. Muitas vezes, é difícil escrever um código que seja livre de ciclo e não possa ser deadlock. Aguardar cada solicitação ser 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 de granularidade executar alguma solicitação assíncrona para um serviço de banco de dados, a granularidade pausará a execução da solicitação até que a resposta do banco de dados chegue à granularidade.

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

Várias solicitações podem ser intercaladas nos seguintes casos:

Com a reentrada, o caso a seguir se torna uma execução válida e a possibilidade do deadlock acima é removida.

Caso 3: a granularidade ou método é de reentrada

Diagrama de agendamento de reentrada com granularidade ou método com reentrada.

Neste exemplo, as granularidades A e B podem se chamar simultaneamente sem qualquer potencial de solicitação de deadlocks de agendamento porque as duas são de reentrada. As seções a seguir apresentam mais detalhes sobre reentrada.

Granularidades de reentrada

As classes de implementação Grain podem ser marcadas com o ReentrantAttribute para indicar que diferentes solicitações podem ser livremente intercaladas.

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 está limitada a um único thread, portanto, a ativação ainda está executando uma curva por vez e cada turno está sendo executada em nome de apenas uma das solicitações da ativação.

O código de granularidade de reentrada nunca executará várias partes do código de granularidade em paralelo (a execução do código de granularidade sempre será de thread único), mas as granularidades de reentrada podem ver a execução do código de diferentes solicitações de intercalação. Ou seja, a continuação é derivada de solicitações diferentes que podem se intercalar.

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

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

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

Se essa granularidade for marcada com ReentrantAttribute, a execução de Foo e Bar poderá se intercalar.

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

Linha 1, linha 3, linha 2 e linha 4. Ou seja, a vez de solicitações diferentes são intercaladas.

Se a granularidade não fosse de reentrada, 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 ser iniciada antes da finalização da anterior).

A principal compensação na escolha entre granularidades que são de reentrada e que não são de reentrada é a complexidade do código para fazer a intercalação funcionar corretamente e a dificuldade de interpretar isso.

Em um caso trivial quando as granularidades são sem estado e a lógica é simples, menos granularidades de reentradas devem, em geral, ser um pouco mais eficientes (mas não muito poucos, de modo que todos os threads de hardware sejam usados).

Se o código for mais complexo, um número maior de granularidades que não são de reentrada, mesmo que um pouco menos eficientes no geral, deverá prevenir muitos problemas de intercalação não óbvios.

No final, a resposta dependerá das especificidades do aplicativo.

Métodos de intercalação

Os métodos de interface de granularidade marcados com AlwaysInterleaveAttribute sempre intercalarão qualquer outra solicitação e sempre poderão ser intercalados com qualquer outra solicitação, até 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 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());

As chamadas para GoSlow não são intercaladas, portanto, o tempo total de execução das duas chamadas GoSlow leva cerca de 20 segundos. Por outro lado, GoFast está marcado com AlwaysInterleaveAttribute, e as três chamadas a ela serão executadas simultaneamente e são concluídas em aproximadamente 10 segundos no total, em vez de exigir pelo menos 30 segundos para serem concluídas.

Métodos readonly

Quando um método de granularidade não modifica o estado de granularidade, é seguro executar simultaneamente com outras solicitações. O ReadOnlyAttribute indica que um método não modifica o estado de uma granularidade. Marcar métodos como ReadOnly permite que o Orleans processe sua solicitação simultaneamente com outras solicitações ReadOnly, 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 está 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 reentrada pode ser habilitada por site de chamada usando reentrada em cadeia de chamadas. Para habilitar a reentrada da cadeia de chamadas, chame o método AllowCallChainReentrancy(), que retorna um valor que permite a reentrada de qualquer chamador mais abaixo na cadeia de chamadas até que ele seja descartado. Isso inclui a reentrada do grão que chama o próprio método. 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 into ChatRoomGrain.OnJoinRoom(user), que tenta chamar de volta UserGrain.GetDisplayName() para obter o nome de exibição do usuário. Uma vez que esta cadeia de chamadas envolve um ciclo, isto resultará num impasse se o UserGrain não permitir a reentrada utilizando qualquer um dos mecanismos suportados discutidos neste artigo. Neste caso, estamos usando AllowCallChainReentrancy(), que permite apenas roomGrain chamar de volta para o UserGrain. Isso concede a você um controle refinado sobre onde e como a reentrada é habilitada.

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

Suprimir a reentrada da cadeia de chamadas

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

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

Reentrada usando um predicado

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

Aqui está um exemplo que permite a intercalação se o tipo de argumento de solicitação tiver o 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.
    }
}