为什么 Enumerator.MoveNext 在与 using 和 async-await 一起使用时没有像我预期的那样工作?

Why is Enumerator.MoveNext not working as I expect it when used with using and async-await?

我想通过一个List<int>枚举并调用一个异步方法。

如果我这样做:

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

如果我删除 usingawait 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

任何人都可以向我解释这种行为吗?

下面是这个问题的简写。下面是更长的解释。

  • List<T>.GetEnumerator() returns一个结构,一个值类型。
  • 这个结构是可变的(always a recipe for disaster
  • using () {} 存在时,该结构存储在底层生成的 class 的字段中以处理 await 部分。
  • 通过此字段调用 .MoveNext() 时,会从底层对象加载字段值的副本,因此当代码读取 [=20= 时,就好像 MoveNext 从未被调用过]

正如 Marc 在评论中提到的那样,既然您知道问题所在,一个简单的 "fix" 就是重写代码以显式框住结构,这将确保可变结构与使用的相同这段代码中的任何地方,而不是到处都在变异的新副本。

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

那么,这里真的发生了什么。

方法的 async / await 性质对方法有一些作用。具体来说,整个方法被提升到一个新生成的 class 上并变成一个状态机。

在你看到的任何地方 await,该方法都类似于 "split",因此必须像这样执行该方法:

  1. 调用初始部分,直到第一个 await
  2. 下一部分必须由 MoveNext 处理,有点像 IEnumerator
  3. 下一部分,如果有的话,以及所有后续部分,都由这个MoveNext部分处理

这个MoveNext方法是在这个class上生成的,原始方法的代码放在里面,零碎地适应方法中的各个序列点。

因此,该方法的任何 local 变量都必须从对该 MoveNext 方法的一次调用到下一次调用继续存在,并且它们是 "lifted"作为私有字段添加到此 class。

示例中的 class 可以 非常简单地 重写为如下内容:

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。我在 LINQPad 中执行了原始方法(仅将 Trace 更改为 Debug),因为它能够转储生成的 IL。

我不会在这里 post 整个 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[=13=][=13=]01
IL_0086:  ldloca.s    03 // CS[=13=][=13=]01
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().

来改变此局部变量

由于最终结果,现在在这个局部变量中,更新存储回字段中,字段保持原样。


这是一个不同的例子,它使问题 "clearer" 从某种意义上说,作为结构的枚举器对我们来说有点隐藏:

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.

看起来像是旧编译器中的错误,可能是由于在 using 和 async 中执行的代码转换的一些干扰造成的。

随 VS2015 一起发布的编译器似乎正确地得到了这个。