C # 5 async CTP: pourquoi «état» interne est-il mis à 0 dans le code généré avant l'appel EndAwait?

195

Hier, je faisais un exposé sur la nouvelle fonctionnalité C # "async", en particulier sur l'aspect du code généré et the GetAwaiter()/ BeginAwait()/ les EndAwait()appels.

Nous avons examiné en détail la machine d'état générée par le compilateur C #, et il y avait deux aspects que nous ne pouvions pas comprendre:

  • Pourquoi la classe générée contient une Dispose()méthode et une $__disposingvariable, qui ne semblent jamais être utilisées (et la classe ne l'implémente pas IDisposable).
  • Pourquoi la statevariable interne est définie sur 0 avant tout appel à EndAwait(), alors que 0 semble normalement signifier "c'est le point d'entrée initial".

Je soupçonne que le premier point pourrait être répondu en faisant quelque chose de plus intéressant dans la méthode asynchrone, bien que si quelqu'un a des informations supplémentaires, je serais heureux de l'entendre. Cette question concerne cependant davantage le deuxième point.

Voici un exemple de code très simple:

using System.Threading.Tasks;

class Test
{
    static async Task<int> Sum(Task<int> t1, Task<int> t2)
    {
        return await t1 + await t2;
    }
}

... et voici le code qui est généré pour la MoveNext()méthode qui implémente la machine d'état. Ceci est copié directement à partir de Reflector - je n'ai pas corrigé les noms de variables indescriptibles:

public void MoveNext()
{
    try
    {
        this.$__doFinallyBodies = true;
        switch (this.<>1__state)
        {
            case 1:
                break;

            case 2:
                goto Label_00DA;

            case -1:
                return;

            default:
                this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
                this.<>1__state = 1;
                this.$__doFinallyBodies = false;
                if (this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate))
                {
                    return;
                }
                this.$__doFinallyBodies = true;
                break;
        }
        this.<>1__state = 0;
        this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();
        this.<a2>t__$await4 = this.t2.GetAwaiter<int>();
        this.<>1__state = 2;
        this.$__doFinallyBodies = false;
        if (this.<a2>t__$await4.BeginAwait(this.MoveNextDelegate))
        {
            return;
        }
        this.$__doFinallyBodies = true;
    Label_00DA:
        this.<>1__state = 0;
        this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
        this.<>1__state = -1;
        this.$builder.SetResult(this.<1>t__$await1 + this.<2>t__$await3);
    }
    catch (Exception exception)
    {
        this.<>1__state = -1;
        this.$builder.SetException(exception);
    }
}

C'est long, mais les lignes importantes pour cette question sont les suivantes:

// End of awaiting t1
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

// End of awaiting t2
this.<>1__state = 0;
this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();

Dans les deux cas, l'état est à nouveau modifié par la suite avant d'être ensuite observé de manière évidente ... alors pourquoi le mettre à 0? Si j'étais MoveNext()appelé à nouveau à ce stade (soit directement, soit via Dispose), il redémarrerait effectivement la méthode asynchrone, ce qui serait tout à fait inapproprié pour autant que je sache ... si et MoveNext() n'est pas appelé, le changement d'état n'est pas pertinent.

Est-ce simplement un effet secondaire du compilateur réutilisant le code de génération de bloc d'itérateur pour async, où il peut avoir une explication plus évidente?

Avertissement important

Évidemment, ce n'est qu'un compilateur CTP. Je m'attends à ce que les choses changent avant la version finale - et peut-être même avant la prochaine version CTP. Cette question n'essaie en aucun cas de prétendre qu'il s'agit d'une faille dans le compilateur C # ou quelque chose comme ça. J'essaie juste de savoir s'il y a une raison subtile à cela que j'ai ratée :)

Jon Skeet
la source
7
Le compilateur VB produit une machine d'état similaire (je ne sais pas si c'est prévu ou non, mais VB n'avait pas de blocs d'itérateur auparavant)
Damien_The_Unbeliever
1
@Rune: MoveNextDelegate est juste un champ délégué qui fait référence à MoveNext. Il est mis en cache pour éviter de créer une nouvelle action à passer dans l'attente à chaque fois, je crois.
Jon Skeet
5
Je pense que la réponse est: c'est un CTP. Le bit d'ordre élevé pour l'équipe a été de diffuser cela et la conception du langage a été validée. Et ils l'ont fait incroyablement rapidement. Vous devez vous attendre à ce que l'implémentation fournie (des compilateurs, pas MoveNext) diffère considérablement. Je pense qu'Eric ou Lucian reviendront avec une réponse selon laquelle il n'y a rien de profond ici, juste un comportement / bug qui n'a pas d'importance dans la plupart des cas et que personne n'a remarqué. Parce que c'est un CTP.
Chris Burrows
2
@Stilgar: Je viens de vérifier avec ildasm, et c'est vraiment ce qui se passe.
Jon Skeet
3
@ JonSkeet: Remarquez comment personne ne vote les réponses. 99% d'entre nous ne savent pas vraiment si la réponse sonne juste.
the_drow

Réponses:

71

D'accord, j'ai enfin une vraie réponse. J'ai en quelque sorte réglé cela par moi-même, mais seulement après que Lucian Wischik de la partie VB de l'équipe ait confirmé qu'il y avait vraiment une bonne raison. Un grand merci à lui - et s'il vous plaît visitez son blog , qui est génial.

La valeur 0 ici est uniquement spéciale car ce n'est pas un état valide dans lequel vous pourriez être juste avant leawait dans un cas normal. En particulier, ce n'est pas un état que la machine d'état peut finir par tester ailleurs. Je crois que l'utilisation d'une valeur non positive fonctionnerait aussi bien: -1 n'est pas utilisé pour cela car il est logiquement incorrect, car -1 signifie normalement "terminé". Je pourrais dire que nous donnons un sens supplémentaire à l'état 0 pour le moment, mais en fin de compte, cela n'a pas vraiment d'importance. Le point de cette question était de savoir pourquoi l'État est mis en place.

La valeur est pertinente si l'attente se termine par une exception qui est interceptée. Nous pouvons finir par revenir à la même déclaration d'attente, mais nous ne devons pas être dans l'état, ce qui signifie «Je suis sur le point de revenir de cette attente» car sinon toutes sortes de codes seraient ignorés. Il est plus simple de montrer cela avec un exemple. Notez que j'utilise maintenant le deuxième CTP, donc le code généré est légèrement différent de celui de la question.

Voici la méthode asynchrone:

static async Task<int> FooAsync()
{
    var t = new SimpleAwaitable();

    for (int i = 0; i < 3; i++)
    {
        try
        {
            Console.WriteLine("In Try");
            return await t;
        }                
        catch (Exception)
        {
            Console.WriteLine("Trying again...");
        }
    }
    return 0;
}

Sur le plan conceptuel, le SimpleAwaitablepeut être tout à fait attendue - peut-être une tâche, peut-être autre chose. Aux fins de mes tests, il renvoie toujours false for IsCompletedet lève une exception dansGetResult .

Voici le code généré pour MoveNext:

public void MoveNext()
{
    int returnValue;
    try
    {
        int num3 = state;
        if (num3 == 1)
        {
            goto Label_ContinuationPoint;
        }
        if (state == -1)
        {
            return;
        }
        t = new SimpleAwaitable();
        i = 0;
      Label_ContinuationPoint:
        while (i < 3)
        {
            // Label_ContinuationPoint: should be here
            try
            {
                num3 = state;
                if (num3 != 1)
                {
                    Console.WriteLine("In Try");
                    awaiter = t.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        awaiter.OnCompleted(MoveNextDelegate);
                        return;
                    }
                }
                else
                {
                    state = 0;
                }
                int result = awaiter.GetResult();
                awaiter = null;
                returnValue = result;
                goto Label_ReturnStatement;
            }
            catch (Exception)
            {
                Console.WriteLine("Trying again...");
            }
            i++;
        }
        returnValue = 0;
    }
    catch (Exception exception)
    {
        state = -1;
        Builder.SetException(exception);
        return;
    }
  Label_ReturnStatement:
    state = -1;
    Builder.SetResult(returnValue);
}

J'ai dû bouger Label_ContinuationPointpour en faire un code valide - sinon ce n'est pas dans la portée de la gotodéclaration - mais cela n'affecte pas la réponse.

Pensez à ce qui se passe quand GetResultlève son exception. Nous allons parcourir le bloc catch, incrémenter i, puis boucler à nouveau (en supposant qu'il iest toujours inférieur à 3). Nous sommes toujours dans l'état où nous étions avant l' GetResultappel ... mais quand nous entrons dans le trybloc, nous devons imprimer "In Try" et rappeler GetAwaiter... et nous ne le ferons que si l'état n'est pas 1. Sans l' state = 0affectation, il utilisera l'attente existante et sautera l' Console.WriteLineappel.

C'est un morceau de code assez tortueux à travailler, mais cela ne fait que montrer le genre de choses auxquelles l'équipe doit penser. Je suis content de ne pas être responsable de la mise en œuvre de cela :)

Jon Skeet
la source
8
@Shekhar_Pro: Oui, c'est un goto. Vous devriez vous attendre à voir de nombreuses instructions goto dans les machines à états générées automatiquement :)
Jon Skeet
12
@Shekhar_Pro: Dans le code écrit manuellement, c'est - parce que cela rend le code difficile à lire et à suivre. Personne ne lit le code généré automatiquement, sauf les imbéciles comme moi qui le décompilent :)
Jon Skeet
Alors que se passe- t- il lorsque nous attendons à nouveau après une exception? Nous recommençons?
configurateur
1
@configurator: il appelle GetAwaiter sur l'attendu, ce que j'attends de lui.
Jon Skeet
les gotos ne rendent pas toujours le code plus difficile à lire. En fait, parfois, ils ont même un sens à utiliser (sacrilège pour dire, je sais). Par exemple, vous devrez parfois rompre plusieurs boucles imbriquées. La fonction la moins utilisée de goto (et une utilisation plus laide de l'OMI) est de provoquer la cascade des instructions de commutation. Sur une note distincte, je me souviens d'un jour et d'un âge où gotos était le fondement principal de certains langages de programmation et à cause de cela, je comprends parfaitement pourquoi la simple mention de goto fait frémir les développeurs. Ils peuvent rendre les choses moches s'ils sont mal utilisés.
Ben Lesh
5

si elle était maintenue à 1 (premier cas), vous obtiendriez un appel à EndAwaitsans appel à BeginAwait. Si elle est maintenue à 2 (deuxième cas), vous obtiendrez le même résultat sur l'autre serveur.

Je suppose que l'appel de BeginAwait renvoie false s'il a déjà été démarré (une supposition de mon côté) et conserve la valeur d'origine à renvoyer à EndAwait. Si tel est le cas, cela fonctionnerait correctement alors que si vous le définissez sur -1, vous pourriez avoir un fichier non initialiséthis.<1>t__$await1 pour le premier cas.

Cela suppose cependant que BeginAwaiter ne démarrera pas réellement l'action sur les appels après le premier et qu'il retournera false dans ces cas. Le démarrage serait bien sûr inacceptable car il pourrait avoir un effet secondaire ou simplement donner un résultat différent. Il suppose également que EndAwaiter retournera toujours la même valeur, peu importe combien de fois il est appelé et qui peut être appelé lorsque BeginAwait retourne faux (selon l'hypothèse ci-dessus)

Cela semblerait être une protection contre les conditions de concurrence. Si nous insérons les déclarations où movenext est appelé par un thread différent après l'état = 0 dans les questions, il ressemblera à ce qui suit

this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.<>1__state = 0;

//second thread
this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.$__doFinallyBodies = true;
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

//other thread
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

Si les hypothèses ci-dessus sont correctes, des travaux inutiles sont effectués, tels que get sawiater et la réaffectation de la même valeur à <1> t __ $ wait1. Si l'état était maintenu à 1, la dernière partie serait plutôt:

//second thread
//I suppose this un matched call to EndAwait will fail
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

en outre, si elle était définie sur 2, la machine d'état supposerait qu'elle a déjà obtenu la valeur de la première action qui serait fausse et une variable (potentiellement) non affectée serait utilisée pour calculer le résultat.

Rune FS
la source
Gardez à l'esprit que l'état n'est pas réellement utilisé entre l'affectation à 0 et l'affectation à une valeur plus significative. Si cela est destiné à se prémunir contre les conditions de course, je m'attendrais à ce qu'une autre valeur indique cela, par exemple -2, avec une vérification pour cela au début de MoveNext pour détecter une utilisation inappropriée. Gardez à l'esprit qu'une seule instance ne devrait en fait jamais être utilisée par deux threads à la fois - elle est destinée à donner l'illusion d'un seul appel de méthode synchrone qui parvient à "faire une pause" de temps en temps.
Jon Skeet
@Jon Je suis d'accord que cela ne devrait pas être un problème avec une condition de concurrence dans le cas asynchrone mais pourrait être dans le bloc d'itération et pourrait être un reste
Rune FS
@Tony: Je pense que j'attendrai la sortie du prochain CTP ou beta, et vérifierai ce comportement.
Jon Skeet
1

Serait-ce quelque chose à voir avec les appels asynchrones empilés / imbriqués? ..

c'est à dire:

async Task m1()
{
    await m2;
}

async Task m2()
{
    await m3();
}

async Task m3()
{
Thread.Sleep(10000);
}

Le délégué movenext est-il appelé plusieurs fois dans cette situation?

Juste un coup de pied vraiment?

GaryMcAllister
la source
Il y aurait trois classes générées différentes dans ce cas. MoveNext()serait appelé une fois sur chacun d'eux.
Jon Skeet
0

Explication des états réels:

états possibles:

  • 0 Initialisé (je pense que oui) ou en attente de fin d'opération
  • > 0 vient de s'appeler MoveNext, choisissant l'état suivant
  • -1 terminé

Est-il possible que cette implémentation veuille simplement garantir que si un autre appel à MoveNext d'où qu'il se produise (en attendant), il réévalue à nouveau toute la chaîne d'état depuis le début, pour réévaluer des résultats qui pourraient être entre-temps déjà dépassés?

fixagon
la source
Mais pourquoi voudrait-il recommencer depuis le début? Ce n'est certainement pas ce que vous voudriez réellement faire - vous voudriez qu'une exception soit levée, car rien d'autre ne devrait appeler MoveNext.
Jon Skeet