عنوان:

‫چرا Dictionary معمولی در اجرای موازی شکست می‌خورد؟


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۰۴ ۰۹:۳۰
آدرس: www.dntips.ir
چکیده
مجموعه‌های متداول در دات‌نت مانند List و Dictionary به دلیل ساختار سبک و کارایی بالا، بیشترین کاربرد را در توسعه نرم‌افزار دارند. با این حال، این مجموعه‌ها برای دسترسی هم‌زمان و پردازش موازی طراحی نشده‌اند. تغییر هم‌زمان یک Dictionary توسط چند نخ (Thread) می‌تواند منجر به تخریب ساختار داده‌های داخلی (Data Corruption)، بروز استثناهای غیرمنتظره و حتی قفل شدن پردازنده (100% CPU Usage) به دلیل حلقه‌های بی‌نهایت در ساختارهای درونی گردد.
این مقاله به بررسی علت فنی این رفتارهای بحرانی در برنامه‌های کاربردی (به‌ویژه خدمات Singleton و Static در ASP.NET Core)، معرفی مجموعه‌های ایمن از نخ در فضای نام System.Collections.Concurrent، الگوی بهینه استفاده از Lazy جهت جلوگیری از Race Condition در متدهای کارخانه‌ای، و الگوهای نادرست رایج در دسترسی‌های غیر اتمیک می‌پردازد.

۱. مقدمه
در دنیای برنامه‌نویسی مدرن دات‌نت، افزایش کارایی نرم‌افزارها تا حد زیادی وابسته به بهره‌گیری از پردازش‌های هم‌روند (Concurrency) و اجرا بر روی چند نخ مجزا است. در ساختارهای متداول، کلاس‌هایی نظیر Dictionary عملکرد فوق‌العاده‌ای با پیچیدگی زمانی نزدیک به O(1) در خواندن و نوشتن ارائه می‌دهند.
با این حال، زمانی که سیستم وارد فاز برنامه‌نویسی موازی می‌شود یا برنامه‌های وب متمرکز، نظیر ASP.NET Core تحت بار ترافیکی بالا قرار می‌گیرند، استفاده از مجموعه‌های معمولی در قالب سرویس‌های Singleton یا متغیرهای Static بسیار خطرساز می‌شود. بروز باگ‌های ناشی از هم‌روندی، غالباً در محیط‌های تست محلی (Local) خود را نشان نمی‌دهند، بلکه دقیقاً در محیط عملیاتی (Production) و زیر بار شدید ترافیکی ظهور می‌کنند؛ امری که تشخیص و رفع آن‌ها را بسیار دشوار می‌سازد.

۲. چرا Dictionary در اجرای موازی دچار شکست می‌شود؟
۲.۱. ساختار داخلی Dictionary و نحوه تغییر اندازه (Resizing)
برای درک دقیق مشکل، باید نگاهی به ساختار درونی Dictionary داشته باشیم. این مجموعه بر پایه آرایه‌ای از خانه هَش‌ها (Buckets) و ورودی‌ها (Entries) کار می‌کند. هنگام افزودن یک کلید جدید:
  • کد هش کلید (HashCode) محاسبه شده و نگاشت آن به یک Bucket انجام می‌شود.
  • اگر تعداد عناصر از حد آستانه (Capacity/Load Factor) فراتر رود، دیکشنری باید آرایه‌های داخلی خود را بازتخصیص داده و اندازه آن‌ها را افزایش دهد (Resize).
  • در فرایند Resize، تمامی عناصر موجود مجدداً هش شده و به Buckets جدید منتقل می‌شوند.

نمونه کد آسیب‌پذیر در برابر پردازش موازی:
// استفاده ناایمن از دیکشنری معمولی در اجرای موازی
Dictionary<int, string> cache = new();

Parallel.For(0, 10000, i =>
{
    // حتی اگر کلیدها متفاوت باشند، تغییر ساختار داخلی ناایمن است!
    cache[i] = ComputeExpensiveData(i);
});
حتی اگر هر نخ، کلید کاملاً متفاوتی را درج کند، در لحظه‌ای که دو نخ به‌طور هم‌زمان نیاز به انجام عملیات Resize یا بروزرسانی لینک‌های داخلی در Buckets داشته باشند، ساختار آرایه‌ها در حافظه خراب (Corrupted) می‌شود.

۲.۲. پیامدهای ناگوار در محیط عملیاتی (Production)
تخریب ساختار داده داخلی دیکشنری به عواقب زیر می‌انجامد:
  • پرش استثناهای نامربوط: مشاهده استثناهایی نظیر IndexOutOfRangeException یا NullReferenceException از درون کدهای داخلی Runtime دات‌نت.
  • مفقود شدن داده‌ها (Data Loss): بروزرسانی هم‌زمان موجب اوررایت شدن Pointerها شده و داده‌های ثبت‌شده بدون بروز هیچ خطایی گم می‌شوند.
  • مصرف ۱۰۰٪ پردازنده (High CPU Usage) و قفل برنامه: اگر لینک‌های درونی Buckets در اثر هم‌روندی دچار چرخه دورانی (Cyclic Loop) شوند، متد خواندن کلید (مثلاً TryGetValue) وارد یک حلقه بی‌نهایت می‌شود. در این حالت مصرف یک یا چند هسته CPU به ۱۰۰٪ رسیده و کل سرویس وب ASP.NET Core قفل می‌کند.

۳. راهکار: استفاده از مجموعه‌های ایمن از نخ (Concurrent Collections)
مایکروسافت در فریم‌ورک دات‌نت فضانام System.Collections.Concurrent را جهت حل این چالش‌ها ارائه داده است. کلاس‌های موجود در این فضانام از تکنیک‌های پیشرفته هم‌روندی مانند Lock-Free Programming، ساختارهای داده اتمیک (Interlocked) و قفل‌های ریزدانه (Fine-grained Locks / SpinLock) بهره می‌برند.

۳.۱. جایگزینی ConcurrentDictionary
با جایگزینی دیکشنری ساده با ConcurrentDictionary، عملیات خواندن و نوشتن هم‌زمان بدون تخریب حافظه تضمین می‌گردد:
using System.Collections.Concurrent;

// کد ایمن و بهینه‌شده برای اجرای موازی
ConcurrentDictionary<int, string> cache = new();

Parallel.For(0, 10000, i =>
{
    cache[i] = ComputeExpensiveData(i);
});

۳.۲. مرور سایر مجموعه‌های فضانام Concurrent

نام کلاسساختار / الگوی کاریسناریوی کاربردی توصیه شده
ConcurrentDictionaryKey/Value Pair ایمنکش‌های اشتراکی در حافظه، سرویس‌های Singleton
ConcurrentQueueFIFO (صف ورود و خروج)الگوهای Producer-Consumer و پردازش صف پیام‌ها
ConcurrentStackLIFO (پشته)ارزیابی تعبیرها، الگوریتم‌های پشته‌ای موازی
ConcurrentBagمجموعه بدون ترتیب (Unordered)تخصیص‌های محلی نخ (Thread-Local) که در آن یک نخ عمدتاً داده‌های خود را اضافه/حذف می‌کند.
BlockingCollectionWrapper روی صف/پشته با امکان Blockسناریوهایی که تولیدکننده و مصرف‌کننده سرعت متفاوت دارند و نیاز به انتظار اتوماتیک است.
۴. نکات پیشرفته و الگوهای عمیق فنی
۴.۱. چالش متد GetOrAdd و اجرای چندباره Factory
متد GetOrAdd یکی از پرکاربردترین متدهای ConcurrentDictionary است. با این حال یک برداشت اشتباه رایج وجود دارد: بسیاری تصور می‌کنند تابع کارخانه‌ای (Factory Delegate) ارسال شده به این متد، تنها یک بار اجرا می‌شود!
// کد دارای چالش کارایی
var connection = cache.GetOrAdd(id, key => CreateConnection(key));
تحلیل رفتار در ناهم‌روندی:
اگر دو نخ به‌طور هم‌زمان برای یک کلید یکسان که هنوز در دیکشنری وجود ندارد GetOrAdd را فراخوانی کنند، جهت جلوگیری از قفل شدن کامل دیکشنری، دات‌نت ممکن است تابع CreateConnection(key) را برای هر دو نخ اجرا کند! در نهایت فقط یک مقدار در دیکشنری ذخیره می‌شود و مقدار دیگر دور انداخته خواهد شد. اما اگر ساخت شیء، سنگین باشد (مانند ایجاد Connection شبکه یا DB)، منابع سیستم هدر خواهد رفت.

راهکار فوق‌العاده: ترکیب ConcurrentDictionary با Lazy
برای تضمین این‌که شیء سنگین دقیقاً و انحصاراً یک‌بار ساخته شود، باید از کلاس Lazy با حالت ایمنی ExecutionAndPublication استفاده نمود:
// الگوی پیشرفته و کاملاً ایمن برای اشیاء سنگین
var lazyEntity = cache.GetOrAdd(
    id,
    key => new Lazy<Connection>(
        () => CreateConnection(key),
        LazyThreadSafetyMode.ExecutionAndPublication
    )
);

// ساخت واقعی شیء تنها زمانی اتفاق می‌افتد که Value فراخوانی شود
Connection connection = lazyEntity.Value;
در این حالت، حتی اگر چند نمونه Lazy ساخته شود، بسیار سبک هستند و ساخت واقعی شیء درون Value تنها یک‌بار و به‌صورت اتمیک انجام می‌پذیرد.

۴.۲. مغالطه ایمنی: Race Condition در سطح خطوط کد (Check-Then-Act)
ایمن بودن یک مجموعه به معنای ایمن بودن تمام منطق برنامه شما نیست! به کد زیر دقت کنید:
// الگوی ناایمن (Race Condition) - حتی با ConcurrentDictionary!
if (!dict.ContainsKey(key)) // گام ۱: بررسی
{
    dict[key] = value;       // گام ۲: عمل
}
این کد دارای خطای Check-Then-Act Race Condition است. ممکن است نخ A نبود کلید را بررسی کند. قبل از آنکه نخ A خط دوم را اجرا کند، نخ B کلید را درج نماید. سپس نخ A مقدار نخ B را اووررایت خواهد کرد.
حل مشکل با متدهای اتمیک (Atomic Methods):
// استفاده از متد اتمیک تک‌مرحله‌ای
bool added = dict.TryAdd(key, value);

// یا استفاده از AddOrUpdate
dict.AddOrUpdate(key, addValue, (k, oldValue) => updateValue);

۵. چه زمانی نباید از مجموعه‌های Concurrent استفاده کرد؟

  • برنامه‌های تک‌نخی (Single-Threaded): اگر فقط یک نخ به مجموعه دسترسی دارد، استفاده از Dictionary معمولی سریع‌تر و بهینه‌تر است.
  • سناریوهای Read-Only: اگر مجموعه یک‌بار پر شده و پس از آن فقط توسط نخ‌های مختلف خوانده می‌شود (بدون تغییر)، مجموعه‌های غیرقابل تغییر مانند ReadOnlyDictionary یا ImmutableDictionary گزینه‌های فوق‌العاده بهینه‌تری هستند.

۶. نتیجه‌گیری
کلاس‌های پایه مانند Dictionary برای کارایی بالا در شرایط تک‌نخی طراحی شده‌اند و استفاده از آن‌ها در محیط‌های موازی و هم‌روند (نظیر سرویس‌های Singleton در ASP.NET Core) می‌تواند منجر به باگ‌های هولناک، نشت داده و قفل شدن کامل CPU شود.
سه اصل کلیدی که هر توسعه‌دهنده ارشد دات‌نت باید همواره رعایت کند:
  • هنگام نوشتن هم‌زمان توسط چند نخ، همواره از ConcurrentDictionary استفاده کنید.
  • به جای ترکیب چند متد (Check-Then-Act)، از متدهای اتمیک نظیر TryAdd، GetOrAdd و AddOrUpdate استفاده نمایید.
  • در سناریوهایی که تابع کارخانه‌ای GetOrAdd سنگین است، جهت جلوگیری از اجرای چندباره، آن را با Lazy ترکیب کنید.