【问题标题】:Why is Enumerator.MoveNext not working as I expect it when used with using and async-await?为什么 Enumerator.MoveNext 在与 using 和 async-await 一起使用时无法正常工作?
【发布时间】:2015-05-27 15:30:00
【问题描述】:

我想通过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

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

谁能向我解释这种行为?

【问题讨论】:

  • @MarcGravell 是,刚刚检查了生成的IL。
  • 一个懒惰的“修复”是使用IEnumerator&lt;int&gt; 而不是var;这意味着枚举器将被装箱,但装箱和工作比没有装箱和损坏要好
  • 可变结构是邪恶的™
  • 确认:L_0065: ldfld valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator`0&lt;int32&gt; ConsoleApplication21.Program/&lt;NotWorking&gt;d__1::&lt;enumerator&gt;5__3 L_006a: stloc.s enumerator L_006c: ldloca.s enumerator L_006e: call instance bool [mscorlib]System.Collections.Generic.List`1/Enumerator`0&lt;int32&gt;::MoveNext() - bleugh
  • 这是一个编译器错误。存储为字段的结构无关紧要。 C# 规范说这不会发生,所以这是一个错误。

标签: c# list async-await ienumerable


【解决方案1】:

这是这个问题的短板。接下来是更长的解释。

  • List&lt;T&gt;.GetEnumerator() 返回一个结构体,一个值类型。
  • 此结构是可变的 (always a recipe for disaster)
  • 当存在using () {} 时,该结构存储在底层生成类的字段中以处理await 部分。
  • 通过该字段调用.MoveNext() 时,会从底层对象加载字段值的副本,因此当代码读取.Current 时,就好像从未调用过MoveNext

正如 Marc 在 cmets 中提到的,既然您知道了问题所在,一个简单的“修复”就是重写代码以显式地装箱该结构,这将确保可变结构与此代码中各处使用的相同,而不是到处变异新的副本。

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

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

方法的async / await 特性对方法有一些作用。具体来说,就是将整个方法提升到一个新生成的类上,变成一个状态机。

在你看到await 的任何地方,该方法都是“拆分”的,因此该方法必须像这样执行:

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

这个MoveNext方法是在这个类上生成的,来自原始方法的代码被放置在其中,零碎地适应方法中的各种序列点。

因此,该方法的任何本地变量都必须从一次调用此MoveNext 方法到下一次继续存在,并且它们作为私有字段被“提升”到此类。 p>

然后,示例中的类可以非常简单地被重写为如下内容:

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。

这里就不贴整个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。

【讨论】:

  • 您应该提到这是一个编译器错误。听起来好像是代码有问题。
  • @MarcGravell 我很确定规范并没有说当在一些完全不同的地方引入不相关的等待或使用时方法调用的行为不同......重要的是要知道这是一个错误以便用户知道他必须“摆动”代码直到错误消失并留下评论。
  • @usr 框修复不是“偶然”,也不会触发不同的编译器代码路径。它的工作原理是避免 mutating value-types 问题
  • 这段代码应该可以正常工作。变异值类型在这里没有错。他们的行为是明确规定的。根本不需要特别照顾。
  • 在此处发布了 Microsoft Connect 问题:connect.microsoft.com/VisualStudio/feedback/details/1200553
【解决方案2】:

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

与 VS2015 一起提供的编译器似乎可以正确理解这一点。

【讨论】:

  • 今天在使用 C# 7 的 VS 2017 Pro 中发生在我身上。不过,该项目的目标是 .NET 4.5.2,所以不确定这是否会有所不同。
猜你喜欢
  • 2021-06-30
  • 2020-08-21
  • 1970-01-01
  • 2019-11-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多