小心使用 Task.Run 解惑篇
阿新 • • 發佈:2020-12-09
繼[上一篇文章](https://mp.weixin.qq.com/s/BjOuUUOvdllOV71Zj7jQcg)之後,這篇文章主要解答以下兩個疑惑:
1. 由於值型別是拷貝的方式賦值,所以捕獲的本地變數和類成員是指向的是各自的值,對本地變數的捕獲不會影響到整個類。但如果把 `_id` 改為引用型別(如 StringBuilder),那兩者指向的就是同一個物件值,那是不是意味著即便使用本地變數也還是無法避免記憶體洩漏的問題?
2. GC 第一次回收時發現 `myClass` 例項存在被捕獲的成員,則認為它不應該被回收。那當 `Task.Run` 執行完後,GC 再次搜尋時不就可以回收 `myClass` 物件嗎?只是晚了一些時間回收而已。
為了方便理解,我再把昨天的關鍵程式碼貼出來:
```csharp
public class MyClass
{
private int _id;
public Task Foo()
{
var localId = _id;
return Task.Run(() =>
{
Console.WriteLine($"Task.Run is executing with ID {localId}");
Thread.Sleep(100); // 模擬耗時操作
});
}
}
```
先來看第一個疑惑。經實測,把 `_id` 改為 `StringBuilder` 型別執行結果是和 `int` 一樣的,說明和值型別或引用型別無關。我的理解是這樣的:
我們知道,引用型別的變數在宣告的時候就會在棧中分配一個空間,用來存放地址引用,而給它的賦值則儲存在託管堆中。雖然本地變數 `localId` 和類的成員 `_id` 的地址都指向的是託管堆中同一塊空間,但他們在棧中的地址卻分屬不同的作用域。所謂被捕獲就是被作用域捕獲,當一個作用域結束時,該作用域內的成員的地址空間都會隨著一起被釋放。至於地址指向的託管堆中的字串值,則不是作用域關心的事情。當該字串值所在的空間沒有地址指向它時,就會被 GC 回收。 有點抽象,但應該還好理解。
再來看第二個疑惑。在此之前,我們先來了解一下 GC 的分代演算法。
當 CLR 試圖搜尋不再使用的物件的時,它需要遍歷託管堆上的物件。隨著程式的持續執行,託管堆可能越來越大,如果要對整個託管堆進行垃圾回收,勢必會嚴重影響效能。所以,為了優化這個過程,CLR 中使用了**分代演算法**。
簡單來說,分代演算法就是把記憶體中的資源劃分為三代:Gen 0、Gen 1、Gen 2,它們被 GC 遍歷的頻率依次從高到低。所有新建立的物件屬於 Gen 0,GC 掃描它的頻率最高。進行一次掃描後,處於 Gen 0 的不可回收物件就會被標記為 Gen 1。類似的,GC 掃描 Gen 1 時,如果 Gen 1 的物件依然不可回收,就會標記為 Gen 2。有點像馬太效應,資源停留在記憶體時間越長,就越不容易被回收。
![alt](https://w-share.oss-cn-shanghai.aliyuncs.com/net-mem-06-generation.png)
Gen 2 的回收被稱為 **Full GC**。而 Full GC 只有在滿足一定的條件才會執行,具體請閱讀這篇官方文件:
```
https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/notifications#full-garbage-collection
```
也就是說,進入 Gen 2 的資源,若條件沒有達到,就會一直不被回收。
理解了分代演算法和 Full GC,第二個疑惑就迎刃而解了。第二個疑惑關鍵在三個時間點上:
1. `myClass` 物件作用域結束的時間點
2. GC 執行回收的時間點
3. `Task.Run` 匿名方法執行完成的時間點
如果程式執行的時間點順序是:1、3、2,那麼不會有記憶體漏洩的問題,這點很容易理解。
由於實際情況 `Task.Run` 一般為耗時操作(非耗時任務一般沒有必要使用 `Task.Run`),所以時間點的順序極有可能是:1、2、3。如果是此執行的順序,那麼 GC 在回收時就會因為 `myClass` 物件存在成員被引用而把它標記為 Gen 1。如果 Task.Run 耗時足夠長, `myClass` 就可能會進入 Gen 2,進而可能很難被回收,甚至可能永遠不被回收。
其實大部分場景,我們也不必過於小心,即使在 `Task.Run` 匿名方法捕獲了類的成員使該類的例項進入了 Gen 2,Gen 2 中留存的不再使用的資源也是有限的。根據官方文件對 Full GC 的介紹(地址在前文),當 Gen 2 積累到一定的量時便滿足了執行回收的條件,在 GC 下一次回收時便會回收 Gen 2 中不再使用的資源。當然,作為一個優秀的程式設計師,我們還是得養成好的編碼習慣,不要在 `Task.Run` 中的匿名方法捕獲類的成員。
最後,鄭重宣告,最近三篇關於小心使用 Task.Run 的文章皆屬我個人理解,知識水平有限,難免存在遺漏和錯誤。若有發現,請大家不吝指正。
PS:本人部落格園文章一般晚於公眾號一天釋出,望大家見諒。關於是否屬於記憶體洩漏問題,我在今天的文章中有討論:[《.NET記憶體洩漏的爭議》](https://mp.weixin.qq.com/s/2vHe7qA08-3CfDU9F5NYoA)