پیاده‌سازی Singleton در #C به روش صحیح (Thread-Safe)

الگوی Singleton یکی از ساده‌ترین و درعین‌حال پرکاربردترین الگوهای طراحی (Design Patterns) در مهندسی نرم‌افزار است. هدف این الگو آن است که از یک کلاس، فقط و فقط یک نمونه (Instance) در سراسر برنامه وجود داشته باشد، و همان نمونه در هر بار استفاده بازگردانده شود.
کینگتو - آموزش برنامه نویسی تخصصصی - دات نت - سی شارپ - بانک اطلاعاتی و امنیت

پیاده‌سازی Singleton در #C به روش صحیح (Thread-Safe)

28 بازدید 0 نظر ۱۴۰۴/۰۸/۲۰

این الگو معمولاً برای اشیائی مانند Logger، Cache Manager، Configuration Reader، Database Connection Manager و دیگر منابع اشتراکی استفاده می‌شود؛ یعنی جایی که ایجاد چند نمونه از آن کلاس نه‌تنها بی‌فایده بلکه زیان‌بار است.

اما نکته‌ای که اغلب نادیده گرفته می‌شود، Thread-Safety است. در برنامه‌های چندریسمانی (multi-threaded) در صورت پیاده‌سازی نادرست Singleton، ممکن است چند نمونه از کلاس به طور هم‌زمان ایجاد شود. در این مقاله به روش صحیح، امن و بهینهٔ پیاده‌سازی Singleton در C# می‌پردازیم.

 

تعریف ساده Singleton

به زبان ساده، Singleton الگویی است که:

  1. اجازه نمی‌دهد بیش از یک نمونه از یک کلاس ساخته شود.

  2. راهی عمومی برای دسترسی به آن نمونه در اختیار برنامه قرار می‌دهد.

به بیان دیگر، Singleton همانند «دروازه‌بان» کلاس است که کنترل ایجاد اشیاء را بر عهده دارد.

 

مثال ابتدایی و اشتباه از Singleton

ابتدا بیایید ساده‌ترین و درعین‌حال غیر امن‌ترین شکل Singleton را ببینیم:

public class SimpleSingleton
{
    private static SimpleSingleton _instance;

    private SimpleSingleton()
    {
    }

    public static SimpleSingleton Instance
    {
        get
        {
            if (_instance == null)
                _instance = new SimpleSingleton();

            return _instance;
        }
    }
}

 

در نگاه اول این کد درست به‌نظر می‌رسد. اما مشکل کجاست؟
در محیط‌های تک‌ریسمانی (Single-threaded) مشکلی ندارد، اما در برنامه‌هایی که چند رشته به طور هم‌زمان در حال اجرا هستند (مثلاً برنامه‌های ASP.NET Core یا Windows Service یا برنامه‌های موازی)، ممکن است چند Thread به‌صورت هم‌زمان وارد شرط if (_instance == null) شوند و قبل از آنکه نمونه ایجاد شود، هرکدام سعی کنند نمونهٔ خود را بسازند. در نتیجه چندین Instance مختلف از کلاس ساخته خواهد شد، که هدف Singleton را نقض می‌کند.

 

روش اول: قفل کردن (Lock) – پیاده‌سازی Thread-Safe

برای جلوگیری از ایجاد چند نمونه، کافی است هنگامی که یک Thread در حال ساخت Singleton است، سایر Threadها منتظر بمانند. این کار با دستور lock در C# انجام می‌شود:

public class LockedSingleton
{
    private static LockedSingleton _instance;
    private static readonly object _lock = new object();

    private LockedSingleton()
    {
    }

    public static LockedSingleton Instance
    {
        get
        {
            lock (_lock)
            {
                if (_instance == null)
                    _instance = new LockedSingleton();

                return _instance;
            }
        }
    }
}

 

 

در این پیاده‌سازی، با استفاده از یک شیء قفل (_lock) اطمینان حاصل می‌شود که فقط یک Thread در هر زمان می‌تواند وارد بخش lock شود. بنابراین در محیط چندریسمانی، از ایجاد نمونه‌های متعدد جلوگیری می‌شود.

اما این روش یک ایراد دارد:
هر بار که متد Instance فراخوانی می‌شود—even اگر Singleton از قبل ساخته شده باشد—Thread باید وارد lock شود، که باعث کاهش کارایی (Performance Overhead) می‌گردد.

برای رفع این مشکل، از تکنیکی به نام Double-Checked Locking استفاده می‌کنیم.

 

روش دوم: Double-Checked Locking

این روش با بررسی دوبار مقدار _instance قبل و بعد از قفل، از ورود غیرضروری به lock جلوگیری می‌کند:

public class DoubleCheckedSingleton
{
    private static DoubleCheckedSingleton _instance;
    private static readonly object _lock = new object();

    private DoubleCheckedSingleton()
    {
    }

    public static DoubleCheckedSingleton Instance
    {
        get
        {
            if (_instance == null)
            {
                lock (_lock)
                {
                    if (_instance == null)
                        _instance = new DoubleCheckedSingleton();
                }
            }

            return _instance;
        }
    }
}

 

 

در اینجا، ابتدا خارج از قفل بررسی می‌شود که آیا نمونه ساخته شده یا نه.
اگر ساخته نشده باشد، وارد قفل می‌شویم و دوباره بررسی می‌کنیم.
فقط در صورتی که همچنان مقدار null باشد، نمونه ساخته می‌شود.

این روش نسبت به روش قبلی کاراتر است و یکی از پیاده‌سازی‌های کلاسیک Singleton در C# محسوب می‌شود.

نکته: از نسخهٔ .NET 2.0 به بعد، مدل حافظهٔ CLR به گونه‌ای طراحی شده که Double-Checked Locking بدون نیاز به استفاده از کلمه کلیدی volatile به‌درستی کار می‌کند، اما برای اطمینان می‌توان همچنان از آن استفاده کرد:

private static volatile DoubleCheckedSingleton _instance;

 

 

روش سوم: استفاده از Lazy>T> – روش مدرن و توصیه‌شده

از .NET 4 به بعد، کلاسی به نام Lazy>T> معرفی شد که مخصوص همین کار است.
این کلاس به طور خودکار عملیات Thread-Safe Lazy Initialization را انجام می‌دهد.

بنابراین دیگر نیازی به نوشتن کدهای قفل و بررسی دستی نداریم.

public class LazySingleton
{
    private static readonly Lazy<LazySingleton> _instance =
        new Lazy<LazySingleton>(() => new LazySingleton());

    private LazySingleton()
    {
    }

    public static LazySingleton Instance => _instance.Value;
}

 

 

کلاس Lazy>T> اطمینان می‌دهد که نمونه فقط یک‌بار و در لحظهٔ اولین استفاده ساخته می‌شود و این فرآیند به‌صورت کاملاً Thread-Safe انجام می‌گیرد.

مزایای این روش:

  • کوتاه‌تر و خواناتر است.

  • نیازی به قفل‌گذاری دستی ندارد.

  • مدیریت هم‌زمانی بهینه و ایمن دارد.

  • Lazy Loading را به شکل واقعی و بهینه پیاده می‌کند.

به همین دلیل، امروزه بهترین روش برای پیاده‌سازی Singleton در #C استفاده از Lazy<T> است.

 

روش چهارم: Static Initialization

روش دیگری که در #C بسیار تمیز و رایج است، استفاده از مقداردهی استاتیک در زمان بارگذاری کلاس است.

در #C، مقداردهی متغیرهای استاتیک در سطح نوع (type) به طور خودکار توسط CLR تضمین می‌شود که Thread-Safe باشد.

public class StaticSingleton
{
    private static readonly StaticSingleton _instance = new StaticSingleton();

    private StaticSingleton()
    {
    }

    public static StaticSingleton Instance => _instance;
}

در این روش، نمونهٔ Singleton در زمان اولین بارگذاری کلاس ساخته می‌شود (یعنی زمانی که برای اولین بار به Instance یا هر عضو دیگر از کلاس دسترسی پیدا می‌کنیم).

این روش ساده و کارا است، ولی Lazy Loading واقعی محسوب نمی‌شود، زیرا نمونه بلافاصله با بارگذاری نوع ساخته می‌شود، حتی اگر هرگز از آن استفاده نکنیم.

 

مقایسهٔ روش‌ها

 

روش Thread-Safe Lazy Loading پیچیدگی توضیح
پیاده‌سازی ساده بسیار ساده در محیط چندریسمانی ناامن
قفل‌گذاری ساده (lock) متوسط امن ولی کند
Double-Checked Locking کمی پیچیده‌تر کاراتر از قفل ساده
Lazy بسیار ساده روش مدرن و توصیه‌شده
Static Initialization ساده نمونه همیشه ساخته می‌شود

 

نکات کلیدی در طراحی Singleton

  1. سازندهٔ خصوصی (private constructor)
    برای جلوگیری از ساخت نمونهٔ جدید در خارج از کلاس، سازنده باید private باشد.

  2. عدم ارث‌بری (Sealed)
    معمولاً کلاس Singleton را با کلیدواژهٔ sealed تعریف می‌کنند تا از ارث‌بری و ایجاد نمونه‌های فرعی جلوگیری شود.

    public sealed class LazySingleton
    {
        // ...
    }
    

    توجه به Dispose و منابع خارجی
    اگر Singleton شامل منابع unmanaged یا IDisposable باشد (مثل Database Connection یا File Stream)، باید طوری طراحی شود که منابع در پایان عمر برنامه آزاد شوند.
    چون Singleton معمولاً تا پایان اجرای برنامه زنده می‌ماند، باید با دقت از memory leak جلوگیری کرد.

  3. استفاده در Dependency Injection
    در ASP.NET Core یا سایر فریم‌ورک‌های مبتنی بر DI، معمولاً نیازی به پیاده‌سازی دستی Singleton نیست. کافی است در Program.cs یا Startup.cs بنویسید:

    services.AddSingleton<IMyService, MyService>();
    

    در این صورت، خود فریم‌ورک مسئول ایجاد فقط یک نمونه از سرویس خواهد بود.

 

خطاهای رایج در استفاده از Singleton

  • استفاده از Singleton برای همه‌چیز!
    Singleton باید فقط زمانی استفاده شود که منطقاً باید فقط یک نمونه وجود داشته باشد.
    استفاده بی‌رویه از Singleton منجر به وابستگی‌های محکم (tight coupling) و سختی در تست واحد (unit testing) می‌شود.

  • ایجاد Singleton در کتابخانه‌های مشترک بدون در نظر گرفتن محیط مصرف‌کننده.
    در برخی محیط‌ها (مثلاً ASP.NET) Singleton ممکن است وضعیت مشترک خطرناکی ایجاد کند.

  • دست‌کاری مستقیم Instance یا بازنشانی آن.
    Singleton نباید قابل جایگزینی باشد مگر در تست‌های خاص با Mock.

 

نمونهٔ واقعی استفاده از Singleton

فرض کنید نیاز داریم کلاس Logger داشته باشیم که همهٔ بخش‌های برنامه از یک خروجی لاگ مشترک استفاده کنند.

public sealed class Logger
{
    private static readonly Lazy<Logger> _instance = new Lazy<Logger>(() => new Logger());
    private readonly string _filePath;

    private Logger()
    {
        _filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "log.txt");
    }

    public static Logger Instance => _instance.Value;

    public void Log(string message)
    {
        string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} | {message}";
        File.AppendAllText(_filePath, line + Environment.NewLine);
    }
}

و در هرجای برنامه می‌توانید بنویسید:

Logger.Instance.Log("Application started...");

بدون نگرانی از چندریسمانی یا تداخل در نوشتن فایل.

 

جمع‌بندی

الگوی Singleton با وجود سادگی ظاهری، اگر به درستی پیاده‌سازی نشود، می‌تواند مشکلات هم‌زمانی جدی ایجاد کند.
در C#، راه‌حل‌های متنوعی برای پیاده‌سازی آن وجود دارد، اما بهترین انتخاب امروز:

استفاده از Lazy>T> است، زیرا:

  • Thread-Safe است

  • خوانا و تمیز است

  • از Lazy Loading پشتیبانی می‌کند

  • بدون سربار قفل‌گذاری دستی

در نهایت، همیشه در طراحی نرم‌افزار به این نکته دقت کنید که Singleton تنها زمانی مفید است که واقعاً فقط یک نمونه از آن کلاس لازم باشد.

استفادهٔ آگاهانه از این الگو می‌تواند کارایی، انسجام و ایمنی برنامه را افزایش دهد، اما استفادهٔ بی‌مورد آن ممکن است دقیقاً نتیجهٔ عکس بدهد.

 
لینک استاندارد شده: 9oMHLQMo

0 نظر

    هنوز نظری برای این مقاله ثبت نشده است.
جستجوی مقاله و آموزش
دوره‌ها با تخفیفات ویژه