Khởi tạo Cơ bản 9 phút đọc

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
  1. Singleton pattern giải quyết bài toán gì trong C# / .NET?
  2. Viết Singleton thread-safe trong C# hiện đại thế nào?
  3. Khi nào AddSingleton() của DI container thay thế pattern này?
  4. Khi nào KHÔNG nên dùng Singleton pattern?
  5. Singleton khác static class ở điểm gì?
  6. Một ví dụ Singleton thật trông thế nào trong .NET 10?
  7. Đọ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 đó:

  1. 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.
  2. 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.
  3. Đ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:

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:

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.

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
Truyền làm tham số method? Không
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
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:

Đâ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?

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?
Bản thân nó thì không. Singleton trở thành anti-pattern khi giữ trạng thái thay đổi được, khi có dependency lẽ ra phải inject, hoặc khi làm caller không test được. Một Singleton thread-safe, read-only, bất biến sau khởi tạo (như bảng giá load một lần lúc app start) hoàn toàn ổn. Còn Singleton bị code khác mutate thì chỉ là biến global mặc vest.
Singleton khác static class ở điểm gì?
Static class không implement được interface, không truyền vào tham số được, và không thay thế trong unit test được. Singleton làm được cả ba vì nó vẫn là một instance bình thường — chỉ đảm bảo có đúng một. Dùng static class cho hàm thuần như 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?
Dùng 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?
Chỉ khi Singleton được expose qua interface và inject vào. Nếu code gọi thẳ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.