为什么在使用异步方法时捕获 class 范围变量,但在使用 Action<T> 时不捕获(内部代码示例)?
Why is a class scope variable captured when using an async method but not when using an Action<T> (code examples inside)?
遛狗的时候我在想 Action<T>
、Func<T>
、Task<T>
、async/await
(是的,书呆子,我知道...)并构建了一个我脑子里的小测试程序想知道答案是什么。我注意到我不确定结果,所以我创建了两个简单的测试。
设置如下:
- 我有一个 class 范围变量(字符串)。
- 它被分配了一个初始值。
- 变量作为参数传递给 class 方法。
- 该方法不会直接执行,而是分配给一个'Action'。
- 在动作执行之前,我改变了变量的值。
输出结果是什么?初始值,还是修改后的值?
有点意外但可以理解,输出的是改变后的值。我的解释是:在动作执行之前,变量不会被压入堆栈,所以它会是被改变的。
public class foo
{
string token;
public foo ()
{
this.token = "Initial Value";
}
void DoIt(string someString)
{
Console.WriteLine("SomeString is '{0}'", someString);
}
public void Run()
{
Action op = () => DoIt(this.token);
this.token = "Changed value";
// Will output "Changed value".
op();
}
}
接下来,我创建了一个变体:
public class foo
{
string token;
public foo ()
{
this.token = "Initial Value";
}
Task DoIt(string someString)
{
// Delay(0) is just there to try if timing is the issue here - can also try Delay(1000) or whatever.
return Task.Delay(0).ContinueWith(t => Console.WriteLine("SomeString is '{0}'", someString));
}
async Task Execute(Func<Task> op)
{
await op();
}
public async void Run()
{
var op = DoIt(this.token);
this.token = "Changed value";
// The output will be "Initial Value"!
await Execute(() => op);
}
}
这里我做了DoIt()
return一个Task
。 op
现在是 Task
,不再是 Action
。 Execute()
方法等待任务。令我惊讶的是,输出现在是 "Initial Value"。
为什么它的行为不同?
DoIt()
直到调用Execute()
才会执行,那为什么会捕获token
的初始值呢?
完成测试:https://gist.github.com/Krumelur/c20cb3d3b4c44134311f and https://gist.github.com/Krumelur/3f93afb50b02fba6a7c8
在这两种情况下,您都在关闭。但是,在这两种情况下,您要关闭不同的东西。
在第一种情况下,您在 this
上创建一个带有闭包的匿名方法 - 当您最终执行委托时,它将采用 current 值this
的 当前 值并使用 this.token
的值。所以你看到修改后的值。
在第二种情况下,this
没有关闭 - 或者即使是,也没有什么区别。您显式传递 this.token
,DoIt
方法只需要关闭它自己的参数 someString
。这会立即(同步)发生,而不是延迟发生 - 因此 this.token
的初始值被捕获。 await
实际上并不 执行 委托 - 它只等待异步方法的结果。该方法本身已经 运行,只有它的异步部分是异步的 - 在这种情况下,只有 Console.WriteLine("SomeString is '{0}'", someString)
.
如果您想更清楚地看到这一点,请在 this.token = "Changed value";
之后添加 Thread.Sleep(1000)
- 您甚至会在 之前 看到 SomeString is 'Initial Value'
打印出来到达 await
.
要使第二个示例的行为与第一个示例相同,您需要做的就是再次将 op
更改为委托,而不是 Task
- var op = () => DoIt(this.token);
。这再次延迟 DoIt
的执行,并导致与第一个示例相同的关闭。
长话短说:
行为不同,因为在第一种情况下,您推迟执行 DoIt(this.token)
,而在第二个示例中,您立即 运行 DoIt(this.token)
。我的回答中的其他几点也很重要,但这是关键。
你在这里有几个误解。首先,当您调用 DoIt
时,它 returns 一个已经开始执行的任务。执行 不会 仅当您 await
任务时才开始。
您还在 someString
变量上创建了一个闭包,当您重新分配 class 级字段时,其值 不会改变 :
Task DoIt(string someString)
{
return Task.Delay(0).ContinueWith(t
=> Console.WriteLine("SomeString is '{0}'", someString));
}
传递给 ContinueWith
的 Action
在 someString
变量上关闭。请记住,字符串是 不可变的 ,因此,当您重新分配 token
的值时,您实际上是在分配一个 新字符串引用 。然而,DoIt
中的局部变量 someString
保留了旧的引用,因此即使在 class 字段被重新分配后,它的值也保持不变。
您可以通过直接关闭 class 级字段来解决此问题:
Task DoIt()
{
return Task.Delay(0).ContinueWith(t
=> Console.WriteLine("SomeString is '{0}'", this.token));
}
让我们分解每个案例。
从 Action<T>
开始:
My explanation: the variable is not pushed onto the stack until the
action executes, so it will be the changed one
这与堆栈无关。编译器从您的第一个代码片段生成以下内容:
public foo()
{
this.token = "Initial Value";
}
private void DoIt(string someString)
{
Console.WriteLine("SomeString is '{0}'", someString);
}
public void Run()
{
Action action = new Action(this.<Run>b__3_0);
this.token = "Changed value";
action();
}
[CompilerGenerated]
private void <Run>b__3_0()
{
this.DoIt(this.token);
}
编译器从您的 lambda 表达式发出命名方法。一旦您调用该操作,并且由于我们在同一个 class 中,this.token
就是更新后的 "Changed Value"。编译器甚至不会将它提升到显示中 class,因为这都是在实例方法内部创建和调用的。
现在,对于 async
方法。有两个状态机正在生成,我略过状态机的膨胀并进入相关部分。状态机执行以下操作:
this.<>8__1 = new foo.<>c__DisplayClass4_0();
this.<>8__1.op = this.<>4__this.DoIt(this.<>4__this.token);
this.<>4__this.token = "Changed value";
taskAwaiter = this.<>4__this.Execute(new Func<Task>(this.<>8__1.<Run>b__0)).GetAwaiter();
这里发生了什么? token
传递给 DoIt
,这将 return 一个 Func<Task>
。该委托包含对旧标记字符串 "Initial Value" 的引用。请记住,尽管我们谈论的是引用类型,但它们都是按值传递的。这实际上意味着现在在指向 "Initial Value" 的 DoIt
方法中有一个旧字符串的新存储位置。然后,下一行将 token
更改为 "Changed Value"。存储在 Func
中的 string
和被更改的 现在指向两个不同的字符串 。
当您调用委托时,它会打印初始值,因为 op
任务会存储您较旧的陈旧值。这就是为什么您会看到两种不同的行为。
遛狗的时候我在想 Action<T>
、Func<T>
、Task<T>
、async/await
(是的,书呆子,我知道...)并构建了一个我脑子里的小测试程序想知道答案是什么。我注意到我不确定结果,所以我创建了两个简单的测试。
设置如下:
- 我有一个 class 范围变量(字符串)。
- 它被分配了一个初始值。
- 变量作为参数传递给 class 方法。
- 该方法不会直接执行,而是分配给一个'Action'。
- 在动作执行之前,我改变了变量的值。
输出结果是什么?初始值,还是修改后的值?
有点意外但可以理解,输出的是改变后的值。我的解释是:在动作执行之前,变量不会被压入堆栈,所以它会是被改变的。
public class foo
{
string token;
public foo ()
{
this.token = "Initial Value";
}
void DoIt(string someString)
{
Console.WriteLine("SomeString is '{0}'", someString);
}
public void Run()
{
Action op = () => DoIt(this.token);
this.token = "Changed value";
// Will output "Changed value".
op();
}
}
接下来,我创建了一个变体:
public class foo
{
string token;
public foo ()
{
this.token = "Initial Value";
}
Task DoIt(string someString)
{
// Delay(0) is just there to try if timing is the issue here - can also try Delay(1000) or whatever.
return Task.Delay(0).ContinueWith(t => Console.WriteLine("SomeString is '{0}'", someString));
}
async Task Execute(Func<Task> op)
{
await op();
}
public async void Run()
{
var op = DoIt(this.token);
this.token = "Changed value";
// The output will be "Initial Value"!
await Execute(() => op);
}
}
这里我做了DoIt()
return一个Task
。 op
现在是 Task
,不再是 Action
。 Execute()
方法等待任务。令我惊讶的是,输出现在是 "Initial Value"。
为什么它的行为不同?
DoIt()
直到调用Execute()
才会执行,那为什么会捕获token
的初始值呢?
完成测试:https://gist.github.com/Krumelur/c20cb3d3b4c44134311f and https://gist.github.com/Krumelur/3f93afb50b02fba6a7c8
在这两种情况下,您都在关闭。但是,在这两种情况下,您要关闭不同的东西。
在第一种情况下,您在 this
上创建一个带有闭包的匿名方法 - 当您最终执行委托时,它将采用 current 值this
的 当前 值并使用 this.token
的值。所以你看到修改后的值。
在第二种情况下,this
没有关闭 - 或者即使是,也没有什么区别。您显式传递 this.token
,DoIt
方法只需要关闭它自己的参数 someString
。这会立即(同步)发生,而不是延迟发生 - 因此 this.token
的初始值被捕获。 await
实际上并不 执行 委托 - 它只等待异步方法的结果。该方法本身已经 运行,只有它的异步部分是异步的 - 在这种情况下,只有 Console.WriteLine("SomeString is '{0}'", someString)
.
如果您想更清楚地看到这一点,请在 this.token = "Changed value";
之后添加 Thread.Sleep(1000)
- 您甚至会在 之前 看到 SomeString is 'Initial Value'
打印出来到达 await
.
要使第二个示例的行为与第一个示例相同,您需要做的就是再次将 op
更改为委托,而不是 Task
- var op = () => DoIt(this.token);
。这再次延迟 DoIt
的执行,并导致与第一个示例相同的关闭。
长话短说:
行为不同,因为在第一种情况下,您推迟执行 DoIt(this.token)
,而在第二个示例中,您立即 运行 DoIt(this.token)
。我的回答中的其他几点也很重要,但这是关键。
你在这里有几个误解。首先,当您调用 DoIt
时,它 returns 一个已经开始执行的任务。执行 不会 仅当您 await
任务时才开始。
您还在 someString
变量上创建了一个闭包,当您重新分配 class 级字段时,其值 不会改变 :
Task DoIt(string someString)
{
return Task.Delay(0).ContinueWith(t
=> Console.WriteLine("SomeString is '{0}'", someString));
}
传递给 ContinueWith
的 Action
在 someString
变量上关闭。请记住,字符串是 不可变的 ,因此,当您重新分配 token
的值时,您实际上是在分配一个 新字符串引用 。然而,DoIt
中的局部变量 someString
保留了旧的引用,因此即使在 class 字段被重新分配后,它的值也保持不变。
您可以通过直接关闭 class 级字段来解决此问题:
Task DoIt()
{
return Task.Delay(0).ContinueWith(t
=> Console.WriteLine("SomeString is '{0}'", this.token));
}
让我们分解每个案例。
从 Action<T>
开始:
My explanation: the variable is not pushed onto the stack until the action executes, so it will be the changed one
这与堆栈无关。编译器从您的第一个代码片段生成以下内容:
public foo()
{
this.token = "Initial Value";
}
private void DoIt(string someString)
{
Console.WriteLine("SomeString is '{0}'", someString);
}
public void Run()
{
Action action = new Action(this.<Run>b__3_0);
this.token = "Changed value";
action();
}
[CompilerGenerated]
private void <Run>b__3_0()
{
this.DoIt(this.token);
}
编译器从您的 lambda 表达式发出命名方法。一旦您调用该操作,并且由于我们在同一个 class 中,this.token
就是更新后的 "Changed Value"。编译器甚至不会将它提升到显示中 class,因为这都是在实例方法内部创建和调用的。
现在,对于 async
方法。有两个状态机正在生成,我略过状态机的膨胀并进入相关部分。状态机执行以下操作:
this.<>8__1 = new foo.<>c__DisplayClass4_0();
this.<>8__1.op = this.<>4__this.DoIt(this.<>4__this.token);
this.<>4__this.token = "Changed value";
taskAwaiter = this.<>4__this.Execute(new Func<Task>(this.<>8__1.<Run>b__0)).GetAwaiter();
这里发生了什么? token
传递给 DoIt
,这将 return 一个 Func<Task>
。该委托包含对旧标记字符串 "Initial Value" 的引用。请记住,尽管我们谈论的是引用类型,但它们都是按值传递的。这实际上意味着现在在指向 "Initial Value" 的 DoIt
方法中有一个旧字符串的新存储位置。然后,下一行将 token
更改为 "Changed Value"。存储在 Func
中的 string
和被更改的 现在指向两个不同的字符串 。
当您调用委托时,它会打印初始值,因为 op
任务会存储您较旧的陈旧值。这就是为什么您会看到两种不同的行为。