为什么在使用异步方法时捕获 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(是的,书呆子,我知道...)并构建了一个我脑子里的小测试程序想知道答案是什么。我注意到我不确定结果,所以我创建了两个简单的测试。

设置如下:

输出结果是什么?初始值,还是修改后的值?

有点意外但可以理解,输出的是改变后的值。我的解释是:在动作执行之前,变量不会被压入堆栈,所以它会是被改变的。

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一个Taskop 现在是 Task,不再是 ActionExecute() 方法等待任务。令我惊讶的是,输出现在是 "Initial Value"。

为什么它的行为不同?

DoIt()直到调用Execute()才会执行,那为什么会捕获token的初始值呢?

完成测试:https://gist.github.com/Krumelur/c20cb3d3b4c44134311f and https://gist.github.com/Krumelur/3f93afb50b02fba6a7c8

在这两种情况下,您都在关闭。但是,在这两种情况下,您要关闭不同的东西。

在第一种情况下,您在 this 上创建一个带有闭包的匿名方法 - 当您最终执行委托时,它将采用 currentthis 当前 值并使用 this.token 的值。所以你看到修改后的值。

在第二种情况下,this 没有关闭 - 或者即使是,也没有什么区别。您显式传递 this.tokenDoIt 方法只需要关闭它自己的参数 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));
}

传递给 ContinueWithActionsomeString 变量上关闭。请记住,字符串是 不可变的 ,因此,当您重新分配 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 任务会存储您较旧的陈旧值。这就是为什么您会看到两种不同的行为。