小心使用 Task.Run 續篇

關於前兩天發佈的文章:為什麼要小心使用 Task.Run,對文中演示的示例到底會不會導致內存泄露,給很多人帶來了疑惑。這點我必須向大家道歉,是我對導致內存泄漏的原因沒描述和解釋清楚,也沒用實際的示例證實,是我的錯。

但是,文中示例演示的 Task.Run 捕獲類成員的情況,確實會有內存泄漏的風險,我將在本文演示給大家看。

如果一個對象(或數據)不需要再使用了,但依然還一直佔據內存空間,則視為內存泄漏。這一點大家觀點是一致的吧,那如何來檢測對象有沒有被回收呢?

我們知道,在 C# 中,實例對象被釋放回收,必然會執行析構函數。所以我們可以對一個類重寫其析構函數,如果該類的實例對象使用完後,強制執行 GC 回收,其析構函數依然不被執行,則說明 GC 沒有回收該對象。若 GC 後面一直不回收這個對象,則說明存在內存泄漏。

手動強制執行 GC 回收的代碼如下:

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

這三句代碼可以確保 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;
    private List<string> _list;

    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 回收,運行結果如下:

我們看到 MyClass 的析構函數一直沒有執行,也就意味着它的實例一直沒有被回收。

現在我們修改 MyClass 類的 Foo 方法,改用本地(局部)變量試一試:

...
public Task Foo()
{
    var localId = _id;
    return Task.Run(() =>
    {
        Console.WriteLine($"Task.Run is executing with ID {localId}");
    });
}
...

再運行看看效果:

這次我們可以看到,MyClass 的析構函數執行了,說明實例對象被回收了。

前後唯一區別是,前者在 Task.Run 的匿名方法中捕獲了類的成員,而後者使用了本地變量。前者出現了內存泄漏,後者避免了內存泄漏。

所以,在 Task.Run 的匿名方法中捕獲類的成員,確實有可能導致內存泄漏(注意是有可能而不是一定)。

那背後的原因是什麼呢?我在上一篇文章是這樣解釋的:

私有成員 _idTask.Run 的匿名方法捕獲使用,進而導致 MyClass 實例被引用。當外部使用完 MyClass 實例時,本該由 GC 回收的時候卻發現它還被其它資源引用着,所以 GC 認為該實例不應該被回收,也就可能永遠失去了被回收的機會。

這個解釋有很大的問題,至少給廣大讀者帶來了兩大疑惑:

  1. 由於值類型是拷貝的方式賦值,所以捕獲的本地變量和類成員指向的是各自的值,對本地變量的捕獲不會影響到整個類。但如果把 _id 改為引用類型(如 String),那兩者指向的就是同一個對象值,那是不是意味着即便使用本地變量也還是無法避免內存泄漏的問題?
  2. GC 第一次回收時發現 myClass 實例存在被捕獲的成員,則認為它不應該被回收。那當 Task.Run 執行完後, 被捕獲的成員也使用完了,GC 再次搜索時不就可以回收 myClass 對象嗎?只是晚了一些時間回收而已嘛。

感謝善於思考提出疑惑的讀者們,為你們點贊。

這兩大疑惑該如何解釋?後半部分我還沒寫完,大家可以先思考一下,我將在下一篇給大家解惑,望見諒。當然,我的解釋也不一定會是對的,希望大家帶着懷疑的態度和批判性思維來看我的文章,也請大家分享自己的理解和觀點。