Partage via


Planification des demandes

Les activations de grain s’appuient sur un modèle d’exécution à thread unique. Par défaut, elles traitent chaque demande du début jusqu’à la fin. Après quoi, le traitement de la demande suivante peut commencer. Dans certaines circonstances, il peut être souhaitable que l’activation traite d’autres demandes pendant qu’une demande attend la fin d’une opération asynchrone. Pour cette raison et pour d’autres, Orleans offre au développeur une certaine latitude quant au comportement d’entrelacement des demandes, comme indiqué dans la section Réentrance. Voici un exemple de planification de demandes non réentrantes, ce qui est le comportement par défaut dans Orleans.

Examinez la définition de PingGrain suivante :

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

Deux grains de type PingGrain sont utilisés dans notre exemple : A et B. Un appelant invoque l’appel suivant :

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

Diagramme de planification de la réentrance.

Le flux d’exécution est le suivant :

  1. L’appel parvient à A, qui journalise "1" et émet un appel vers B.
  2. B effectue un retour immédiat de Ping() vers A.
  3. A journalise "2" et le retourne à l’appelant d’origine.

Pendant que A attend l’appel à B, il ne peut traiter aucune demande entrante. Par conséquent, si A et B devaient s’appeler simultanément, un blocage pourrait se produire dans l’attente de l’achèvement de ces appels. Voici un exemple où le client émet l’appel suivant :

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

Cas 1 : les appels n’entraînent pas de blocage

Diagramme de planification de la réentrance sans interblocage.

Dans cet exemple :

  1. L’appel Ping() en provenance de A parvient à B avant que l’appel CallOther(a) parvienne à B.
  2. Par conséquent, B traite l’appel Ping() avant l’appel CallOther(a).
  3. Comme B traite l’appel Ping(), A peut retourner vers l’appelant.
  4. Quand B émet son appel Ping() vers A, A est toujours occupé à journaliser son message ("2"), si bien que l’appel doit attendre un bref instant, mais il est bientôt en capacité de le traiter.
  5. A traite l’appel Ping() et retourne vers B qui retourne vers l’appelant d’origine.

Examinons une série d’événements moins heureuse, où le même code entraîne un blocage en raison d’un timing légèrement différent.

Cas 2 : les appels entraînent un blocage

Diagramme de planification de la réentrance avec interblocage.

Dans cet exemple :

  1. Les appels CallOther parviennent à leur grain respectif et sont traités simultanément.
  2. Les deux grains journalisent "1" et passent à await other.Ping().
  3. Comme les deux grains sont toujours occupés (traitement de la demande CallOther, qui n’a pas encore abouti), les demandes Ping() attendent
  4. Après un certain temps, Orleans détermine que l’appel a expiré et que chaque appel Ping() peut entraîner la levée d’une exception.
  5. Le corps de la méthode CallOther ne gère pas l’exception et fait surface au niveau de l’appelant d’origine.

La section suivante explique comment empêcher les blocages en autorisant plusieurs demandes à entrelacer leur exécution conjointement.

Réentrance

Orleans choisit par défaut un flux d’exécution sûr, où l’état interne d’un grain n’est pas modifié simultanément pendant plusieurs demandes. La modification simultanée de l’état interne complique la logique et impose une charge plus importante au développeur. Cette protection contre ces types de bogues de concurrence a un coût, abordé précédemment, principalement sur le plan de l’activité : certains modèles d’appels peuvent aboutir à des blocages. Une façon d’éviter les blocages est de veiller à ce que les appels de grain ne créent jamais un cycle. Souvent, il est difficile d’écrire du code qui soit sans cycle et à l’abri des blocages. De même, le fait d’attendre que chaque demande s’exécute du début jusqu’à la fin avant de traiter la demande suivante peut nuire aux performances. Par exemple, par défaut, si une méthode de grain adresse une demande asynchrone à un service de base de données, le grain met l’exécution de la demande en suspens, le temps que la réponse de la base de données parvienne au grain.

Chacun de ces cas est examiné dans les sections suivantes. Pour ces raisons, Orleans propose aux développeurs des options permettant d’exécuter simultanément tout ou partie des demandes, de manière entrelacée. Dans Orleans, ces préoccupations sont appelées réentrances ou entrelacements. En exécutant simultanément les demandes, les grains qui effectuent des opérations asynchrones peuvent traiter plus de demandes sur une période plus courte.

L’entrelacement de plusieurs demandes est possible dans les cas suivants :

Avec la réentrance, le cas suivant devient une exécution valide et le risque de blocage évoqué plus haut disparaît.

Cas 3 : le grain ou la méthode est réentrant

Diagramme de planification de la réentrance avec un grain réentrant ou une méthode réentrance.

Dans cet exemple, les grains A et B peuvent s’appeler simultanément sans aucun risque de blocage de la planification des demandes, car les deux grains sont réentrants. Pour plus d’informations sur la réentrance, lisez les sections suivantes.

Grains réentrants

Les classes d’implémentation Grain peuvent être marquées avec ReentrantAttribute pour indiquer qu’il est possible d’entrelacer librement différentes demandes.

En d’autres termes, une activation réentrante peut commencer à exécuter une autre demande alors que le traitement d’une demande précédente n’est pas terminé. L’exécution étant toujours limitée à un thread, l’activation exécute toujours un tour à la fois, et chaque tour s’exécute pour le compte d’une seule des demandes de l’activation.

Le code de grain réentrant n’exécute jamais plusieurs éléments de code de grain en parallèle (l’exécution de code de grain est toujours à thread unique), mais les grains réentrants peuvent voir l’exécution de code pour différentes demandes entrelacées. Autrement dit, les tours de continuation de différentes demandes peuvent s’entrelacer.

Par exemple, comme indiqué dans le pseudo-code suivant, considérez que Foo et Bar sont deux méthodes de la même classe de grain :

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

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

Si ce grain est marqué ReentrantAttribute, l’exécution de Foo et Bar peut être entrelacée.

Par exemple, l’ordre d’exécution suivant est possible :

Ligne 1, ligne 3, ligne 2 et ligne 4. Ainsi, les tours de différentes demandes s’entrelacent.

Si le grain n’était pas réentrant, les seules exécutions possibles seraient : ligne 1, ligne 2, ligne 3, ligne 4 OU ligne 3, ligne 4, ligne 1, ligne 2 (aucune nouvelle demande ne peut pas commencer tant que la précédente n’a pas abouti).

Le principal arbitrage entre le choix de grains réentrants et le choix de grains non réentrants est la complexité du code pour faire fonctionner correctement l’entrelacement et la difficulté à raisonner sur la question.

Dans un cas simple où les grains sont sans état et où la logique est simple, un nombre limité (mais pas trop non plus afin que tous les threads matériels soient utilisés) de grains réentrants doit, en principe, s’avérer légèrement plus efficace.

Pour un code plus complexe, un plus grand nombre de grains non réentrants, même s’ils sont globalement un peu moins efficaces, devrait vous dispenser de la difficulté à identifier des problèmes d’entrelacement non évidents.

Au final, la décision dépend des spécificités de l’application.

Méthodes d’entrelacement

Les méthodes d’interface de grain marquées avec AlwaysInterleaveAttribute entrelacent toujours toute autre demande et peuvent toujours être entrelacées avec une autre demande, même les demandes pour les méthodes non-[AlwaysInterleave].

Prenons l’exemple suivant :

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

Examinons le flux d’appels initié par la demande de client suivante :

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

Les appels à GoSlow ne sont pas entrelacés. Par conséquent, le temps d’exécution total des deux appels GoSlow prend environ 20 secondes. En revanche, GoFast est marqué AlwaysInterleaveAttribute, et les trois appels à celui-ci s’exécutent simultanément et aboutissent en 10 secondes environ et non en 30 secondes au minimum.

Méthodes en lecture seule

Lorsqu’une méthode de grain ne modifie pas l’état du grain, elle peut s’exécuter en toute sécurité avec d’autres demandes. ReadOnlyAttribute indique qu’une méthode ne modifie pas l’état d’un grain. Le marquage des méthodes avec ReadOnly permet à Orleans de traiter votre demande simultanément avec d’autres demandes ReadOnly, ce qui peut améliorer considérablement les performances de votre application. Prenons l’exemple suivant :

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

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

Réentrance de la chaîne d’appels

Si un grain appelle une méthode sur un autre grain qui revient ensuite dans le grain d’origine, l’appel entraîne un interblocage, sauf si l’appel est réentrant. La réentrance peut être activée sur une base par site d’appel à l’aide de la réentrance de la chaîne d’appels. Pour activer la réentrance de la chaîne d’appels, appelez la méthode AllowCallChainReentrancy(), qui retourne une valeur qui autorise la réentrance de n’importe quel appelant plus bas dans la chaîne d’appels jusqu’à ce qu’elle soit supprimée. Cela inclut la réentrance du grain appelant la méthode lui-même. Prenons l’exemple suivant :

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

Dans l’exemple précédent, UserGrain.JoinRoom(roomName) appelle dans ChatRoomGrain.OnJoinRoom(user), qui essaie de rappeler dans UserGrain.GetDisplayName() pour obtenir le nom d’affichage de l’utilisateur. Étant donné que cette chaîne d’appels implique un cycle, cela entraîne un blocage si UserGrain n’autorise pas la réentrance à l’aide des mécanismes pris en charge décrits dans cet article. Dans cette instance, nous utilisons AllowCallChainReentrancy(), qui permet uniquement à roomGrain de rappeler dans UserGrain. Cela vous donne un contrôle précis sur l’emplacement et la façon dont la réentrance est activée.

Si vous deviez à la place empêcher l’interblocage en annotant la déclaration de méthode GetDisplayName() sur IUserGrain avec [AlwaysInterleave], vous autoriseriez n’importe quel grain à entrelacer un appel GetDisplayName avec n’importe quelle autre méthode. Au lieu de cela, vous autorisez uniquement roomGrain à appeler des méthodes sur notre grain et seulement jusqu’à la suppression de scope.

Supprimer la réentrance de la chaîne d’appels

La réentrance de la chaîne d’appels peut également être supprimée à l’aide de la méthode SuppressCallChainReentrancy(). Cette méthode a une utilité limitée pour les développeurs finaux, mais elle est importante pour une utilisation interne par les bibliothèques qui étendent les fonctionnalités de grain Orleans, telles que la diffusion en continu et les canaux de diffusion afin de garantir que les développeurs conservent un contrôle total sur le moment où la réentrance de la chaîne d’appels est activée.

La méthode GetCount ne modifie pas l’état du grain. Elle est donc marquée avec ReadOnly. Les appelants qui attendent cet appel de méthode ne sont pas bloqués par d’autres demandes ReadOnly adressées au grain, et la méthode retourne immédiatement.

Réentrance avec prédicat

Les classes de grain peuvent spécifier un prédicat pour déterminer l’entrelacement appel par appel en inspectant la demande. L’attribut [MayInterleave(string methodName)] fournit cette fonctionnalité. L’argument de l’attribut est le nom d’une méthode statique dans la classe de grain qui accepte un objet InvokeMethodRequest et retourne un bool indiquant si la demande doit être entrelacée ou non.

Voici un exemple qui autorise l’entrelacement si le type d’argument de la demande contient l’attribut [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.
    }
}