راهنمای جامع ابزارهای همگامسازی و قفلگذاری در #C: انتخاب ابزار بهینه برای هر سناریو
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۱۲ ۰۹:۲۵
آدرس: www.dntips.ir
چکیده: مدیریت همزمانی (Concurrency) و پیشگیری از شرایط رقابتی (Race Conditions) از ارکان کلیدی معماری نرمافزارهای مدرن در اکوسیستم داتنت (.NET) به شمار میروند. سالها کلمه کلیدیlockراهکار پیشفرض و جامع بسیاری از توسعهدهندگان محسوب میشد؛ اما ظهور مدل برنامهنویسی ناهمگام (Asynchronous Programming) و ظهور الگوهای چندریسمانی پیشرفته، محدودیتهای ذاتی این سازوکار را آشکار کرد. این مقاله به کالبدشکافی فنی، ساختار زیرین و کاربرد دقیق ابزارهای همگامسازی داده در داتنت میپردازد؛ از دستورات اتمیک بدون قفل (Interlocked) و سازوکارهای ترکیبی مدرن مانندLockجدید در داتنت ۹ گرفته تا راهحلهای ناهمگام (SemaphoreSlim)، ابزارهای بهینهساز خواندن/نوشتن (ReaderWriterLockSlim) و ابزارهای سطح سیستمعامل (MutexوSemaphore). همچنین قواعد طلایی پیشگیری از بنبست (Deadlock) و اثرات سربار سیستمعامل (Context Switching) در این نوشتار تبیین شدهاند تا دیدگاهی جامع برای اتخاذ تصمیمات دقیق معماری شکل گیرد.
_balance += amount;
«در حال حاضر چه کسی یا چند ریسمان مجاز به دسترسی و تغییر این بخش حساس از کد (Critical Section) هستند؟»
lock (_gate)
{
await SaveToDatabaseAsync(); // ❌ خطای زمان کامپایل: CS1996
}lock وابسته به شناسه ریسمان (ThreadId) است. هنگامی که از await استفاده میشود، ریسمان آزاد شده و ادامه متد ممکن است روی ریسمان دیگری از ThreadPool بازگردد. اگر ریسمان آزادکننده با ریسمان قفلکننده متفاوت باشد، یکپارچگی مانیتور نقض میشود. این نقطه آغازی است برای شناخت دقیقتر خانواده ابزارهای همگامسازی در داتنت.Interlocked(رویکرد Lock-Free)System.Threading.Interlocked این دسترسی را مستقیماً از طریق دستورالعملهای سختافزاری اتمیک CPU (مانند LOCK CMPXCHG در معماری x86/x64) بدون معلقسازی ریسمان یا تغییر زمینه (Context Switch) فراهم میکند.// بهروزرسانی اتمیک شمارنده Interlocked.Increment(ref _counter); // جمع مقادیر به شکل امن Interlocked.Add(ref _totalBalance, amount); // تعویض مقدار int originalValue = Interlocked.Exchange(ref _state, 1); // تعویض مشروط: مقداردهی در صورت برابری با مقدار مورد انتظار Interlocked.CompareExchange(ref _destination, newValue, comparand);
Interlocked فقط از یک متغیر اتمیک منفرد پشتیبانی میکند. اگر لازم باشد دو متغیر به طور همبسته و یکپارچه تغییر کنند (مانند کسر از یک حساب و واریز به حساب دیگر)، این کلاس بهتنهایی پاسخگو نبوده و نیاز به یک بخش بحرانی منسجم است.lockو کالبدشکافیMonitorlock سادهترین و پرکاربردترین سازوکار همگامسازی برای کدهای همگام (Synchronous) است. این دستور در واقع یک لایه نحوی (Syntactic Sugar) بر روی کلاس System.Threading.Monitor است.private readonly object _gate = new();
lock (_gate)
{
_balance += amount;
}bool lockTaken = false;
try
{
Monitor.Enter(_gate, ref lockTaken);
_balance += amount;
}
finally
{
if (lockTaken)
{
Monitor.Exit(_gate);
}
}Monitor قابلیتهای پیشرفتهتری فراهم میکند؛ از جمله Monitor.TryEnter(object, TimeSpan) برای جلوگیری از انتظار بیپایان، و متدهای Wait و Pulse جهت پیادهسازی الگوهای سیگنالدهی مصرفکننده/تولیدکننده.💡 نکته تخصصی در داتنت ۹: داتنت ۹ نوع جدیدی به نامSystem.Threading.Lockمعرفی کرده است. اکنون با تعریفprivate readonly Lock _gate = new();، کامپایلر به جایMonitorاز یک ساختار سبکتر و کممصرفتر بر پایه متدEnterScopeاستفاده میکند که از نظر حافظه و زمان پاسخ بهینهتر عمل میکند.
SemaphoreSlimasync/await، مکانیزم lock سنتی از کار میافتد. راهحل بنیادین داتنت در این حوزه System.Threading.SemaphoreSlim است. این ساختار علاوه بر متدهای همگام، متد غیرمسدودکننده WaitAsync را ارائه میدهد که بدون اشغال ریسمان منتظر آزادسازی منابع میماند.SemaphoreSlim امکان تعریف حداکثر ظرفیت همزمانی را فراهم میسازد که ابزاری ایدهآل برای الگوی Throttling (کنترل ترافیک درخواست به دیتابیس یا سرویس خارجی) است:// ظرفیت همزمانی ۱ برای عملکرد انحصاری (Mutual Exclusion)
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task ProcessDataAsync()
{
await _gate.WaitAsync();
try
{
await SaveToDatabaseAsync();
}
finally
{
_gate.Release();
}
}
// سناریوی کنترل نرخ ارسال: حداکثر ۵ فراخوانی همزمان
private readonly SemaphoreSlim _rateLimiter = new(initialCount: 5, maxCount: 5);Release همواره و بدون استثنا باید داخل بلاک finally فراخوانی شود تا از قفلشدگی همیشگی سیستم ناشی از وقوع خطا جلوگیری به عمل آید.ReaderWriterLockSlimReaderWriterLockSlim اجازه میدهد بینهایت ریسمان به صورت همزمان داده را بخوانند، اما در زمان نوشتن، قفل کاملاً انحصاری عمل میکند:private readonly ReaderWriterLockSlim _cacheLock = new();
public string Read(string key)
{
_cacheLock.EnterReadLock();
try
{
return _cache.TryGetValue(key, out var val) ? val : null;
}
finally
{
_cacheLock.ExitReadLock();
}
}
public void Write(string key, string value)
{
_cacheLock.EnterWriteLock();
try
{
_cache[key] = value;
}
finally
{
_cacheLock.ExitWriteLock();
}
}ReaderWriterLockSlim سربار داخلی بالاتری نسبت به یک lock ساده دارد. اگر نسبت خواندن به نوشتن چشمگیر نیست (مثلاً زیر ۸۰٪)، یا حجم کار درون قفل بسیار اندک است، قفل معمولی به دلیل پیچیدگی کمتر سریعتر عمل خواهد کرد. پیش از بهکارگیری، کارایی سیستم را با سناریوهای واقعی بسنجید.SpinLockSpinLock به جای تعلیق ریسمان و واگذاری زمان پردازشگر (Context Switching)، ریسمان را در یک حلقه فشرده (While) سرگردان نگه میدارد تا قفل آزاد شود (Busy-Waiting).private SpinLock _spinLock = new(enableThreadOwnerTracking: false);
public void IncrementSafe()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
_shortLivedCounter++;
}
finally
{
if (lockTaken)
_spinLock.Exit();
}
}MutexوSemaphoreدر سطح سیستمعاملMutex نامگذاریشده (Named Mutex)، جلوگیری از اجرای همزمان چند نمونه (Instance) از یک برنامه است:using var mutex = new Mutex(initiallyOwned: true, @"Global\MyUniqueAppId", out bool isFirstInstance);
if (!isFirstInstance)
{
Console.WriteLine("نمونه دیگری از این برنامه در حال اجراست.");
return;
}
// ادامه اجرای برنامهMutex، میتوان یک Semaphore نامگذاریشده با قابلیت اشتراک در کل سیستمعامل ساخت تا دسترسی به یک منبع سختافزاری مشترک (مانند یک پورت سریال یا فایل سیستمی مشترک) میان چند برنامه کنترل شود:using var globalSemaphore = new Semaphore(1, 1, @"Global\SharedHardwarePortLock");
lock یا SemaphoreSlim کندتر هستند و تنها باید در مرزهای بینپردازهای استفاده شوند.| ابزار | دامنه کاربرد (Scope) | پشتیبانی از await | مدل انتظار (Wait Mechanism) | میزان سربار نسبی (Overhead) | بهترین سناریوی مصرف |
Interlocked | درونپردازهای | خیر | سختافزاری / دستور پردازنده | بسیار ناچیز (کمترین) | افزایش/کاهش شمارنده و فلگهای وضعیت |
lock / Monitor | درونپردازهای | خیر | معلقسازی ریسمان (Kernel Wait) | کم | حفاظت از وضعیت مشترک در متدهای همگام |
SemaphoreSlim | درونپردازهای | بله (WaitAsync) | غیرمسدودکننده / ناهمگام | کم تا متوسط | بخشهای ناهمگام و اعمال محدودیت هجوم (Throttling) |
ReaderWriterLockSlim | درونپردازهای | خیر | ترکیبی (Spin + Kernel Wait) | متوسط | سناریوهای خواندن پرشمار و نوشتن اندک |
SpinLock | درونپردازهای | خیر | سرگردانی فعال (Busy Waiting) | کم (در زمان کوتاه) / فاجعه (در زمان طولانی) | بخشهای بحرانی نانوثانیهای و بسیار حساس به تأخیر |
Mutex | بینپردازهای / سیستمی | خیر | معلقسازی سطح هسته (Kernel Transition) | زیاد | تکنمونه کردن اجرای برنامه (Single-Instance) |
Semaphore | بینپردازهای / سیستمی | خیر | معلقسازی سطح هسته (Kernel Transition) | زیاد | هماهنگی منابع سختافزاری/سیستمی میان چند پردازه |
lock(this)):lock(this)، lock(typeof(MyClass)) یا قفل روی متغیرهای نوع string ممنوع است؛ زیرا متون در حافظه داتنت اشتراکی هستند (String Interning) و اشیاء خارجی نیز ممکن است روی نمونه کلاس شما قفل بگذارند که این امر به بنبستهای غیرقابل ردیابی میانجامد. همواره از یک نمونه اختصاصی، خصوصی و فقطخواندنی استفاده کنید:private readonly object _syncRoot = new(); // در داتنت ۹: private readonly Lock _syncRoot = new();
finally:SemaphoreSlim.Release، خروج از ReaderWriterLockSlim و ...) در بلاک finally اجرا شود تا در صورت بروز خطاهای پیشبینینشده، سیستم در وضعیت بنبست ابدی باقی نماند.Interlocked بروید.lock بهره بگیرید.await به میدان: بیدرنگ از SemaphoreSlim استفاده کنید.ReaderWriterLockSlim را ارزیابی و بنچمارک کنید.Mutex و Semaphore رجوع نمایید.System.Threading.Lock جایگزینی مدرن، بسیار سبکتر و با کارایی بالاتر برای مکانیزم سنتی قفلگذاری مبتنی بر Monitor است. در ادامه، چرایی معرفی این نوع جدید، نحوه استفاده، کدهای پشت صحنه کامپایلر و نتایج بنچمارک عملی آن بررسی شده است.object + Monitor) چه بود؟private readonly object _sync = new();
lock (_sync)
{
// عملیات همگام
}Monitor.Enter و Monitor.Exit ترجمه میشد. اما متغیر _sync صرفاً یک شیء عمومی (System.Object) در حافظه Heap بود. برای مدیریت وضعیت قفل روی هر شیء تصادفی در داتنت:Monitor.Enter به متدهای Native در سطح CLR منتهی میشد که دارای سربار پرش و عدم بهینهسازی پردازنده بودند.System.Threading.Lockچگونه کار میکند؟System.Threading.Lock به طور خاص فقط و فقط برای قفلگذاری مهندسی شده است.Lock ذخیره میشود و دیگر نیازی به جدول داخلی SyncBlock در CLR نیست.EnterScope() دارد که یک ref struct به نام Scope برمیگرداند و پیادهساز الگوی IDisposable (بدون Allocation و Boxing) است.lock (توصیهشده و خودکار)System.Threading.Lock باشد، کامپایلر C# 13 به جای تولید کد Monitor.Enter/Exit، به صورت خودکار کد بهینه مبتنی بر EnterScope را تولید میکند:using System.Threading;
public class BankAccount
{
// تعریف شیء اختصاصی از نوع Lock
private readonly Lock _lock = new();
private decimal _balance;
public void Deposit(decimal amount)
{
// کامپایلر C# 13 کد را به Lock.EnterScope ترجمه میکند
lock (_lock)
{
_balance += amount;
}
}
}Lock.Scope scope = _lock.EnterScope();
try
{
_balance += amount;
}
finally
{
scope.Dispose(); // قفل بدون سربار آزاد میشود
}usingEnterScope بهره ببرید:public void Withdraw(decimal amount)
{
using (_lock.EnterScope())
{
_balance -= amount;
}
}if (_lock.TryEnter(TimeSpan.FromMilliseconds(100)))
{
try
{
// عملیات
}
finally
{
_lock.Exit();
}
}using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Threading;
using System.Threading.Tasks;
[MemoryDiagnoser]
public class LockBenchmark
{
private readonly object _classicLock = new();
private readonly Lock _net9Lock = new();
private int _counter;
// سناریوی ۱: قفل سنتی بدون رقابت
[Benchmark(Baseline = true)]
public void ClassicLock_Uncontended()
{
lock (_classicLock)
{
_counter++;
}
}
// سناریوی ۲: قفل داتنت ۹ بدون رقابت
[Benchmark]
public void Net9Lock_Uncontended()
{
lock (_net9Lock)
{
_counter++;
}
}
// سناریوی ۳: قفل سنتی با ۱۰ ریسمان موازی
[Benchmark]
public void ClassicLock_Contended()
{
Parallel.For(0, 100, _ =>
{
lock (_classicLock)
{
_counter++;
}
});
}
// سناریوی ۴: قفل داتنت ۹ با ۱۰ ریسمان موازی
[Benchmark]
public void Net9Lock_Contended()
{
Parallel.For(0, 100, _ =>
{
lock (_net9Lock)
{
_counter++;
}
});
}
}| سناریو | متد | زمان میانگین (Mean) | نسبت به مبنا (Ratio) | تخصیص حافظه (Allocated) |
| Uncontended | ClassicLock (Monitor) | ~12.5 ns | 1.00x | 0 B |
| Uncontended | Net9Lock (Lock) | ~8.2 ns | 0.65x (~35% سریعتر) | 0 B |
| Contended | ClassicLock (Monitor) | ~4.8 μs | 1.00x | 0 B |
| Contended | Net9Lock (Lock) | ~2.6 μs | 0.54x (~45% سریعتر) | 0 B |
System.Threading.Lock:ref struct Scope، کامپایلر هیچ گونه تخصیص حافظهای در پشته یا هیپ تحمیل نمیکند.Lockprivate readonly object _gate = new();
private readonly Lock _gate = new();
lock (...) نیاز نیست.await: به یاد داشته باشید که کلاس Lock نیز مانند Monitor کاملاً همگام است و در کدهای دارای await نمیتوان از آن استفاده کرد. در سناریوهای ناهمگام، انتخاب درست همچنان SemaphoreSlim است.