پست

در چه زمان از Volatile در .Net استفاده کنیم

در چه زمان از Volatile در .Net استفاده کنیم

چرا volatile در C# آن چیزی نیست که فکر می‌کنید؟ یک بررسی دقیق با مثال واقعی

چند وقت پیش داشتم روی یک سرویس دریافت داده کار می‌کردم که چند Worker به‌صورت موازی روی یک صف کار می‌کردن. یک شمارنده ساده لازم داشتم که بدونم چند تا کار باقی مونده. همون لحظه یاد یک باگ قدیمی افتادم که یک همکار سابق با استفاده اشتباه از volatile توی کدش ساخته بود؛ فکر می‌کرد این کلمه‌کلیدی مشکل race condition رو حل می‌کنه، در حالی که اصلاً برای اون کار طراحی نشده بود. همین بهانه‌ای شد که این مطلب رو بنویسم: volatile دقیقاً چیه، چه مشکلی رو حل می‌کنه، و مهم‌تر از همه، چه مشکلاتی رو حل نمی‌کنه.

مسئله از کجا شروع می‌شود

فرض کنید یک کلاس ساده دارید که یک thread توی یک حلقه کار می‌کنه و منتظر یک سیگنال توقف هست:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Worker
{
    private bool _stopRequested = false;

    public void DoWork()
    {
        while (!_stopRequested)
        {
            // کار مفید انجام می‌دهد
        }
    }

    public void RequestStop()
    {
        _stopRequested = true;
    }
}

انتظار طبیعی این است که وقتی یک thread دیگر RequestStop را صدا بزند، حلقه در DoWork متوقف شود. اما در عمل، این تضمین وجود ندارد و می‌تواند این حلقه تا ابد ادامه پیدا کند.

دلیلش به بهینه‌سازی‌های JIT compiler و پردازنده برمی‌گردد. کامپایلر از دید یک thread تنها به کد نگاه می‌کند و می‌بیند که _stopRequested هیچ‌جا داخل خودِ متد DoWork تغییر نمی‌کند. پس تصمیم می‌گیرد به‌جای خواندن مکرر از حافظه اصلی (که کند است)، مقدار را یک‌بار در یک رجیستر CPU نگه دارد و همیشه از همان‌جا بخواند. این بهینه‌سازی از دید یک thread منفرد کاملاً درست است، اما وقتی thread دیگری در پس‌زمینه مقدار را در حافظه اصلی تغییر می‌دهد، thread اول هیچ‌وقت آن تغییر را نمی‌بیند.

راه‌حل پایه: کلمه‌کلیدی volatile

کلمه‌کلیدی volatile دقیقاً برای همین سناریو ساخته شده است. با اضافه کردنش، به کامپایلر و runtime می‌گویید که این فیلد ممکن است توسط thread دیگری تغییر کند، پس هیچ‌وقت آن را کش نکن و همیشه مستقیم از حافظه بخوان:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Worker
{
    private volatile bool _stopRequested = false;

    public void DoWork()
    {
        while (!_stopRequested)
        {
            // کار مفید انجام می‌دهد
        }
    }

    public void RequestStop()
    {
        _stopRequested = true;
    }
}

همین یک کلمه، مشکل بالا را حل می‌کند. طبق مستندات رسمی مایکروسافت، volatile فقط روی انواعی قابل استفاده است که خواندن و نوشتن‌شان ذاتاً atomic باشد: reference types، pointer در کد unsafe، انواع عددی ساده مثل byte, int, short, bool, char, float و enum بر پایه این انواع. نوع‌هایی مثل double و long نمی‌توانند volatile باشند، چون روی معماری‌های 32 بیتی خواندن یا نوشتن یک مقدار 64 بیتی می‌تواند به دو عملیات جدا تقسیم شود و دیگر atomic نیست.

آنجایی که volatile کافی نیست

اینجا دقیقاً همان جایی است که همکار سابقم اشتباه کرد. فرض کنید به‌جای یک flag ساده، یک شمارنده دارید:

1
2
3
4
5
6
private volatile int _count;

public void Increment()
{
    _count++; // اینجا مشکل دارد
}

مشکل این است که _count++ در واقع یک عملیات نیست، سه عملیات است: خواندن مقدار فعلی، افزودن یک واحد، و نوشتن مقدار جدید. volatile فقط تضمین می‌کند که هر کدام از این سه مرحله به‌درستی از حافظه خوانده یا در آن نوشته شود؛ اما بین این سه مرحله هیچ محافظتی وجود ندارد. اگر دو thread همزمان Increment را صدا بزنند، این اتفاق کاملاً ممکن است: هر دو thread مقدار قدیمی را می‌خوانند (مثلاً ۵)، هر دو یک واحد اضافه می‌کنند (می‌شود ۶)، و هر دو مقدار ۶ را می‌نویسند. نتیجه باید ۷ باشد ولی شده ۶؛ یک افزایش کامل گم شده است. این دقیقاً همان race condition است که volatile هیچ محافظتی در برابرش ندارد.

راه درست استفاده از Interlocked است:

1
2
3
private int _count;

public void Increment() => Interlocked.Increment(ref _count);

Interlocked.Increment این سه مرحله را به‌صورت یک عملیات اتمی و غیرقابل‌قطع در سطح پردازنده انجام می‌دهد، به همین دلیل مشکل بالا اصلاً رخ نمی‌دهد.

یک مثال واقعی از پروژه: شمارنده کارهای باقی‌مانده

بیایید یک مثال واقعی‌تر ببینیم؛ کلاسی که تعداد کارهای باقی‌مانده در یک سیستم پردازش موازی را نگه می‌دارد:

1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class RemainingWorkCounter
{
    private int _count;

    public RemainingWorkCounter(int initialCount)
    {
        _count = initialCount;
    }

    public bool HasRemainingWork => Volatile.Read(ref _count) > 0;

    public void Decrement() => Interlocked.Decrement(ref _count);
}

نکته جالب این کلاس این است که به‌جای کلمه‌کلیدی volatile روی فیلد، از کلاس System.Threading.Volatile استفاده شده. این کلاس همان قابلیت را می‌دهد اما انعطاف بیشتری دارد، چون می‌تواند از طریق ref روی هر فیلدی در هر نقطه از کد اعمال شود، نه فقط فیلدهایی که از ابتدا با volatile تعریف شده‌اند.

اینجا Decrement با Interlocked.Decrement مقدار را به‌صورت اتمیک کاهش می‌دهد؛ چند thread می‌توانند همزمان این متد را صدا بزنند بدون این‌که هیچ کاهشی گم شود. و HasRemainingWork با Volatile.Read مقدار را می‌خواند و تضمین می‌کند همیشه آخرین مقدار نوشته‌شده را ببیند، نه یک نسخه کش‌شده و قدیمی.

چرا این ترکیب کار می‌کند: داستان Memory Fence‌ها

برای فهم این‌که چرا نوشتن با Interlocked و خواندن با Volatile.Read با هم سازگارند، باید یک لایه عمیق‌تر برویم و درباره memory fence حرف بزنیم.

پردازنده و کامپایلر برای افزایش سرعت، اجازه دارند ترتیب اجرای دستورات خواندن و نوشتن حافظه را جابه‌جا کنند، تا زمانی که این جابه‌جایی از دید یک thread تنها قابل تشخیص نباشد. یک memory fence نقطه‌ای در کد است که این جابه‌جایی را محدود می‌کند. دو نوع اصلی فنس وجود دارد:

یک acquire fence مانع از این می‌شود که دستورات بعد از خودش به قبل منتقل شوند؛ مثل یک دیوار یک‌طرفه که فقط از بالا به پایین عبور اجازه می‌دهد. یک release fence برعکس عمل می‌کند: مانع از این می‌شود که دستورات قبل از خودش به بعد منتقل شوند.

خواندن volatile (چه با کلمه‌کلیدی volatile چه با Volatile.Read) فقط یک acquire fence ایجاد می‌کند، و نوشتن volatile فقط یک release fence. به همین دلیل به آن‌ها half-fence می‌گویند؛ فقط یک جهت را مسدود می‌کنند.

در مقابل، عملیات‌های Interlocked (و همچنین lock، Monitor.Enter/Exit، Thread.MemoryBarrier) یک full fence می‌سازند: هم acquire هم release به‌طور همزمان. یعنی هیچ دستوری، نه از قبل و نه از بعد، اجازه عبور از این نقطه را ندارد.

حالا برگردیم به مثال RemainingWorkCounter. وقتی یک thread Interlocked.Decrement را اجرا می‌کند، این عملیات هم به‌عنوان release عمل می‌کند (هر چیزی که قبلش نوشته شده منتشر می‌شود) و هم به‌عنوان acquire. وقتی thread دیگری با Volatile.Read مقدار را می‌خواند، این خواندن یک acquire fence ایجاد می‌کند. چون نویسنده یک release fence داشته (که بخشی از full fence است) و خواننده یک acquire fence دارد، این جفت با هم یک رابطه happens-before می‌سازند: هر چیزی که قبل از Interlocked.Decrement نوشته شده، برای خواننده‌ای که بعد از Volatile.Read چک می‌کند، تضمین‌شده قابل مشاهده است.

یک نکته ظریف که در بحث‌های عمیق‌تر پیدا می‌شود

یک نکته صادقانه که ارزش گفتن دارد: در پیاده‌سازی واقعی CLR روی معماری x86/x64، به‌دلیل memory model نسبتاً قوی این پردازنده‌ها، بسیاری از write‌ها حتی بدون کلمه‌کلیدی volatile هم رفتار release-like دارند. این موضوع را Joe Duffy، یکی از معماران اصلی .NET runtime، در تحلیل‌های فنی‌اش توضیح داده است. اما این به این معنا نیست که می‌شود از volatile صرف‌نظر کرد؛ چون این رفتار وابسته به معماری پردازنده است، نه یک تضمین رسمی زبان. کدی که روی x64 درست کار می‌کند، ممکن است روی معماری ARM با memory model ضعیف‌تر دچار مشکل شود. بنابراین همیشه باید بر اساس مشخصات رسمی زبان برنامه‌ریزی کرد، نه رفتار مشاهده‌شده روی یک سخت‌افزار خاص.

جدول تصمیم‌گیری

بعد از این همه توضیح، سوال عملی این است: کِی از کدام مکانیزم استفاده کنیم؟

سناریوابزار مناسبدلیل
یک flag ساده که یک thread می‌نویسد و بقیه فقط می‌خوانندvolatile یا Volatile.Read/Writeفقط یک خواندن یا نوشتن ساده است، نیازی به atomicity ترکیبی نیست
شمارنده‌ای که چند thread همزمان تغییرش می‌دهندInterlockedافزایش یا کاهش یک عملیات ترکیبی است و volatile فقط visibility می‌دهد نه atomicity
الگوی double-checked locking برای singletonvolatile روی referenceجلوی reorder شدن سازنده نسبت به تخصیص مرجع را می‌گیرد
هر عملیاتی که نتیجه‌اش به مقدار فعلی وابسته استlock یا Interlockedبین خواندن و نوشتن ممکن است thread دیگری مقدار را تغییر دهد
چند فیلد که باید به‌صورت یک واحد تغییر کنندlockنه volatile و نه Interlocked اتمیک بودن روی چند فیلد را تضمین نمی‌کنند

جمع‌بندی

volatile یک ابزار دقیق و محدود است، نه یک راه‌حل همه‌کاره برای مشکلات همزمانی. کارش فقط تضمین visibility بین thread‌هاست، نه atomicity و نه محافظت از عملیات‌های ترکیبی. وقتی فقط با یک خواندن یا نوشتن ساده روی یک فیلد سروکار دارید، volatile دقیقاً همان چیزی است که لازم دارید. اما به محض این‌که پای عملیاتی مثل شمارش، یا هر منطقی که به مقدار فعلی یک متغیر وابسته است به میان بیاید، باید سراغ Interlocked یا lock بروید. شناخت این مرز، همان چیزی است که بین کد thread-safe واقعی و کدی که فقط ظاهر thread-safe دارد فرق می‌گذارد.