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?
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 partieawait
. - 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 siMoveNext
é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:
- appelez la partie initiale, jusqu'à la première attente
- la partie suivante devra être gérée par un
MoveNext
un peu comme unIEnumerator
- 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
.
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.