穿過茂密的樹影與輕柔的晨霧,你走進了一座靜謐的森林。

那裡沒有 bug,沒有 exception,只有陽光灑落的參天樹、木製窗框散出香氣的小屋

你打開門,進入了一間森林旅宿。櫃檯小姐微笑地遞給你一張房卡,你今晚的房間已經準備好了。

對旅宿而言,房間是 Singleton 生命週期的服務,從你入住前到離開後,它始終如一地在那裡 —— 只建一次,長存不變

你看著桌上放著一組乾淨的 Scoped 盥洗用品:牙刷、毛巾、小瓶洗髮精。它們會陪你走完這段住宿時光,使用多少、如何使用,全由你決定。但當你退房時,它們也會一起離開,下一位旅人會拿到全新的組合。

角落還放著衛生紙,柔軟、乾淨,隨時可以撕下一張來使用。你不會重複用它,也不會留著它,只會在需要的時候取一張。這,就是 Transient:臨時、即用即丟、毫無記憶的存在。

不同的服務有不同的生命週期,就像不同的物品有不同的使用方式 —— 如果錯把盥洗用品作為 Singleton 使用,那麼下一位旅客來到時,拿到的可能就是使用過得噁心牙刷。

設計生命週期的本質,不是為了節省資源,而是為了讓每一段旅程,都能乾淨、清晰、可控地發生。
請進吧,旅人。我們即將在這座森林旅宿裡,一起理解生命的長短、服務的界線



🌴 Singleton 服務引用 Scoped 物件:潛藏的危機

  • ❌ 記憶體洩漏(Memory Leak) : Singleton 不會釋放,但你卻把 Scoped 物件包進去。例如 DbContext 有追蹤變更、快取記錄,結果被 Singleton 抓住不放。時間一久,這些物件就堆積如山,GC 無法回收,記憶體爆炸!
  • ❌ 多執行緒錯誤(Thread Safety Issue): Singleton 是全域共用,可能同時被多個執行緒呼叫。但像 DbContext 這種 Scoped 類型的服務 不是 thread-safe 的!多執行緒存取可能造成資料錯亂、例外錯誤(例如:InvalidOperationException)。

這行為也通常會讓程式啟動直接炸掉

.NET DI 容器會直接幫你擋下來:

❗Cannot consume scoped service ‘MyDbContext’ from singleton ‘MySingletonService’

這是 ASP.NET Core 很聰明的保護機制,避免你做出生命週期違規的設計。



💡延遲解析(Lazy Resolve)

在 ASP.NET Core Singleton 生命週期服務引用 Scoped 物件

有時候我們還是需要在 Singleton 中使用 Scoped 類型的服務,例如:

  • 背景服務要操作資料庫(DbContext 是 Scoped)
  • 全域服務要根據使用者狀態,叫用不同的邏輯類別(例如 Scoped 的策略)

這時候就不能硬塞在建構子裡,我們要採用一個關鍵技巧 - 延遲解析(Lazy Resolve) + Scope 控制
也就是說,不要「一開始」就解析出 Scoped 物件,而是「需要的時候再拿」,並且包在 CreateScope() 內。

✅ 使用 IServiceProvider 的標準做法

  1. 注入 IServiceProvider(這是根容器)
  2. 呼叫 CreateScope() → 產生一個新的「生命週期範圍」
  3. 在該範圍內透過 scope.ServiceProvider.GetRequiredService() 解析 Scoped 物件
  4. 用完後,using 區塊結束,Scope 被釋放,Scoped 物件會自動 Dispose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

public class MySingletonService
{
private readonly IServiceProvider _serviceProvider;

public MySingletonService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public void DoSomething()
{
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<WebStoreDBContext>();
// 使用 dbContext 安全地進行資料操作
}
}
}



🎯 實戰:Resolver 模式 + 延遲解析

想像有很多 RD 工程師的服務(Winston、Bruce 等)實作同一個 RDInterface,這些是 Scoped 的,契約終止表示著實體服務的結束,現在你要在 Singleton 的 BackgroundService 中選一位出來執行任務,我們想要藉由簡單的指名,就能解析出對應的 RD,因此自己建立了一個 Resolver

1
2
3
4
5
6
7
8
9
10
11

.ConfigureServices((hostContext, services) =>
{
var configuration = hostContext.Configuration;
services.AddDbContext<WebStoreDBContext>(options => options.UseSqlServer(configuration.GetConnectionString("WebStoreDB")));
services.AddHostedService<MemoryUsageMonitor>();
services.AddScoped<RDInterface, Winston>();
services.AddScoped<RDInterface, Bruce>();
services.AddSingleton<IRDResolver, RDResolver>();
})

Resolver 實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

public class RDResolver : IRDResolver
{
private readonly IServiceProvider _rootProvider;

public RDResolver(IServiceProvider rootProvider)
{
_rootProvider = rootProvider;
}

public RDInterface Resolve(string name)
{
var scope = _rootProvider.CreateScope();
var services = scope.ServiceProvider.GetServices<RDInterface>();

var target = services.FirstOrDefault(rd => rd.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (target == null)
throw new ArgumentException($"找不到名稱為 {name} 的 RDInterface 實例。");

return target;
}
}

在 Singleton 服務中使用:

1
2
3
4
5
6

using (var scope = _serviceProvider.CreateScope())
{
var bruce = _rdResolver.Resolve("Bruce");
bruce.WriteCSHARP();
}
問題 正確心法
Singleton 想用 Scoped? ❌ 不可直接注入實例
✅ 要透過 IServiceProvider.CreateScope() 做延遲解析
Scoped 想要支援多實作動態選擇? 使用 Resolver 模式(名稱/策略)
什麼時候該解析? 不要在 Singleton 建構時解析 Scoped,要等到真正需要時才解析(Lazy Resolve)


🚫 為什麼不能直接 services.GetRequiredService();

這樣等於從「根容器」直接解析 Scoped 物件,會讓 Scoped 實例失去生命週期控制,變成意外的 Singleton,導致記憶體洩漏、快取殘留、執行緒衝突等問題。尤其像 DbContext 這種會追蹤狀態、支援交易、管理資料列的服務,更需要用完就釋放,乾乾淨淨。



🧪 生命週期驗證實驗:我們真的只養了一隻嗎?

我們聊了這麼多生命週期的概念,是時候來驗明正身了。

說自己是 Singleton 不稀奇,關鍵是你是不是真的「全場只出現一次」?

為了觀察生命週期的具體行為,我們建立了一個簡單的服務 FindLover,它會在建構時輸出一個唯一的 Guid,讓我們能從輸出內容判斷:這隻服務是 全場唯一,還是 每次呼叫都重新出生的小可愛。

建立一組 Service / Interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface FindLoverInterface
{
Guid Id { get; }
}

public class FindLover : FindLoverInterface
{
public Guid Id { get; } = Guid.NewGuid();

public FindLover()
{
System.Console.WriteLine($"Lover Service is created : {Id}");
}
}

🟥 測試一:註冊為 Singleton

1
services.AddSingleton<FindLoverInterface, FindLover>();

執行三次解析,觀察結果:

1
2
3
4
Lover Service is created : a40404ce-51c7-4922-8991-b1b28a601d18
Service 0 ID: a40404ce-51c7-4922-8991-b1b28a601d18
Service 1 ID: a40404ce-51c7-4922-8991-b1b28a601d18
Service 2 ID: a40404ce-51c7-4922-8991-b1b28a601d18

驗證結果:真‧單一實例。FindLover 只被建立一次,三次取用都是同一個 ID,正如我們期待的 Singleton 行為:出生一次、全站通用、不離不棄。

🟨 測試二:註冊為 Transient

1
services.AddTransient<FindLoverInterface, FindLover>();

再試一次呼叫三次服務,這回呢?

1
2
3
4
5
6
Lover Service is created : 3fdba22c-157b-4c96-8da0-9d9d0e123518
Lover Service is created : 502c1f4b-c034-402a-9a56-a4cfc4dabfe8
Lover Service is created : 1afdf1da-860a-4a03-9f9d-7ca9a26cb6e8
Service 0 ID: 3fdba22c-157b-4c96-8da0-9d9d0e123518
Service 1 ID: 502c1f4b-c034-402a-9a56-a4cfc4dabfe8
Service 2 ID: 1afdf1da-860a-4a03-9f9d-7ca9a26cb6e8

驗證結果:每次都不一樣。這就是 Transient 的特性 —— 每次呼叫,都是一次新的開始,像擦手紙一樣用完就丟,下一次會是全新的你(和 GUID)。

🌱 那 Scoped 呢?

如果應用程式沒有開 Scope,它就會像 Singleton 一樣全站共用



🌴 執行緒安全:大家共用不等於大家和平共處

Singleton 不代表 thread-safe,Scoped 如果沒開 scope 也可能像 Singleton,Transient 則是每次新建完全沒問題。

那接下來,要進入真正的地雷區了 —— 多執行緒環境下的共用狀態會不會爆炸?

想像一間辦公室裡放了一張白板,所有同事都可以隨時寫上去。但大家沒有協調,也沒人在乎邏輯順序,甚至有人還邊寫邊擦,結果怎樣?
白板沒壞,資料壞了。這就是執行緒不安全(thread-unsafe)帶來的典型場景。

但我們知道,框架幫我們處理了這個部分,.NET Core DI(內建依賴注入容器)建立的 Singleton 是 thread-safe 的我們就來驗證一下

🧪 實驗一:Parallel.For 中的 Service 取得行為

我們使用 Parallel.For(同步 API + 多執行緒) 來測試,Parallel.For 是 System.Threading.Tasks.Parallel 裡的一個靜態方法。它利用了 ThreadPool 和 Task Parallel Library (TPL) 的技術。底層其實會像這樣操作:

  • 決定有多少 thread 可以同時用(通常跟 CPU 核心數有關)。
  • 將你給的 i 值 (像是迴圈那個 i 的概念) 分派給多條 thread 去跑。
  • 所以不保證執行順序,也不保證一個 thread 只做一件事。

Parallel.For 是「同步執行流程、但在內部幫你用多執行緒同時處理每一筆資料」的工具,它不是逐步一個個跑的。

1
2
3
4
5
6
7

Parallel.For(0, 10, index =>
{
var svc = serviceProvider.GetRequiredService<FindLoverInterface>();
System.Console.WriteLine($"Thread {index}: Service ID = {svc.Id}");
});

Singleton

1
2
3
4
5
6
7
8
9
10
Thread 1: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 2: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 3: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 0: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 5: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 7: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 6: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 4: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 8: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d
Thread 9: Service ID = b428f9d9-02e3-4378-8ebc-c3dd8c32768d

✔ 結論:大家都拿到同一個實例。就像大家共用一支 Wi-Fi,只是大家下載東西的速度可能不一樣。

Scoped(未開 scope):意外變成 Singleton

1
2
3
4
5
6
7
8
9
10
11
Lover Service is created : 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 1: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 3: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 6: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 7: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 2: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 5: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 0: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 4: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 9: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8
Thread 8: Service ID = 60851d37-7578-4ad6-bd21-f46daffe85f8

👉 你會發現所有執行緒拿到的仍然是同一個實例。
這是因為在沒有呼叫 CreateScope() 的情況下,serviceProvider 本身就是 root scope,所有的 Scoped 服務其實會像 Singleton 一樣「只建一個」。

Scoped(開啟 scope)
CreateScope()

1
2
3
4
5
6
Parallel.For(0, count, i =>
{
using var scope = serviceProvider.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<FindLoverInterface>();
System.Console.WriteLine($"Thread {i}: Service ID = {svc.Id}");
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Lover Service is created : 0827b278-cc43-4bdd-a521-fbbc0c56b10c
Lover Service is created : 240ed452-9d3e-4a18-9a84-df042f08cf71
Lover Service is created : e752bb4f-6be7-4984-a3cc-1a638bcc879c
Lover Service is created : 002d0041-43ab-413f-95b4-93e82212a297
Lover Service is created : 6daab032-e1b7-4ae4-b8f4-267b385f801c
Lover Service is created : e96d4ed7-219d-4d2b-9ef3-8da729e57174
Lover Service is created : ba899eab-d4d8-4ba7-8a97-8fc8e5b06db1
Lover Service is created : 5fcbdfa6-dfc1-4975-a0fe-4eed167aac94
Lover Service is created : 8bd944a1-4bce-4c20-944e-6e143166f61b
Lover Service is created : 996e3373-77ce-4e88-ae18-e183d17d25f6
Lover Service is created : 4b791f48-25ca-4378-8566-89d8d1a7190d
Thread 4: Service ID = 002d0041-43ab-413f-95b4-93e82212a297
Thread 6: Service ID = 240ed452-9d3e-4a18-9a84-df042f08cf71
Thread 9: Service ID = e96d4ed7-219d-4d2b-9ef3-8da729e57174
Thread 7: Service ID = e752bb4f-6be7-4984-a3cc-1a638bcc879c
Thread 0: Service ID = 6daab032-e1b7-4ae4-b8f4-267b385f801c
Thread 1: Service ID = 996e3373-77ce-4e88-ae18-e183d17d25f6
Thread 3: Service ID = 8bd944a1-4bce-4c20-944e-6e143166f61b
Thread 8: Service ID = ba899eab-d4d8-4ba7-8a97-8fc8e5b06db1
Thread 2: Service ID = 5fcbdfa6-dfc1-4975-a0fe-4eed167aac94
Thread 5: Service ID = 4b791f48-25ca-4378-8566-89d8d1a7190d

這下就正確地發揮 Scoped 的本意了!每一個 scope 都像是一個小型的「Request 生命週期」,內部使用的 Scoped 服務也就不會共用同一個實例。

Transient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Lover Service is created : 7fa7e822-e2e5-49f3-8128-410fe656d04f
Lover Service is created : ba767f56-ef68-4bb6-ae60-988adc5bc5d4
Lover Service is created : 5c0a8585-cb7f-4829-ba44-5e3273dbc161
Lover Service is created : 3a2410ab-5043-482e-9fd5-00fb73c66d40
Lover Service is created : cbcbb913-2549-4dc8-b064-4115f2eaf8a6
Lover Service is created : 9746a516-d4ec-4f9d-9106-ed598eed2ebf
Lover Service is created : ce3e602d-f345-4143-a110-15c01eebf4c4
Thread 2: Service ID = ba767f56-ef68-4bb6-ae60-988adc5bc5d4
Thread 9: Service ID = 5c0a8585-cb7f-4829-ba44-5e3273dbc161
Thread 3: Service ID = 3a2410ab-5043-482e-9fd5-00fb73c66d40
Lover Service is created : 5b7e1606-8bd2-41dc-9a0d-3992ddbc10de
Thread 1: Service ID = cbcbb913-2549-4dc8-b064-4115f2eaf8a6
Thread 5: Service ID = 5b7e1606-8bd2-41dc-9a0d-3992ddbc10de
Thread 0: Service ID = ce3e602d-f345-4143-a110-15c01eebf4c4
Lover Service is created : 030c489b-0da8-46b2-bbbf-78fab6b5e8d3
Lover Service is created : b0177ff6-8806-4113-98b0-0ddac7024b38
Lover Service is created : bc65f658-58d0-4176-b3c1-95f2e87091e0
Thread 6: Service ID = 9746a516-d4ec-4f9d-9106-ed598eed2ebf
Thread 4: Service ID = 030c489b-0da8-46b2-bbbf-78fab6b5e8d3
Thread 7: Service ID = b0177ff6-8806-4113-98b0-0ddac7024b38
Thread 8: Service ID = bc65f658-58d0-4176-b3c1-95f2e87091e0

✔ 結論:每個執行緒拿到的是不同的實例。像是 10 個人走進咖啡店,每人都點一杯獨一無二的手沖咖啡。

總結

  • Singleton 像是公用飲水機,誰來都用那桶水。
  • Transient 是每個人買自己的瓶裝水。
  • Scoped 呢?如果你不開 scope,就像大家共用同一壺泡好的茶;但如果你開了 scope,每個人就自己泡一壺。


🌴 同一個實例,不表示執行緒安全

Thread-safety 分成兩個層面來看:

面向 是否 thread-safe 說明
DI 容器的行為 ✅ 是 當你註冊 AddSingleton<>() 時,容器只會建立一次該物件實例,而且建構過程是 thread-safe 的。
你的 Singleton 物件內部行為 ❌ 不一定 如果你的 Singleton 裡有「共享狀態」(像是 List、字典、計數器等),你要自己保證它們是 thread-safe 的

生命週期會決定物件是否被共用,而執行緒安全問題會在「共用同一個實例 + 同時操作它」時發生。
在多執行緒環境下,如果你的物件裡有「共用資料」但沒有任何同步機制,那你就像讓一群人同時在白板上寫字,而且不打草稿、不協調、還用力塗改。

結果會發生什麼事呢?

😵‍💫 可能出現的災情

  • ❌ 例外拋出(像 InvalidOperationException, ArgumentException…)
  • ❌ 資料遺失(資料沒加進去或被覆蓋)
  • ❌ 結果不一致(跑一次對、跑一次錯,看心情)

🧪 實驗:共用狀態(Singleton) + Parallel.For

我們來修改 FindLover 服務,內部保有一個 List,並且多執行緒同時加入資料:

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
public class FindLover : FindLoverInterface
{
private List<string> _data = new List<string>();
public Guid Id { get; } = Guid.NewGuid();

public FindLover()
{
System.Console.ForegroundColor = ConsoleColor.Cyan;
System.Console.WriteLine($"Lover Service is created : {Id}");
System.Console.ResetColor();
}

public void AddSomthing(int addCount)
{
_data.Add($"Data from thread {addCount}");
}
public void PrintResult(int expected)
{
System.Console.ForegroundColor = ConsoleColor.Yellow;
System.Console.WriteLine($"Expected count: {expected}");
System.Console.WriteLine($"Actual count: {_data.Count}");
System.Console.ResetColor();
if (_data.Count != expected)
{
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.WriteLine("Data is corrupted due to lack of thread safety!");
System.Console.ResetColor();
}
else
{
System.Console.ForegroundColor = ConsoleColor.Green;
System.Console.WriteLine("Data integrity is correct (by luck or protection)");
System.Console.ResetColor();
}
}
}

執行程式

1
2
3
4
5
6
7
8
9
var svc = serviceProvider.GetRequiredService<FindLoverInterface>();
int count = 10_000;

Parallel.For(0, count, i =>
{
svc.AddSomthing(count);
});

svc.PrintResult(count);

結果

1
2
3
4
Lover Service is created : 28bb54c9-3c90-4857-bdc3-8bc938043c15
Unhandled exception. System.AggregateException: One or more errors occurred. (Source array was not long enough. Check the source index, length, and the array's lower bounds. (Parameter 'sourceArray'))
---> System.ArgumentException: Source array was not long enough. Check the source index, length, and the array's lower bounds. (Parameter 'sourceArray')
at System.Array.CopyImpl(Array sourceArray, Int32 sourceIndex, Array destinationArray, Int32 destinationIndex, Int32 length, Boolean reliable)

👉 這是典型的執行緒安全問題,因為 List 在還沒長好(resize 的中途)時,其他執行緒就跑來塞資料,導致「認知不一致」,進而拋出例外。

為什麼會這樣?

List 不是執行緒安全的結構。它內部有一個陣列 _items[],當容量不夠時會觸發 Resize(),此時可能產生陣列搬移(Array.Copy)。若這個過程被其他執行緒「插隊使用」,就會造成 IndexOutOfRange、ArgumentException 等錯誤。

解決方案

1️⃣ 加鎖(Lock)

1
2
3
4
5
6
7
8
9
private readonly object _lock = new();

public void AddSomthing(int addCount)
{
lock (_lock)
{
_data.Add($"Data from thread {addCount}");
}
}

✅ 優點:

簡單、直覺,任何資料結構都可以套用(包含 List、Dictionary<K,V> 等)

❌ 缺點:

若很多執行緒同時進來,會導致 阻塞(blocking),效能降低,不小心互鎖可能會產生 死鎖(deadlock)

2️⃣ ConcurrentBag / ConcurrentQueue 等集合

1
2
3
4
5
6
private ConcurrentBag<string> _data = new();

public void AddSomthing(int addCount)
{
_data.Add($"Data from thread {addCount}");
}

✅ 優點:

TPL(Task Parallel Library)支援,效能優化過,非阻塞,多執行緒環境下表現良好

❌ 缺點:

無法控制順序(Bag 是無序的),不能用索引取值(不像 List 有 _data[3])



☘️ 結語

清晨的光灑進房內,窗外鳥鳴不歇。你整理好行李,準備退房。昨晚用過的毛巾已被收起、牙刷包妥,房間依然靜靜待在那裡

這趟旅宿的體驗,其實就像我們所學的依賴注入(Dependency Injection):

  • 房間,是 Singleton,自始至終都只會有一間。
  • 盥洗備品,是 Scoped,陪你走完這次旅程,下一位旅人會擁有全新的。
  • 衛生紙,是 Transient,即用即丟,不留痕跡,也不該被留。

所以我們設計生命週期,不只是為了效率,而是為了秩序與清晰。當每一份服務都在剛剛好的時機誕生、在剛剛好的時刻結束,
系統就像旅宿一樣 —— 平衡而自由,複雜卻優雅。

現在,是時候離開森林旅宿,走回日常。但希望這場靜謐的旅程,能讓你在面對程式世界的混沌時,心中仍留一份從容與理解。