Почему перечислитель.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, метод является своего рода "сплит", так что метод должен быть выполнен примерно так:

  1. вызов начальной части, вплоть до первого ожидания
  2. следующая часть должна быть обработана MoveNext вроде как IEnumerator
  3. следующая часть, если таковая имеется, и все последующие части обрабатываются этим 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, похоже, получает это правильно.