Подтвердить что ты не робот

Почему Enumerator.MoveNext не работает, как я ожидаю, когда он используется при использовании и async-wait?

Я хотел бы перечислить через 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

Может ли кто-нибудь объяснить это поведение мне?

4b9b3361

Ответ 1

Здесь недостаток этой проблемы. Далее следует более подробное объяснение.

  • List<T>.GetEnumerator() возвращает struct, тип значения.
  • Эта структура изменчива (всегда рецепт катастрофы)
  • Когда присутствует 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() выполняется на этой копии. Исходное значение поля остается как-is, поэтому при чтении .Current возвращается исходное значение по умолчанию, которое в этом случае равно 0.


Итак, посмотрим на сгенерированный ИЛ этого метода. Я выполнил оригинальный метод (только меняя Trace на 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     // 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 здесь читает значение поля и выталкивает значение в стеке. Затем эта копия хранится в локальной переменной метода .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.

Ответ 2

Похож на ошибку в старом компиляторе, возможно, вызванную некоторой помехой преобразований кода, выполняемых при использовании и async.

Отправка компилятора с VS2015 выглядит правильно.