Pourquoi est Enumerator.MoveNext ne fonctionne pas comme je l'attends lorsqu'il est utilisé avec using et async-await?

Je voudrais énumérer un List<int> et appeler une méthode asynchrone.

, Si je fais cela de cette façon:

public async Task NotWorking() {
  var list = new List<int> {1, 2, 3};

  using (var enumerator = list.GetEnumerator()) {
    Trace.WriteLine(enumerator.MoveNext());
    Trace.WriteLine(enumerator.Current);

    await Task.Delay(100);
  }
}

Le résultat est:

True
0

Mais je m'attends à ce que ce soit:

True
1

Si je supprime le using ou le await Task.Delay(100):

public void Working1() {
  var list = new List<int> {1, 2, 3};

  using (var enumerator = list.GetEnumerator()) {
    Trace.WriteLine(enumerator.MoveNext());
    Trace.WriteLine(enumerator.Current);
  }
}

public async Task Working2() {
  var list = new List<int> {1, 2, 3};

  var enumerator = list.GetEnumerator();
  Trace.WriteLine(enumerator.MoveNext());
  Trace.WriteLine(enumerator.Current);

  await Task.Delay(100);
}

La sortie est comme prévu:

True
1

Quelqu'un peut-il m'expliquer ce comportement?

21
demandé sur Peter Punzenberger 2015-03-24 13:50:19

2 réponses

Voici le court de ce problème. Une explication plus longue suit.

  • List<T>.GetEnumerator() renvoie une structure, un type de valeur.
  • Cette structure est mutable (toujours une recette pour le désastre)
  • lorsque le using () {} est présent, la structure est stockée dans un champ de la classe générée sous-jacente pour gérer la partie await.
  • lorsque vous appelez .MoveNext() à travers ce champ, une copie de la valeur du champ est chargée à partir de l'objet sous-jacent, c'est donc comme si MoveNext était jamais appelé quand le code lit .Current

Comme Marc l'a mentionné dans les commentaires, Maintenant que vous connaissez le problème, un simple "correctif" est de réécrire le code pour boxer explicitement la structure, cela fera en sorte que la structure mutable soit la même utilisée partout dans ce code, au lieu de nouvelles copies mutées partout.

using (IEnumerator<int> enumerator = list.GetEnumerator()) {

Donc, ce qui se passe vraiment ici.

Le async / await la nature d'une méthode un peu les choses à une méthode. Plus précisément, la méthode entière est soulevée sur une nouvelle classe générée et transformée en une machine d'état.

Partout où vous voyez await, la méthode est en quelque sorte "divisée" de sorte que la méthode doit être exécutée comme ceci:

  1. appelez la partie initiale, jusqu'à la première attente
  2. la partie suivante devra être gérée par un MoveNext un peu comme un IEnumerator
  3. la partie suivante, le cas échéant, et toutes les parties suivantes, sont toutes traitées par cette MoveNext Partie

Ce MoveNext méthode est générée sur cette classe, et le code de la méthode d'origine est placé à l'intérieur, au coup par coup pour s'adapter aux différents sequencepoints dans la méthode.

En tant que telle, toutes les variables locales de la méthode doivent survivre d'un appel à cette méthode MoveNext à l'autre, et elles sont "levées" sur cette classe en tant que champs privés.

La classe de l'exemple peut alors très simpliste être réécrite à quelque chose comme ceci:

public class <NotWorking>d__1
{
    private int <>1__state;
    // .. more things
    private List<int>.Enumerator enumerator;

    public void MoveNext()
    {
        switch (<>1__state)
        {
            case 0:
                var list = new List<int> {1, 2, 3};
                enumerator = list.GetEnumerator();
                <>1__state = 1;
                break;

            case 1:
                var dummy1 = enumerator;
                Trace.WriteLine(dummy1.MoveNext());
                var dummy2 = enumerator;
                Trace.WriteLine(dummy2.Current);
                <>1__state = 2;
                break;

Ce code est loin du code correct , mais assez proche à cet effet.

Le problème ici est ce deuxième cas. Pour une raison quelconque, le code généré lit ce champ comme une copie, et non comme une référence au champ. En tant que tel, l'appel à .MoveNext() se fait sur cette copie. La valeur du champ d'origine est laissée telle quelle, donc lorsque .Current est lu, la valeur par défaut d'origine est renvoyée, qui dans ce cas est 0.


Regardons donc L'IL généré de cette méthode. J'ai exécuté la méthode originale (ne changeant que Trace en Debug) dans LINQPad car il a la capacité de vider l'IL généré.

Je ne posterai pas tout le code IL ici, mais trouvons l'utilisation de l'énumérateur:

Voici var enumerator = list.GetEnumerator():

IL_005E:  ldfld       UserQuery+<NotWorking>d__1.<list>5__2
IL_0063:  callvirt    System.Collections.Generic.List<System.Int32>.GetEnumerator
IL_0068:  stfld       UserQuery+<NotWorking>d__1.<enumerator>5__3

Et voici l'appel de MoveNext:

IL_007F:  ldarg.0     
IL_0080:  ldfld       UserQuery+<NotWorking>d__1.<enumerator>5__3
IL_0085:  stloc.3     // CS$0$0001
IL_0086:  ldloca.s    03 // CS$0$0001
IL_0088:  call        System.Collections.Generic.List<System.Int32>+Enumerator.MoveNext
IL_008D:  box         System.Boolean
IL_0092:  call        System.Diagnostics.Debug.WriteLine

ldfld ici lit la valeur du champ et pousse la valeur sur la pile. Ensuite cette copie est stockée dans une variable locale de la .MoveNext() méthode, et cette variable locale est alors muté par un appel à .MoveNext().

, Puisque le résultat final, maintenant, dans cette variable locale est plus récente stocké dans le champ, le champ est laissé tel quel.


Voici un exemple différent qui rend le problème "plus clair" dans le sens où l'énumérateur étant une structure nous est en quelque sorte caché:

async void Main()
{
    await NotWorking();
}

public async Task NotWorking()
{
    using (var evil = new EvilStruct())
    {
        await Task.Delay(100);
        evil.Mutate();
        Debug.WriteLine(evil.Value);
    }
}

public struct EvilStruct : IDisposable
{
    public int Value;
    public void Mutate()
    {
        Value++;
    }

    public void Dispose()
    {
    }
}

Cela produira aussi 0.

16
répondu Lasse Vågsæther Karlsen 2017-05-23 11:44:01

Ressemble à un bug dans l'ancien compilateur, peut-être causé par une interférence des transformations de code effectuées dans using et async.

L'expédition du compilateur avec VS2015 semble l'obtenir correctement.

3
répondu VSadov 2015-07-21 17:34:52