.NET 記憶體洩漏的爭議

精緻碼農發表於2020-12-10

前幾天釋出了幾篇關於要小心使用 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 託管堆上的記憶體洩漏,我直接引用其它兩篇文章的現象描述吧(文章地址在文末)。

第一篇[1]描述的一個記憶體洩漏的現象是:

If the reference is stored in a field reference in the class where the method is declared, it’s not so smart, since it’s impossible to determine whether it will be reused later on, or at least very very hard. If this data structure becomes unnecessary, you should clear the reference you’re holding to it so that GC will pick it up later.

也說是在方法中捕獲類成員的現象,和本文示例相符。如果物件不再需要使用了,你應該清除掉它“身上”的引用,以讓 GC 在下一次搜尋時把它回收掉。

第二篇[2](我的《為什麼要小心使用Task.Run》文章就參考了這篇文章)是這樣描述的:

There are 2 related core causes for memory leaks. The first core cause is when you have objects that are still referenced but are effectually unused. Since they are referenced, the GC won’t collect them and they will remain forever, taking up memory. This can happen, for example, when you register to events but never unregister. Let’s call this a managed memory leak.

和第一篇的意思差不多,也是說當物件實際上不再使用了,但因為它還被引用,GC 則不會回收它們,這種現象作者把它歸為導致記憶體洩漏的一個主要原因。

第二篇[2]文中還有這麼一段:

Many share the opinion that managed memory leaks are not memory leaks at all since they are still referenced and theoretically can be de-allocated. It’s a matter of definition and my point of view is that they are indeed memory leaks. They hold memory that can’t be allocated for another instance and will eventually cause an out-of-memory exception.

翻譯如下:

很多人都認為,託管記憶體洩漏根本不是記憶體洩漏,因為它們仍然被引用,理論上可以去分配。這是一個定義的問題,我的觀點是,它們確實是記憶體洩漏。它們持有的記憶體無法分配給另一個例項,最終可能會造成記憶體溢位異常。

簡單概括就是很多人認為託管記憶體洩漏不屬於記憶體洩漏,這具有爭議性,作者認為這是定義問題。

維基上的定義是這樣的:

記憶體洩漏(Memory leak)是在電腦科學中,由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體。

這個定義並沒有對記憶體洩漏在時間上設限,請注意“由於疏忽或錯誤”和“不再使用”這兩個重要關鍵詞。”未能釋放“是永久還是長時間?並沒有明確定義。如果你要說我是在咬文嚼字,嗯,隨你吧。

一個 .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 終篇解惑

相關文章