前言

現在有很多系統除非太小,不然應該都會使用第三方開發的 DI 容器,來實現 IoC。但是使用 DI 容器時免不了要對於物件的生命週期有一定的了解,不然到時候出現相關的問題就手忙腳亂了。因此這一篇文章就記錄了我使用 Autofac 時,遇到的 memory leak 的問題,並把所做的實驗記錄下來。 實驗的情境主要是 run 在 console 環境中,模擬類似 windows service 的情境,所以 web 的 ASP.Net 環境並不在此實驗的考慮範圍內。 這篇文章使用的原始碼連結如下(已更新專案為 .NET 5):

GitHub repo url

內文

物件的生命週期與最初註冊物件的依賴是怎樣的方式,預設是 InstancePerDependency 這個方法,它會確保你每次呼叫 Resolve 方法時可以取得新的物件,並不會與其他呼叫端使用同一份實體。這個特性在大多數的時候都會符合需求。 因此在注入時我們先這樣寫:

ps.快照時間基本上都是10秒30秒各拍一次,執行超過50秒就結束實驗。

實驗一

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class DAModules : Autofac.Module
{
  protected override void Load(ContainerBuilder builder)
  {
    var assembly = Assembly.Load("NineYi.ERP.DA.ERPDB");
    builder.RegisterType<deamonresourcerepository>()
                    .As<ideamonresourcerepository>()
                    .InstancePerDependency();
  }
}

接下來就是我們這次的實驗目標的寫法:

  • 界面
1
2
3
4
5
6
7
8
/// <summary>
/// Iinterface of deamon resource repository
/// </summary>
public interface IDeamonResourceRepository: IDisposable
{
    /// Eat resource
    void ResourceMonster();
}
  • 實作
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/// <summary>
/// Deamon resource repository.
/// <seealso cref="NineYi.ERP.DA.ERPDB.Deamon.IDeamonResourceRepository" />
/// <seealso cref="System.IDisposable" />
/// </summary>
public class DeamonResourceRepository : IDeamonResourceRepository
{
    /// <summary>
    /// Gets the name.
    /// The name.
    /// </summary>
    public string Name { get { return typeof(DeamonResourceRepository).FullName; } }

    /// <summary>
    /// 序號 (為了輔助驗證取得的實體)
    /// </summary>
    public int Number { get; set; }

    /// <summary>
    /// 執行與釋放 (Free)、釋放 (Release) 或重設 Unmanaged 資源相關聯之應用程式定義的工作。
    /// </summary>
    public void Dispose()
    {
        Console.WriteLine(string.Format("{0} dispose!", this.Name));
    }

    /// <summary>
    /// Eat resource
    /// </summary>
    public void ResourceMonster()
    {
        this.Number += 1;

        Console.WriteLine(string.Format("DeamonResource Number:{0}", this.Number));
    }
}

接下來我們在呼叫端這樣寫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static void Main(string[] args)
{
    var builder = new ContainerBuilder();
    builder.RegisterModule<NineYi.ERP.DA.ERPDB.Modules.DAModules>();

    using (var container = builder.Build())
    {
      MemoryLeakMethod02(container);
    }
}

/// <summary>
/// 容易不自覺產生memory的語法
/// The container.
/// 重點是使用InstancePerDependency作為物件的生命週期,這會導致每次都會產生一個完全新的實體出來,
/// 就算有呼叫dispose,但是autofac會握有一份參考,導致這份實體並不會被GC真正回收。
/// </summary>
static void MemoryLeakMethod02(IContainer container)
{
    using (var lifetimescope = container.BeginLifetimeScope())
    {
        while (1 == 1)
        {
            using (var resource = lifetimescope.Resolve<NineYi.ERP.DA.ERPDB.Deamon.IDeamonResourceRepository>())
            {
                resource.ResourceMonster();
                System.Threading.Thread.Sleep(500);
            }
        }
    }
}

接下來我們開啟Visual Studio的 Analyze -> Performance Profiler -> 勾選memory usage 就可以開始觀察記憶體的使用了。

Profiler畫面(執行50+秒):

experiment1_profiler

執行畫面:

experiment1_exec_result

從這個執行畫面中我們可以看到一個現象,該實體的數字永遠都是回 1,表示每次取得的實體都是與上一個不同的實體。

實驗結果一

雖然有呼叫 dispose 但是還是可以看到記憶體不斷飆升(兩份快照相比後者比前者多了 20 以上的物件),因此可以預期的是不斷執行後一定會發生 OOM 的例外訊息

實驗二

我們改用 single instance 看看是否真的只會有那麼一個實體被生成出來

1
2
3
4
5
6
protected override void Load(ContainerBuilder builder)
{
  builder.RegisterType<deamonresourcerepository>()
                  .As<ideamonresourcerepository>()
                  .SingleInstance(); 
}

呼叫端則這樣寫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void Main(string[] args)
{
    var builder = new ContainerBuilder();
    builder.RegisterModule<NineYi.ERP.DA.ERPDB.Modules.DAModules>();

    using (var container = builder.Build())
    {
      MemoryLeakMethod02(container);
    }
}

/// <summary>
/// 與實驗一是一樣的寫法,只有物件的生命中期使用方式不同
/// 重點是使用 SingleInstance 作為物件的依賴方式,這會讓每次叫用的物件只會生成一份,
/// 所以看起來就沒有 memory leak 的問題發生。
/// </summary>
static void MemoryLeakMethod02(IContainer container)
{
    using (var lifetimescope = container.BeginLifetimeScope())
    {
        while (1 == 1)
        {
            using (var resource = lifetimescope.Resolve<NineYi.ERP.DA.ERPDB.Deamon.IDeamonResourceRepository>())
            {
                resource.ResourceMonster();
                System.Threading.Thread.Sleep(500);
            }
        }
    }
}

Profiler畫面(執行50+秒):

experiment2_Profiler

可以看得到兩份快照並無增加的物件,表示並不會隨著時間增加而讓物件在記憶體中不斷產生。

執行畫面:

experiment2_execresult

從這個畫面中我們可以看出每次 Number 屬性回應的數字都會累加,表明就算呼叫 dispose 後,依舊可以取得同樣一份實體。

實驗結果二

有呼叫 dispose,不過也因為 autofac 握有一份參考,所以應該還是沒有真正釋放物件,不過因為依賴方式使用 SingleInstance,所以執行再久並沒有 memory leak 的問題發生。

實驗三

這次我們改用 InstancePerLifetimeScope 這個依賴方式

1
2
3
builder.RegisterType<deamonresourcerepository>()
                  .As<ideamonresourcerepository>()
                  .InstancePerLifetimeScope();

依據官方說明文件:這個依賴方式會讓同一個 lifetimescope 中取得相同類別的物件,會取用同一份實體。不同的 lifetimescope 就算呼叫同一個類別也會使用同一份實體,因此我們可以用同樣的情境來驗證是否正確。 呼叫端我們這樣寫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void Main(string[] args)
{
    var builder = new ContainerBuilder();
    builder.RegisterModule<NineYi.ERP.DA.ERPDB.Modules.DAModules>();

    using (var container = builder.Build())
    {
        MemoryLeakMethod02(container);
    }
}

/// <summary>
/// 與實驗一是一樣的寫法,只有物件的依賴方式不同
/// 重點是使用 InstancePerLifetimeScope 作為物件的依賴方式,同一個 scope 下取得的實體將會相同。
/// </summary>
static void MemoryLeakMethod02(IContainer container)
{
    using (var lifetimescope = container.BeginLifetimeScope())
    {
        while (1 == 1)
        {
            using (var resource = lifetimescope.Resolve<NineYi.ERP.DA.ERPDB.Deamon.IDeamonResourceRepository>())
            {
                resource.ResourceMonster();
                System.Threading.Thread.Sleep(500);
            }
        }
    }
}

Profiler畫面(執行50+秒):

experiment3_profiler

執行畫面:

experiment3_execresult

這邊可以看到 Number 回應的數字不斷增長,表示就算有呼叫 dispose 方法,但是取得的實體還是同樣一份。

實驗解果三

有呼叫 dispose,但是因為 autofac 擁有一份參考,所以應該沒有真正釋放次件,但是應為依賴方式為 InstancePerLifetimeScope 所以並不會產生新的實體出來占用記憶體。

實驗四

我們回到一開始的物件依賴方式

1
2
3
builder.RegisterType<deamonresourcerepository>()
                  .As<ideamonresourcerepository>()
                  .InstancePerDependency();

但是接下來我們的呼叫端就改成這樣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void Main(string[] args)
{
    var builder = new ContainerBuilder();
    builder.RegisterModule<NineYi.ERP.DA.ERPDB.Modules.DAModules>();

    using (var container = builder.Build())
    {
        MemoryLeakMethod02(container);
    }
}

/// <summary>
/// 使用 Child Scope 來管理這區域中產生的物件,可以確保物件在這個區域使用完畢後會被釋放
/// 把 using 的範圍用在 Lifetimescope,而不是產生出來的物件
/// </summary>
private static void NoMemoryLeakMethod02(IContainer container)
{
     using (var lifetimescope = container.BeginLifetimeScope())
     {
          while (1 == 1)
          {
               using (var childScope = lifetimescope.BeginLifetimeScope())
               {
                    using (var resource = childScope.Resolve<NineYi.ERP.DA.ERPDB.Deamon.IDeamonResourceRepository>())
                    {
                         resource.ResourceMonster();
                         System.Threading.Thread.Sleep(500);
                    }
               }
          }
     }
}

Profiler畫面(執行50+秒):

experiment4_profiler

雖然圖示看起來記憶體有偏高,但是兩份快照顯示物件並沒有增加。

執行畫面:

experiment4_execresult

這個實驗我們可以發現 Dispose 方法被呼叫了兩次!一次是內圈的 childScope 結束 using 範圍時呼叫,另一次是外圈的 lifetimescope 結束 using 範圍時呼叫!而 Number 回應的數字也都是 1,表明了每次都是取得新的實體。

實驗結果四

我們可以發現 dispose 有被呼叫,而且記憶體也沒有逐漸上升,代表每個新產生的物件有確實的被回收了!這是因為我們這次的 using 是包在 lifetimescope 中,而不是產生出來的實體,在 lifetimescope 結束的當下,他會負責把在他當中產生的實體也一起回收,而在沒有人握有實體的參照,又呼叫 dispose 時 GC 就會把這份實體的資源給回收回去,因此就不會讓記憶體持續累積下去。

以上四組實驗我們可以發現一個基本的 autofac 在物件的生命週期中,所造成的影響。關於這個主題還有很多可以探討的問題,像是官網中還提到的另外幾種依賴的方式,還有不同的依賴方式適合用在什麼情境中,還有不同的應用系統適合用什麼樣的依賴方式。

對於 Autofac 有研究的人,歡迎在下方留言或是寄信給我一起討論,謝謝~!