为什么 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
如果我删除 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
任何人都可以向我解释这种行为吗?
下面是这个问题的简写。下面是更长的解释。
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",因此必须像这样执行该方法:
- 调用初始部分,直到第一个 await
- 下一部分必须由
MoveNext
处理,有点像 IEnumerator
- 下一部分,如果有的话,以及所有后续部分,都由这个
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 一起发布的编译器似乎正确地得到了这个。
我想通过一个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
任何人都可以向我解释这种行为吗?
下面是这个问题的简写。下面是更长的解释。
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",因此必须像这样执行该方法:
- 调用初始部分,直到第一个 await
- 下一部分必须由
MoveNext
处理,有点像IEnumerator
- 下一部分,如果有的话,以及所有后续部分,都由这个
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 一起发布的编译器似乎正确地得到了这个。