.NET 4.5异步/等待和垃圾回收器

20

我想了解async/await在垃圾回收本地变量方面的行为。在下面的示例中,我分配了大量内存并进入了一个显着的延迟。如代码所示,在await之后未使用Buffer。它会在等待期间被垃圾回收,还是内存会被占用整个函数的持续时间?

/// <summary>
/// How does async/await behave in relation to managed memory?
/// </summary>
public async Task<bool> AllocateMemoryAndWaitForAWhile() {
    // Allocate a sizable amount of memory.
    var Buffer = new byte[32 * 1024 * 1024];
    // Show the length of the buffer (to avoid optimization removal).
    System.Console.WriteLine(Buffer.Length);
    // Await one minute for no apparent reason.
    await Task.Delay(60000);
    // Did 'Buffer' get freed by the garabage collector while waiting?
    return true;
}

1
这取决于编译器如何翻译它。async-await不是CLR的概念。(尽管如此,这是一个有效的问题)。 - usr
1
错误答案获得最多赞的好例子。 - Soonts
5
@Soonts:我有点觉得你认为我的回答是错误的很有趣。我向你保证,这是C#语言所做出的保证的正确陈述。其他任何内容都是可能随时更改的实现细节。 - Eric Lippert
@EricLippert,OP的问题并不是理论性的。他并没有问“C#语言保证了什么”。他实际上问的是“它是如何工作的”。当然,实现细节可能会随着下一个Visual Studio甚至服务包而改变。在这种情况下,正确答案将从“否”变为“是”。然而,在当前生产版本的Visual Studio 2012中,正确答案不是“可能”,而是“否”。 - Soonts
3
@soonts异步空返回m()函数(){ var x = new X(); await n(x.h); } 这里h是一个句柄,~X()将对其进行释放。我想问一下,在任务完成之前析构函数是否会运行。如果是的话,这将导致数据库损坏。你的答案仍然是完全安全的吗?因为终结器无法运行?现在你明白为什么考虑保证和发生的事情非常重要了吗?使用良好的工程实践,不要依赖于实现细节。 - Eric Lippert
@EricLippert,使用终结器来处理这样的问题从来都不是“良好的工程实践”。对于这些问题的正确解决方案应该是实现IDisposable接口,然后编写using(var x = new X()){await n(x.h);}。此外,你的例子与OP的问题无关。他显然关心的是RAM的使用情况,而不是你试图引入的东西。 - Soonts
6个回答

26

等待期间,它会被垃圾收集吗?

可能会。垃圾回收器可以这样做,但不是必须的。

在函数执行期间,内存是否会被占用?

可能会。垃圾回收器可以这样做,但不是必须的。

基本上,如果垃圾回收器可以知道缓冲区不会再次被访问,则可以随时释放它。但垃圾回收器从不强制按任何特定时间表释放任何内容。

如果您特别关注,您总是可以将本地变量设置为 null,但除非您明确存在问题,否则我不建议这样做。或者,您可以将操作缓冲区的代码提取到其自己的非异步方法中,并从异步方法同步调用它;然后本地变量成为普通方法的普通本地变量。

await 实现为 return,因此本地变量将超出范围并且其生命周期结束;那么数列将在下一次收集期间被回收,而这是必须在 Delay 期间进行的,对吗?

不,这些声明都不是真的。

首先,如果任务未完成,则 await 仅为 return;现在,当然几乎不可能 Delay 完成,因此是的,这将返回,但我们不能一般性地得出 await 返回给调用者的结论。

其次,本地变量仅在由 C# 编译器实现为临时池中的本地变量时才消失。Jitter 将 jit 该变量作为堆栈插槽或寄存器,该插槽或寄存器在 await 结束时消失。但 C# 编译器不必这样做!

对于调试器中的人来说,在 Delay 之后设置断点并看到局部变量消失似乎很奇怪,因此编译器可能意识到该局部变量作为编译器生成的类中的字段,该类与状态机生成的生命周期绑定。在这种情况下,JIT 编译器不太可能意识到该字段不会再被读取,并且因此也不太可能提前将其删除。(虽然它有权这样做。而且如果 C# 编译器可以证明你已经停止使用该字段,则有权代表你将该字段设置为 null。但是,对于突然看到他们的本地值无故更改的调试器中的人来说,这会很奇怪,但编译器有权生成任何单线程行为正确的代码。)

第三,垃圾收集器没有要求按特定的时间表进行任何清除。这个大数组将分配在大对象堆上,该堆有自己的收集计划。

第四,没有任何要求在任何给定的六十秒间隔内对大对象堆进行收集。如果没有内存压力,就可以永远不收集它。


1
@IV4:我第一次误读了代码,但很快意识到了我的错误。 - Eric Lippert
@Soonts:您当然是正确的。在我们设计该功能时,它将成为一个委托,但到发布时已经变成了一个接口。对于这个问题的目的来说,编译器生成的类的确切形状并不特别重要。 - Eric Lippert
@EricLippert 这很重要。在委托内部,局部变量仍然是局部变量。但在类/结构中,异步方法的局部变量不再是“局部”的,而是变成了字段。 - Soonts
@soonts,我不确定如何更清楚地表达这个意思。将什么作为字段重新实例化的选择与方法是否转换为委托无关。必须在await之后保留的变量必须是字段,无论movnext是否转换为委托。重新实例化其他变量的决定与使用委托的决定无关。 - Eric Lippert
@EricLippert,这取决于具体实现。如果实现例如将异步方法拆分为多个嵌套委托,则必须在第一个委托中声明一个变量,该变量必须在等待期间保持不变,并在第二个委托中引用它。没有任何字段。 - Soonts
显示剩余5条评论

7
埃里克·利珀特所说的是正确的:C#编译器在生成async方法的IL时有相当大的自由度。因此,如果你问规范对此有何规定,答案是:数组可能在等待期间符合回收条件,也就是可能被回收。
但另一个问题是编译器实际上做了什么。在我的计算机上,编译器将Buffer作为生成的状态机类型的字段生成。该字段设置为已分配的数组,然后再也没有设置。这意味着当状态机对象符合回收条件时,该数组也将符合回收条件。而此对象是从继续委托中引用的,因此在等待完成之后才会符合回收条件。所有这些意味着该数组不会在等待期间符合回收条件,也就是不会被回收。
一些额外的注释:
  1. 状态机对象实际上是一个struct,但它通过其实现的接口使用,因此在垃圾回收方面表现为引用类型。
  2. 如果你确实确定该数组不会被回收对你造成问题,那么在await之前将局部变量设置为null可能是值得的。但在绝大多数情况下,你不必担心这个问题。我当然不是说你应该经常在await之前将局部变量设置为null
  3. 这非常是一个实现细节。它可能随时改变,不同版本的编译器可能会有不同的行为。

1
所有的观点都很好。我想补充一下,在设计阶段有些讨论是关于在编译时检测这些情况并自动将字段置空,以便于垃圾收集器更轻松地处理(但会让调试器中本地变量神秘地变成null的开发人员更加困难)。我不知道最终决定了什么。 - Eric Lippert

3

您的代码(在我的环境中:VS2012,C# 5,.NET 4.5,发布模式)编译后包含了一个实现了IAsyncStateMachine接口的结构体,并具有以下字段:

public byte[] <Buffer>5__1;

因此,除非JIT和/或GC非常聪明(关于这一点,请参见Eric Lippert的答案),否则可以合理地假设大型byte[]将在异步任务完成之前一直保持在作用域内。


我觉得这并没有真正回答问题,你还需要知道:1. 编译器是否将字段设置为null?2. 状态机结构体(实际上不是类)何时会成为可回收对象? - svick
@svik,(1)不,它不会。编译并使用Reflector进行验证。(2)异步方法完成后。 - Soonts

0

我非常确定它已经被收集了,因为await会结束当前任务并“继续执行”另一个任务,所以当在await之后不再使用局部变量时,它们应该被清除。

但是:编译器实际上可能会做一些不同的事情,所以我不会依赖这样的行为。


我在我的回答中解决了你的假设。 - Eric Lippert

0

与Rolsyn编译器相关的更新已发布。

在Visual Studio 2015 Update 3中以发布配置运行以下代码会产生以下结果:

True
False

所以本地变量会被垃圾回收。

    private static async Task MethodAsync()
    {
        byte[] bytes = new byte[1024];
        var wr = new WeakReference(bytes);

        Console.WriteLine(wr.Target != null);

        await Task.Delay(100);

        FullGC();

        Console.WriteLine(wr.Target != null);

        await Task.Delay(100);

    }

    private static void FullGC()
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }

请注意,如果我们修改MethodAsync以在等待后使用本地变量,则数组缓冲区将无法进行垃圾回收。
 private static async Task MethodAsync()
    {
        byte[] bytes = new byte[1024];
        var wr = new WeakReference(bytes);

        Console.WriteLine(wr.Target != null);

        await Task.Delay(100);

        Console.WriteLine(bytes.Length);

        FullGC();

        Console.WriteLine(wr.Target != null);

        await Task.Delay(100);

        FullGC();

        Console.WriteLine(wr.Target != null);
    }

这个的输出结果是

True
1024
True
True

这些代码示例取自于此 rolsyn issue


-2
它在等待期间会被垃圾回收吗?
不会。
在函数执行期间,内存会被占用吗?
是的。
打开反编译器中的已编译程序集。您将看到编译器生成了一个继承自IAsyncStateMachine的私有结构体,您异步方法的局部变量是该结构体的字段。只要拥有实例仍然存在,类/结构体的数据字段就不会被释放。

那只是用另一个问题来替代一个问题:拥有实例何时停止存在? - svick
在方法完成后,任务调度程序将持有对 MoveNext 方法的引用,以便在超时后调用它。如果您想要提前取消,请向 Task.Delay 传递一个 CancelationTocken。 - Soonts

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接