Почему перечислитель.MoveNext не работает, как я ожидаю, при использовании с использованием и async-await?
Я хотел бы перечислить через List<int>
и вызвать метод async.
если я сделаю это таким образом:
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);
}
}
результат:
True
0
но я ожидаю, что это будет:
True
1
если я удалить using
или 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);
}
выход, как ожидалось:
True
1
может ли кто-нибудь объяснить мне это поведение?
2 ответов
вот это проблема. Объяснения следующие.
-
List<T>.GetEnumerator()
возвращает структуру, тип значения. - эта структура изменчива (всегда рецепт катастрофы)
- когда
using () {}
присутствует, структура хранится в поле на базовом сгенерированном классе для обработкиawait
часть. - при вызове
.MoveNext()
через это поле загружается копия значения поля из основной объект, таким образом, как будтоMoveNext
никогда не вызывался, когда код читает.Current
как упоминал Марк в комментариях, теперь, когда вы знаете о проблеме, простое "исправление" - переписать код, чтобы явно поместить структуру в поле, это гарантирует, что изменяемая структура является той же самой, которая используется везде в этом коде, вместо того, чтобы свежие копии мутировали повсюду.
using (IEnumerator<int> enumerator = list.GetEnumerator()) {
что происходит действительно здесь.
на async
/ await
природа метода делает несколько вещей для метода. В частности, весь метод поднимается на новый сгенерированный класс и превращается в машину состояний.
везде, где вы видите await
, метод является своего рода "сплит", так что метод должен быть выполнен примерно так:
- вызов начальной части, вплоть до первого ожидания
- следующая часть должна быть обработана
MoveNext
вроде какIEnumerator
- следующая часть, если таковая имеется, и все последующие части обрабатываются этим
MoveNext
часть
этой MoveNext
метод генерируется в этом классе, и код из исходного метода помещается внутри него, по частям, чтобы соответствовать различным последовательностям в методе.
любые местные переменные метода должны выжить от одного вызова до этого MoveNext
метод к следующему, и они "подняты" на этот класс как Закрытое поле.
класс в Примере может тогда очень упрощенно переписать примерно так:
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;
этот код рядом правильный код, но достаточно близко для этого.
проблема здесь в том, что второй случай. По какой-то причине созданный код считывает это поле как копию, а не как ссылку на поле. Как таковой, призыв к .MoveNext()
сделано на этой копии. Исходное поле значение остается как есть, поэтому когда .Current
считывается, возвращается исходное значение по умолчанию, которое в данном случае 0
.
Итак, давайте посмотрим на сгенерированный IL этого метода. Я выполнил исходный метод (только изменение Trace
to Debug
) в помощью linqpad поскольку он имеет возможность сбрасывать генерируемый IL.
я не буду публиковать весь код IL здесь, но давайте найдем использование перечислителя:
здесь 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
и вот вызов MoveNext
:
IL_007F: ldarg.0
IL_0080: ldfld UserQuery+<NotWorking>d__1.<enumerator>5__3
IL_0085: stloc.3 // CS01
IL_0086: ldloca.s 03 // CS01
IL_0088: call System.Collections.Generic.List<System.Int32>+Enumerator.MoveNext
IL_008D: box System.Boolean
IL_0092: call System.Diagnostics.Debug.WriteLine
ldfld
здесь считывает значение поля и нажимает значение в стеке. Затем эта копия сохраняется в локальной переменной .MoveNext()
метод, и эта локальная переменная затем мутирует через вызов .MoveNext()
.
поскольку конечный результат, теперь в этой локальной переменной, более новый, хранится обратно в поле, поле остается как есть.
здесь другой пример, который делает проблему "более ясной" в том смысле, что перечислитель, являющийся структурой, скрыт от нас:
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()
{
}
}
это тоже выход 0
.
похоже на ошибку в старом компиляторе, возможно, вызванную некоторым вмешательством преобразований кода, выполняемых при использовании и асинхронности.
доставка компилятора С VS2015, похоже, получает это правильно.