web-dev-qa-db-fra.com

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

Hier, je parlais de la nouvelle fonctionnalité C # "async", en particulier en fouillant à quoi ressemblait le code généré, et the GetAwaiter()/BeginAwait()/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 méthode Dispose() et une variable $__disposing, Qui ne semblent jamais être utilisées (et la classe n'implémente pas IDisposable).
  • Pourquoi la variable interne state 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 méthode MoveNext() 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 MoveNext() était à nouveau appelé à ce stade (directement ou via Dispose), cela redémarrerait effectivement la méthode async, 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 déterminer s'il y a une raison subtile à cela que j'ai ratée :)

190
Jon Skeet

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

La valeur 0 ici est uniquement spéciale car ce n'est pas un état valide dans lequel vous pourriez être juste avant le await 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 c'est logiquement incorrect, comme -1 normalement signifie "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 instruction d'attente, mais nous ne devons pas être dans l'état signifiant "Je suis sur le point de revenir de cette attente ", sinon tous les types de code 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;
}

Conceptuellement, le SimpleAwaitable peut être tout ce qui est attendable - peut-être une tâche, peut-être autre chose. Aux fins de mes tests, il renvoie toujours false pour IsCompleted, et lève une exception dans GetResult.

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

Je devais bouger Label_ContinuationPoint pour le rendre valide - sinon ce n'est pas dans la portée de l'instruction goto - mais cela n'affecte pas la réponse.

Réfléchissez à ce qui se passe lorsque GetResult lève son exception. Nous allons parcourir le bloc catch, incrémenter i, puis boucler à nouveau (en supposant que i est toujours inférieur à 3). Nous sommes toujours dans l'état où nous étions avant l'appel GetResult ... mais quand nous entrons dans le bloc try nous devons affichez "In Try" et appelez GetAwaiter à nouveau ... et nous ne le ferons que si l'état n'est pas 1. Sans le state = 0 affectation, il utilisera l'attente existante et ignorera Console.WriteLine appel.

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

70
Jon Skeet

s'il était maintenu à 1 (premier cas), vous obtiendriez un appel à EndAwait sans appel à BeginAwait. Si elle est maintenue à 2 (deuxième cas), vous obtiendrez le même résultat uniquement 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 à -1, vous pourriez avoir un this.<1>t__$await1 Non initialisé 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 quel que soit le nombre de fois où il est appelé et qui peut être appelé lorsque BeginAwait retourne false (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.

5
Rune FS

Cela pourrait-il être lié aux 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?

1
GaryMcAllister

Explication des états réels:

états possibles:

  • Initialisé (je pense que oui) o en attente de fin d'opération
  • > juste appelé 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 arrive (en attendant), il réévalue à nouveau toute la chaîne d'état depuis le début, pour réévaluer les résultats qui pourraient être dans l'intervalle) déjà dépassé?

0
fixagon