小心使用 Task.Run 續篇
阿新 • • 發佈:2020-12-08
關於前兩天釋出的文章:[為什麼要小心使用 Task.Run](https://mp.weixin.qq.com/s/jZWXJoQ2tJD71LMf7QqeiA),對文中演示的示例到底會不會導致記憶體洩露,給很多人帶來了疑惑。這點我必須向大家道歉,是我對導致記憶體洩漏的原因沒描述和解釋清楚,也沒用實際的示例證實,是我的錯。
但是,文中示例演示的 `Task.Run` 捕獲類成員的情況,**確實會有記憶體洩漏的風險**,我將在本文演示給大家看。
如果一個物件(或資料)不需要再使用了,但依然還一直佔據記憶體空間,則視為記憶體洩漏。這一點大家觀點是一致的吧,那如何來檢測物件有沒有被回收呢?
我們知道,在 C# 中,例項物件被釋放回收,必然會執行**解構函式**。所以我們可以對一個類重寫其解構函式,如果該類的例項物件使用完後,強制執行 GC 回收,其解構函式依然不被執行,則說明 GC 沒有回收該物件。若 GC 後面一直不回收這個物件,則說明存在記憶體洩漏。
手動強制執行 GC 回收的程式碼如下:
```csharp
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
```
這三句程式碼可以確保 GC 把所有能搜尋到的可回收物件清理乾淨。注意:不推薦在生產環境這樣寫。
我們還是用 [為什麼要小心使用 Task.Run](https://mp.weixin.qq.com/s/jZWXJoQ2tJD71LMf7QqeiA) 這篇文章用到的示例,只是為了測試稍加修改了一下:
```csharp
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 _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 回收,執行結果如下:
![](https://w-share.oss-cn-shanghai.aliyuncs.com/20201206143042.png)
我們看到 `MyClass` 的解構函式一直沒有執行,也就意味著它的例項一直沒有被回收。
現在我們修改 `MyClass` 類的 `Foo` 方法,改用本地(區域性)變數試一試:
```csharp
...
public Task Foo()
{
var localId = _id;
return Task.Run(() =>
{
Console.WriteLine($"Task.Run is executing with ID {localId}");
});
}
...
```
再執行看看效果:
![](https://w-share.oss-cn-shanghai.aliyuncs.com/20201206155248.png)
這次我們可以看到,`MyClass` 的解構函式執行了,說明例項物件被回收了。
前後唯一區別是,前者在 `Task.Run` 的匿名方法中捕獲了類的成員,而後者使用了本地變數。前者出現了記憶體洩漏,後者避免了記憶體洩漏。
所以,在 `Task.Run` 的匿名方法中捕獲類的成員,確實**有可能**導致記憶體洩漏(注意是**有可能**而不是**一定**)。
那背後的原因是什麼呢?我在上一篇文章是這樣解釋的:
> 私有成員 `_id` 被 `Task.Run` 的匿名方法捕獲使用,進而導致 `MyClass` 例項被引用。當外部使用完 `MyClass` 例項時,本該由 GC 回收的時候卻發現它還被其它資源引用著,所以 GC 認為該例項不應該被回收,也就可能永遠失去了被回收的機會。
這個解釋有很大的問題,至少給廣大讀者帶來了兩大疑惑:
1. 由於值型別是拷貝的方式賦值,所以捕獲的本地變數和類成員指向的是各自的值,對本地變數的捕獲不會影響到整個類。但如果把 `_id` 改為引用型別(如 String),那兩者指向的就是同一個物件值,那是不是意味著即便使用本地變數也還是無法避免記憶體洩漏的問題?
2. GC 第一次回收時發現 `myClass` 例項存在被捕獲的成員,則認為它不應該被回收。那當 `Task.Run` 執行完後, 被捕獲的成員也使用完了,GC 再次搜尋時不就可以回收 `myClass` 物件嗎?只是晚了一些時間回收而已嘛。
感謝善於思考提出疑惑的讀者們,為你們點贊。
這兩大疑惑該如何解釋?後半部分我還沒寫完,大家可以先思考一下,我將在下一篇給大家解惑,望見諒。當然,我的解釋也不一定會是對的,希望大家帶著懷疑的態度和批判性思維來看我的文章,也請大家分享自己的理解和觀點。