Singleton Pattern trong C#: Khi nào dùng và khi nào không
Dùng Singleton pattern đúng cách trong C# / .NET 10: thread-safe với Lazy<T>, vì sao AddSingleton<T> thường thay thế nó, và khi nào vẫn cần tự viết tay.
Mục lục
- Singleton pattern giải quyết bài toán gì trong C# / .NET?
- Viết Singleton thread-safe trong C# hiện đại thế nào?
- Khi nào AddSingleton() của DI container thay thế pattern này?
- Khi nào KHÔNG nên dùng Singleton pattern?
- Singleton khác static class ở điểm gì?
- Một ví dụ Singleton thật trông thế nào trong .NET 10?
- Đọc tiếp gì trong series?
Shop của bạn có 50.000 SKU. Giá không đổi suốt phiên giao dịch. Vậy mà mỗi
request checkout đều quật một câu SELECT xuống bảng Products — vì người
viết GetPrice(sku) ngại nghĩ đến chuyện cache. Đến chiều thứ Sáu, DB nhảy
95% CPU và channel on-call cháy.
Cách sửa thì rõ: load bảng giá một lần lúc app start, giữ trong RAM, share một bản đó cho mọi request. Cái object dùng chung duy nhất ấy chính là Singleton pattern. Ý đồ gói trong một dòng: một instance trong một process, có cách truy cập rõ ràng. Phần thú vị không nằm ở ý đồ — mà ở mọi thứ phải nghĩ tới xung quanh nó: thread safety, test, lifetime, khởi tạo trễ, và (quan trọng nhất) khi nào nên bỏ qua pattern và để DI container làm thay.
Đây là bài tôi ước mình đã đọc ngày đầu tiên đi làm .NET.
Singleton pattern giải quyết bài toán gì trong C# / .NET?
Singleton xứng đáng có chỗ khi mọi caller phải dùng chung đúng một instance, và tạo cái thứ hai sẽ là sai, lãng phí, hoặc cả hai. Ba hình dạng cụ thể của tình huống đó:
- Resource read-only đắt tiền. Bảng giá load từ DB. Model ML 200 MB nạp vào RAM. Bộ regex đã pre-compile. Tạo bản thứ hai chỉ tổ tốn RAM và làm chậm startup.
- Tài nguyên OS / phần cứng vốn đã đơn nhất. Handle file log của process. Adapter clipboard hệ thống. Wrapper cổng serial. Bản thân OS cũng chỉ cho phép một consumer; mô hình hoá khác đi chỉ rước bug.
- Điểm điều phối trong cùng một process. Rate limiter in-memory mọi request handler tra cứu. Trạng thái circuit-breaker. Buffer metrics chỉ-ghi. Mọi caller phải thấy cùng bộ counter, không thì đứt điều phối.
Để ý cái KHÔNG có trong danh sách: "class nào tôi muốn gọi từ bất cứ đâu". Đó là cái bẫy người mới sa vào. Khả năng truy cập không phải lý do dùng Singleton — đó là lý do dùng dependency injection.
Viết Singleton thread-safe trong C# hiện đại thế nào?
Bản giáo trình trông có vẻ hợp lý nhưng sai:
// Đừng làm thế này. Race condition giữa null check và phép gán.
public sealed class PriceCatalog
{
private static PriceCatalog? _instance;
public static PriceCatalog Instance
{
get
{
if (_instance == null) _instance = new PriceCatalog();
return _instance;
}
}
private PriceCatalog() { /* load tốn kém */ }
}
Hai thread cùng vượt qua check null ngay khoảnh khắc đó, cùng gọi
constructor, và bạn có hai instance — phá nguyên ý đồ. Junior với lấy
lock. Mid-level với lấy double-checked locking. Senior với lấy
Lazy<T>:
public sealed class PriceCatalog
{
private static readonly Lazy<PriceCatalog> _instance =
new(() => new PriceCatalog());
public static PriceCatalog Instance => _instance.Value;
private readonly IReadOnlyDictionary<string, decimal> _prices;
private PriceCatalog()
{
// Chạy đúng một lần, lần đầu có ai đọc Instance.
_prices = LoadFromDatabase();
}
public decimal? PriceOf(string sku)
=> _prices.TryGetValue(sku, out var p) ? p : null;
private static IReadOnlyDictionary<string, decimal> LoadFromDatabase()
{
// 50K row, mất ~400ms. Cái giá đó chỉ trả đúng một lần.
// ...
return new Dictionary<string, decimal>();
}
}
Cấu trúc khi nhiều caller cùng dùng một instance trông như sau — không caller nào có thể gọi thẳng constructor:
classDiagram
class PriceCatalog {
-Lazy~PriceCatalog~ instance$
-PriceCatalog()
+Instance$ PriceCatalog
+PriceOf(sku) decimal
}
class CheckoutController
class CartService
class PricingJob
CheckoutController ..> PriceCatalog : đọc Instance
CartService ..> PriceCatalog : đọc Instance
PricingJob ..> PriceCatalog : đọc Instance
note for PriceCatalog "Private ctor + static Lazy field<br>= một instance, khởi tạo trễ, thread-safe"
Lazy<T> mặc định dùng LazyThreadSafetyMode.ExecutionAndPublication,
CLR cài bằng một lock nội bộ giải phóng ngay khi value được tạo. Sau lần
đọc đầu, các lần đọc sau chỉ là một field fetch — không lock, không
nhánh, không overhead. Bạn không viết được implementation nhanh hơn bằng
tay đâu.
Hai cảnh báo về Lazy<T> hay đốt ngón tay:
- Factory không được throw. Nếu constructor ném exception,
Lazy<T>cache exception đó và rethrow ở mọi lần đọc sau. Hãy làm constructor bền vững, hoặc dùngLazyThreadSafetyMode.PublicationOnlyvà chấp nhận factory có thể chạy nhiều hơn một lần. - Instance sống đến khi process tắt. Nếu
LoadFromDatabasemở handle dài hạn, handle đó không bao giờ đóng. Hoặc giữ state Singleton là dữ liệu thuần, hoặc chấp nhận không dispose được.
Khi nào AddSingleton() của DI container thay thế pattern này?
Trong ASP.NET Core, .NET Worker Service, và hầu hết host .NET hiện đại,
lifetime là cấu hình chứ không phải cấu trúc class. Class
Lazy<T>-backed phía trên có thể viết lại với public constructor và một
dòng trong Program.cs:
public sealed class PriceCatalog : IPriceCatalog
{
private readonly IReadOnlyDictionary<string, decimal> _prices;
public PriceCatalog(ILogger<PriceCatalog> log, AppDbContext db)
{
log.LogInformation("Loading price catalog from database");
_prices = db.Products.ToDictionary(p => p.Sku, p => p.Price);
}
public decimal? PriceOf(string sku)
=> _prices.TryGetValue(sku, out var p) ? p : null;
}
// Program.cs
builder.Services.AddSingleton<IPriceCatalog, PriceCatalog>();
DI container làm ba việc Singleton tự cuộn không làm được:
- Constructor injection.
PriceCataloggiờ nhậnILogger<>,DbContext, hay bất cứ thứ gì nó cần. Bản tự cuộn phải gọi static factory hoặc service locator trong private constructor, làm dependency vung khắp class. - Thay thế qua interface. Code phụ thuộc
IPriceCatalog, không phải class concrete. Test đăng ký bản giả; production đăng ký bản thật. Cùng calling code. - Điều khiển lifetime. Sang năm bạn quyết định mỗi tenant một
catalog hợp lý hơn, đổi
AddSingletonthànhAddScopedrồi injectITenantContext. Class consumeIPriceCatalogkhông cần biết.
Bản Instance tự cuộn vẫn còn một ngách: library code phải chạy được
mà không có DI container. Application code đa số không thuộc trường hợp
đó. Nếu bạn đã có IServiceCollection trong tay, ưu tiên nó.
Một ghi chú lịch sử nhỏ: nhiều tài liệu vẫn gọi AddSingleton<T>() là
"Singleton pattern trong .NET". Đúng — ý đồ pattern được giữ nguyên,
implementation chỉ chuyển từ class của bạn sang framework. Tài liệu
Service lifetimes
của Microsoft là tham khảo chính thức.
Khi nào KHÔNG nên dùng Singleton pattern?
Pattern bắn trật đích trong bốn tình huống rất cụ thể. Trúng cái nào, bước đi.
- State có thể thay đổi và dùng chung. Hai thread cùng ghi vào một
Dictionary<string, int>Singleton là biến global, không phải design pattern. Nếu thật sự cần state mutable share, cũng phải có đồng bộ rõ ràng (ConcurrentDictionary,lock,Channel) và một dòng cảnh báo to đùng trong class summary. - Class cần hành vi khác nhau trên test. Singleton với static
Instancekhông thay được. Khi một test mutate nó, mọi test sau đó chạy trên state đã bị mutate. Cách sửa là inject dependency qua interface — và đến đó là bạn đang dùng DI, không phải pattern. - Class là service có dependency. "Tôi cần Singleton
PaymentGatewaydùngHttpClient" không phải bài toán Singleton; đó là bài toán DI. Đăng kýAddSingleton<IPaymentGateway, PaymentGateway>()rồi để container nốiIHttpClientFactorycho. Xem Factory Method pattern — bài kế giải quyết câu hỏi "dùng payment gateway nào?". - Bạn thực ra muốn đổi implementation lúc runtime. Nhiều người với Singleton vì muốn share một thuật toán. Cái họ thực sự cần là Strategy pattern — chọn thuật toán lúc runtime, không phải lúc define class.
Nguyên lý chung: mỗi Singleton bạn viết là một mảnh coupling ẩn cộng thêm. Chỉ dùng pattern khi cái coupling đó là một phần của bài toán, không phải một phần của giải pháp.
Singleton khác static class ở điểm gì?
Đây là chỗ phỏng vấn C# hay làm ứng viên rối nhất. Trông na ná — cả hai
đều cho bạn gọi mà không new. Nhưng không phải cùng một thứ.
| Khía cạnh | static class |
Singleton |
|---|---|---|
| Implement interface? | Không | Có |
| Truyền làm tham số method? | Không | Có |
| Thay trong unit test? | Không (trừ phi viết lại chỗ gọi) | Có (qua interface) |
| Lazy initialisation? | Có (CLR chạy static ctor lần đầu) |
Có (Lazy<T> hoặc DI) |
| Polymorphism? | Không | Có |
| Dùng tốt nhất cho | Hàm thuần: Math.Abs, string.IsNullOrEmpty |
Service có state: cache, gateway, registry |
Một quy tắc ngón tay cái: nếu class đó là động từ (Math, Convert,
Path), khả năng cao là static class. Nếu là danh từ (PriceCatalog,
RateLimiter, Logger), khả năng cao là Singleton — và gần như chắc
chắn nên đăng ký bằng AddSingleton<T>() thay vì viết tay.
Một ví dụ Singleton thật trông thế nào trong .NET 10?
Đây là toàn cảnh từ Program.cs đến controller, đúng cách một service
.NET 10 production sẽ wire-up. Đây cũng là ví dụ ta sẽ quay lại xuyên suốt
nhóm Creational — cùng domain, các bài toán khác.
// Domain ----------------------------------------------------------
public interface IPriceCatalog
{
decimal? PriceOf(string sku);
}
public sealed class PriceCatalog : IPriceCatalog
{
private readonly IReadOnlyDictionary<string, decimal> _prices;
public PriceCatalog(AppDbContext db, ILogger<PriceCatalog> log)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
_prices = db.Products
.AsNoTracking()
.ToDictionary(p => p.Sku, p => p.Price);
log.LogInformation("Loaded {Count} prices in {Ms}ms",
_prices.Count, sw.ElapsedMilliseconds);
}
public decimal? PriceOf(string sku)
=> _prices.TryGetValue(sku, out var p) ? p : null;
}
// Composition root ------------------------------------------------
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(/* ... */);
builder.Services.AddSingleton<IPriceCatalog, PriceCatalog>();
// ^^^^^^^^^ một instance cho cả process
// Consumer --------------------------------------------------------
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly IPriceCatalog _prices;
public CheckoutController(IPriceCatalog prices) => _prices = prices;
[HttpGet("price/{sku}")]
public ActionResult<decimal> Get(string sku)
=> _prices.PriceOf(sku) is { } p
? Ok(p)
: NotFound();
}
// Test ------------------------------------------------------------
public sealed class FakeCatalog : IPriceCatalog
{
public decimal? PriceOf(string sku) => sku == "ABC" ? 9.99m : null;
}
[Fact]
public void Returns_price_when_sku_exists()
{
var sut = new CheckoutController(new FakeCatalog());
var result = sut.Get("ABC").Result as OkObjectResult;
Assert.Equal(9.99m, result!.Value);
}
Ba thứ Singleton tự cuộn vất vả mới có cùng lúc:
- Production constructor nhận
AppDbContextvàILogger<>qua DI bình thường — không service locator, không tra cứu dependency static. - Test thay dependency bằng một dòng
FakeCatalog. Không reflection, không reset state static giữa các test. - Quyết định lifetime chỉ một token (
AddSingleton) bạn đổi mà không cần đụng vàoPriceCatalog.
Đây là phiên bản bạn ship thật. Bản Lazy<T> đáng học vì nó cắt nghĩa
AddSingleton<T> hoạt động dưới hood thế nào — nhưng application code
bạn viết nên trông giống snippet trên, không phải implementation
pre-DI từ sách Gang of Four gốc.
Đọc tiếp gì trong series?
- Bài trước: Giới thiệu design pattern trong C#
- Bài kế: Factory Method — vẫn domain
checkout, nhưng câu hỏi đổi sang "tạo
IPaymentProcessornào?" thay vì "làm sao share cái đang có?". - Tiếp xa hơn: Abstract Factory — bản thân factory gần như luôn đăng ký dạng Singleton.
- Tham chiếu chéo: Strategy — nếu bạn với Singleton vì muốn share một thuật toán cụ thể, có lẽ bạn cần Strategy cộng DI mới đúng.
- Cây quyết định: Cách chọn design pattern phù hợp.
Một lưu ý cuối hơi cá nhân: trong mười lăm năm làm C#, tôi viết tay đúng
hai cái Singleton và đăng ký hàng trăm bằng AddSingleton<T>(). Pattern
nằm trong hộp đồ nghề, nhưng đa phần ngày làm việc bạn mở hộp DI
container. Hãy nhớ tỉ lệ đó lần tới khi định với pattern.
Câu hỏi thường gặp
Singleton có phải là anti-pattern không?
Singleton khác static class ở điểm gì?
Math.Abs. Dùng Singleton (ưu tiên qua AddSingleton<T>()) cho service có trạng thái.Làm sao viết Singleton thread-safe trong C# mà không cần lock?
System.Lazy<T> với mode mặc định LazyThreadSafetyMode.ExecutionAndPublication. CLR đảm bảo factory chạy đúng một lần ngay cả dưới truy cập đồng thời. Bạn viết static readonly Lazy<MyType> _instance = new(() => new MyType()) rồi Instance => _instance.Value. Không cần lock, không cần double-checked locking, không cần field volatile.Có nên unit-test code phụ thuộc vào Singleton không?
PriceCatalog.Instance, test không thể thay bằng catalog giả và state static rò rỉ giữa các test case. Cách sửa là phụ thuộc vào IPriceCatalog rồi đăng ký catalog thật bằng services.AddSingleton<IPriceCatalog, PriceCatalog>() — DI sẽ trao điểm inject cho test.