垃圾回收异步方法

15

关于垃圾回收我注意到了一件非常奇怪的事情。

WeakRef方法按预期收集对象,而async方法报告该对象仍然存活,即使我们已经强制进行了垃圾回收。有什么想法呢?

class Program
{
    static void Main(string[] args)
    {
        WeakRef();
        WeakRefAsync().Wait();
    }

    private static void WeakRef()
    {
        var foo = new Foo();
        WeakReference fooRef = new WeakReference(foo);
        foo = null;
        GC.Collect();
        Debug.Assert(!fooRef.IsAlive);
    }

    private static async Task WeakRefAsync()
    {
        var foo = new Foo();
        WeakReference fooRef = new WeakReference(foo);
        foo = null;
        GC.Collect();
        Debug.Assert(!fooRef.IsAlive);
    }
}


public class Foo
{

}

第一个想法:也许是因为async方法中的foofooRef变成了编译器生成的状态机类的属性。但我尝试将这些变量包装在另一个类中,它们确实被收集了...有没有办法询问GC哪里/谁仍然引用Foo实例? - René Vogt
1
在我看来,编译器将您的方法转换为一个类,并且编译器决定foo是该类的成员。因此,只要表示您的方法的对象仍然存在,它的成员就不会被垃圾回收。 - Matteo Umili
3
我认为这与调试本身的行为相关。如果您将Debug.Assert更改为Console.WriteLine并在主函数末尾添加一个ReadLine,则在调试构建中会打印出“True False”,而在发布构建中会打印出“True True”。也许与调试时局部变量的GC生命周期延长方式有关。 - Damien_The_Unbeliever
1
foo = null; 是一个错误。了解 为什么它是一个错误 可以让您了解 C# 编译器如何重写异步方法,从而获得不同的结果。 - Hans Passant
@HansPassant 我认为异步方法中没有使用 await 才是真正的 bug。在实际代码中,将 someLocal = null 设置为空几乎没有什么价值,但是在 async 方法(或者使用 yield 的方法)中,foo 是一个在调用之间存在的字段,因此它确实可能有一些值。如果在其中添加了一些 await,那么这不再是一个 bug,而是一个过早的优化。 - Jon Hanna
显示剩余7条评论
1个回答

9
WeakRef方法按预期收集对象。
没有理由期望这样。例如,在Linqpad中尝试,在调试版本中不会发生,尽管其他有效的编译(包括调试和发布版本)可能具有任一行为。
在编译器和Jitter之间,它们可以自由地优化空值赋值(毕竟没有任何东西使用foo),在这种情况下,GC仍然可以看到线程具有对对象的引用并且不收集它。相反,如果没有foo = null的赋值,它们可以自由地意识到foo不再使用,并重新使用曾经持有它的内存或寄存器来保存fooRef(或者实际上是用于其他任何事情),并收集foo
因此,既可以通过foo = null,也可以不通过foo = null,使GC将foo视为已根或未根,我们可以合理地期望任一行为。
尽管如此,所看到的行为是一个关于可能会发生的合理期望,但值得指出的是,这并不是保证。
好的,除此之外,让我们看看这里实际发生了什么。
async方法产生的状态机是一个具有与源中局部变量对应的字段的结构体。
因此,代码:
var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
GC.Collect();

有点像:

this.foo = new Foo();
this.fooRef = new WeakReference(foo);
this.foo = null;
GC.Collect();

但是字段访问总是在本地进行某些操作。因此,在这方面,它几乎像:

var temp0 = new Foo();
this.foo = temp0;
var temp1 = new WeakReference(foo);
this.fooRef = temp1;
var temp2 = null;
this.foo = temp2;
GC.Collect();

而且temp0没有被清零,所以垃圾回收器发现Foo被视为根。

你的代码有两个有趣的变体:

var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
await Task.Delay(0);
GC.Collect();

还有:

var foo = new Foo();
WeakReference fooRef = new WeakReference(foo);
foo = null;
await Task.Delay(1);
GC.Collect();

当我运行它时(同样,处理本地变量内存/寄存器的合理差异可能导致不同的结果),第一个示例的行为与您的示例相同,因为它调用另一个Task方法并等待它,该方法返回已完成的任务,因此await立即移动到同一基础方法调用中的下一个内容,即GC.Collect()
第二个示例的行为是看到Foo被回收,因为await在那一点上返回,然后状态机大约一毫秒后再次调用其MoveNext()方法。由于这是对幕后方法的新调用,因此没有对Foo的局部引用,因此GC确实可以将其回收。
顺便说一句,编译器有可能有一天不会为那些跨越await边界的本地变量产生字段,这将是一种优化,仍会产生正确的行为。如果发生这种情况,那么您的两种方法在底层行为上将变得更加相似,因此更有可能在观察到的行为上更相似。

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