为什么Enumerator.MoveNext与using和async-await一起使用时不能正常工作?

Pet*_*ger 22 c# ienumerable list async-await

我想通过a枚举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);
  }
}
Run Code Online (Sandbox Code Playgroud)

结果是:

True
0
Run Code Online (Sandbox Code Playgroud)

但我希望它是:

True
1
Run Code Online (Sandbox Code Playgroud)

如果我删除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);
}
Run Code Online (Sandbox Code Playgroud)

输出符合预期:

True
1
Run Code Online (Sandbox Code Playgroud)

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

ang*_*son 17

这是这个问题的不足之处.更长的解释如下.

  • List<T>.GetEnumerator() 返回一个结构,一个值类型.
  • 这个结构是可变的(总是一个灾难的配方)
  • using () {}存在时,结构存储在底层生成的类的字段中以处理该await部分.
  • .MoveNext()通过这个字段调用时,从底层对象加载一个字段值的副本,因此就好像MoveNext在代码读取时从未调用过.Current

正如Marc在评论中提到的,现在您已经知道了这个问题,一个简单的"修复"是重写代码以显式地封装结构,这将确保可变结构与此代码中的任何地方使用的结构相同,而不是新鲜的副本在各处变异.

using (IEnumerator<int> enumerator = list.GetEnumerator()) {
Run Code Online (Sandbox Code Playgroud)

那么,这里真正发生了什么.

方法的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;
Run Code Online (Sandbox Code Playgroud)

此代码远不是正确的代码,但足够接近此目的.

这里的问题是第二种情况.由于某种原因,生成的代码将此字段作为副本读取,而不是作为对该字段的引用.因此,调用.MoveNext()是在此副本上完成的.原始字段值保持原样,因此在.Current读取时,将返回原始默认值,在本例中为0.


那么让我们看看这个方法生成的IL.我在LINQPad中执行了原始方法(仅更改TraceDebug),因为它具有转储生成的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
Run Code Online (Sandbox Code Playgroud)

以下是对以下内容的呼吁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
Run Code Online (Sandbox Code Playgroud)

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()
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

这也将输出0.

  • 在此处发布Microsoft Connect问题:https://connect.microsoft.com/VisualStudio/feedback/details/1200553 (5认同)
  • 您应该提到这是一个编译器错误.听起来好像这是代码的问题. (3认同)
  • @MarcGravell我很确定规范没有说当一个完全不同的地方引入了一个无关的等待或使用时,方法调用的行为会有所不同......重要的是要知道这是一个bug,以便用户知道他必须"摆动"代码,直到bug消失并留下评论. (3认同)