前几天发布了几篇关于要小心使用 Task.Run 的文章,看了博客园的所有评论。发现有不少人在纠结示例中的现象是不是属于内存泄漏,本文分享一下我个人的看法,大家可以保留自己的意见。

在阅读本文前,如果你对 GC 分代算法还不了解,建议先阅读我的上一篇文章:小心使用 Task.Run 终篇解惑

背景

还是先把前面两篇文章的示例贴出来:

class Program
{
    static void Main(string[] args)
    {
        Test();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        // 程序保活
        while (true)
        {
            Thread.Sleep(100);
        }
    }

    static void Test()
    {
        var myClass = new MyClass();
        myClass.Foo();
        // 到这,myClass 实例不再需要了
    }
}

public class MyClass
{
    private int _id;

    public Task Foo()
    {
        return Task.Run(() =>
        {
            Console.WriteLine($"Task.Run is executing with ID {_id}");
            Thread.Sleep(100); // 模拟耗时操作
        });
    }

    ~MyClass()
    {
        Console.WriteLine("MyClass instance has been colleted.");
    }
}

或许是我表述的问题,更或许是我把原本是一篇的文章折成了两篇发布,造成了一些误解。所以在这里我对后两篇的内容再解释一下。

有的童鞋可能误解了这个示例要演示的是什么。我演示的是,myClass 实例对象不再需要使用时,GC 在其成员被捕获的情况下能否把它回收掉。我特意用 Test() 方法包装了一下 MyClass 实例的创建和调用,当 Test() 方法执行结束时,myClass 对象则变成了不再需要使用的对象。为了保证 GC 强制回收时,myClass 对象的成员是被引用(捕捉)着的,我在 Task.Run 的匿名方法中使用了 Thread.Sleep(100)

如果在 while 循环内不断执行强制回收或者在强制回收前等待足够长的时间,保证 Task.Run 执行完,myClass 对象当然会被回收,因为此时它不存在被不可回收的资源捕获的成员,这点我本以为不需要示例演示大家应该也是这么认为的。如果你了解 GC 的分代算法,你关注的会是,当 myClass 对象变成不再需要使用的资源时,它能否被 GC 在 Gen 0 阶段被回收;而不是关注它最终会不会被回收。

在实际 GC 自动回收的情况下(非手动强制回收),如果第一次扫描到 myClass 发现它被其它对象引用,则会把它标记为 Gen 1,再扫描到它时就会把它标记为 Gen 2。每错过一次回收时机,在内存驻留的时间就越长,它就越难被回收。GC 进行 Root 搜索时,它是否会去搜索某个对象是有统计学基础的。

好了,现在切入正题。问:示例中的现象在 .NET 中是否属于内存泄漏?

正题

我们知道,.NET 应用程序主要使用三种类型的内存:堆栈托管堆非托管堆。绝大多数我们在 .NET 中使用的引用类型都是分配在托管堆上的,例如本文示例中的 myClass 对象。发生在托管堆上的内存泄漏我们可以把它称为托管内存泄漏

关于 .NET 托管堆上的内存泄漏,我直接引用其它两篇文章的现象描述吧(文章地址在文末)。

第一篇描述的一个内存泄漏的现象是:

也说是在方法中捕获类成员的现象,和本文示例相符。如果对象不再需要使用了,你应该清除掉它“身上”的引用,以让 GC 在下一次搜索时把它回收掉。

第二篇(我的《为什么要小心使用Task.Run》文章就参考了这篇文章)是这样描述的:

和第一篇的意思差不多,也是说当对象实际上不再使用了,但因为它还被引用,GC 则不会回收它们,这种现象作者把它归为导致内存泄漏的一个主要原因。

第二篇文中还有这么一段:

翻译如下:

简单概括就是很多人认为托管内存泄漏不属于内存泄漏,这具有争议性,作者认为这是定义问题。

维基上的定义是这样的:

这个定义并没有对内存泄漏在时间上设限,请注意“由于疏忽或错误”和“不再使用”这两个重要关键词。”未能释放“是永久还是长时间?并没有明确定义。如果你要说我是在咬文嚼字,嗯,随你吧。

一个 .NET 应用,托管堆中处于 Gen 2 的未回收资源会有很多,其中基本上都是需要使用的。

不需要再使用的资源长时间驻留在内存的托管堆上,它逃过了 Gen 0,逃过了 Gen 1,甚至逃过了 N 次 Gen 2,亦或是仅仅延迟了一点点回收时间,这是否属于内存泄漏,存在很大的争议。我认为这也是定义问题,站在操作系统的视角和.NET托管堆的视角自然会得到不一样的理解。

就像最近头条上很多人对 1=0.999...(无限循环)这个数学问题的争议一样,有的人认为这个等式是对的,有的人认为它是错的。不同的角度,不同的定义,答案就不一样。

最后,我选择以托管堆的视角来理解,我的观点和第二篇引用文的作者一样,因编码不当导致不再需要使用的资源长时间驻留内存(延迟回收),属于内存泄漏。延迟回收也属于代码缺陷,虽然,很多场景大可不必在意这点性能。大家随意,哪种更能帮助你理解你便选择哪种。

文中链接:

[1]. http://dwz.date/d48W

[2]. http://dwz.date/d48U

附前两篇文章链接:

小心使用 Task.Run 续篇

小心使用 Task.Run 终篇解惑

12-11 08:19