فصل هشتم: Threading و Concurrency

یک Process (فرآیند) در اصل همان برنامه‌ای است که روی یک سیستم‌عامل در حال اجرا است. این فرآیند از چندین Thread (رشته‌ی اجرایی) ساخته می‌شود.
Thread of execution (رشته‌ی اجرایی) مجموعه‌ای از دستورهاست که توسط یک Process صادر می‌شود.

قابلیت اجرای بیش از یک Thread به‌صورت هم‌زمان، Multi-Threading (چندریسمانی) نامیده می‌شود.
در این فصل قرار است به موضوع Multi-Threading و Concurrency (هم‌زمانی) بپردازیم.

هر Thread برای اجرا، یک مقدار مشخصی زمان دریافت می‌کند و این اجرا توسط Thread Scheduler (زمان‌بند رشته‌ها) به صورت چرخشی مدیریت می‌شود. زمان‌بند، با استفاده از تکنیکی به نام Time Slicing زمان اجرا را بین Threadها تقسیم می‌کند و هر Thread را در زمان تعیین‌شده برای اجرا به CPU می‌فرستد. ⚙️

Concurrency یعنی توانایی اجرای بیش از یک Thread دقیقاً در یک لحظه. این موضوع تنها در کامپیوترهایی ممکن است که بیش از یک Processor Core (هسته پردازنده) داشته باشند. هرچه تعداد هسته‌ها بیشتر باشد، تعداد بیشتری از Threadها می‌توانند به‌طور هم‌زمان اجرا شوند. 🖥️💡

در طول بررسی Concurrency و Threading در این فصل، با مشکلاتی مثل Blocking (انسداد)، Deadlock (بن‌بست) و Race Condition (شرایط رقابتی) روبه‌رو می‌شویم. همچنین یاد می‌گیریم چطور با استفاده از اصول Clean Code این مشکلات را حل کنیم. ✅


✨ مباحثی که در این فصل پوشش داده خواهند شد

  1. درک چرخه حیات Thread
  2. افزودن پارامتر به Thread
  3. استفاده از Thread Pool
  4. استفاده از Mutual Exclusion Object با Threadهای همگام (Synchronous)
  5. کار با Threadهای موازی با استفاده از Semaphore
  6. محدود کردن تعداد پردازنده‌ها و Threadها در Thread Pool
  7. جلوگیری از Deadlock
  8. جلوگیری از Race Condition
  9. درک Static Constructorها و Methodها
  10. آشنایی با Mutability (قابلیت تغییرپذیری)، Immutability (تغییرناپذیری) و Thread Safety
  11. Synchronized Method Dependencies
  12. استفاده از کلاس Interlocked برای تغییر وضعیت‌های ساده
  13. توصیه‌های عمومی 📝

🎯 پس از پایان این فصل شما توانایی‌های زیر را کسب خواهید کرد


⚡ پیش‌نیاز اجرای کدهای این فصل

برای اجرای کدهای این فصل به یک .NET Framework Console Application نیاز دارید.
مگر در مواقعی که به‌طور خاص ذکر شده باشد، تمام کدها درون کلاس Program قرار خواهند گرفت. 🖊️

🔄 درک چرخه‌ی حیات Thread

در زبان C#، هر Thread (رشته‌ی اجرایی) دارای یک چرخه‌ی حیات مشخص است.
این چرخه، مراحلی را نشان می‌دهد که یک Thread از زمان ایجاد شدن تا پایان کارش طی می‌کند.

چرخه‌ی حیات Thread به صورت زیر است: 🧩

Conventions-UsedThis-Book

وقتی یک Thread (رشته) شروع به کار می‌کند، وارد حالت Running (در حال اجرا) می‌شود. در این حالت، رشته می‌تواند وارد حالت‌های Wait (انتظار)، Sleep (خواب)، Join (پیوستن)، Stop (توقف) یا Suspended (معلق) شود. همچنین رشته‌ها می‌توانند Aborted (متوقف‌شده) شوند. رشته‌های متوقف‌شده وارد حالت Stop می‌شوند. شما می‌توانید با استفاده از متدهای Suspend() و Resume() یک رشته را به ترتیب معلق کرده و دوباره ادامه دهید.

یک رشته زمانی وارد حالت Wait می‌شود که متد Monitor.Wait(object obj) فراخوانی شود. سپس این رشته زمانی ادامه پیدا می‌کند که متد Monitor.Pulse(object obj) فراخوانی شود.
رشته‌ها با فراخوانی متد Thread.Sleep(int millisecondsTimeout) به حالت خواب می‌روند. پس از گذشت زمان مشخص‌شده، رشته دوباره به حالت اجرا بازمی‌گردد.

متد Thread.Join() باعث می‌شود یک رشته وارد حالت انتظار شود. رشته‌ای که Join شده است در حالت انتظار باقی می‌ماند تا تمام رشته‌های وابسته به آن اجرا شوند و سپس به حالت اجرا بازمی‌گردد. با این حال، اگر هر یک از رشته‌های وابسته متوقف شوند (Aborted)، این رشته هم متوقف شده و وارد حالت Stop می‌شود.
رشته‌هایی که کامل اجرا شده‌اند یا متوقف شده‌اند، دیگر قابل شروع مجدد نیستند.

رشته‌ها می‌توانند در Foreground (پیش‌زمینه) یا Background (پس‌زمینه) اجرا شوند. بیایید با رشته‌های پیش‌زمینه شروع کنیم:

🔵 رشته‌های پیش‌زمینه (Foreground Threads)

به‌صورت پیش‌فرض، رشته‌ها در پیش‌زمینه اجرا می‌شوند. یک Process (فرآیند) زمانی که حداقل یک رشته پیش‌زمینه در حال اجرا باشد، همچنان فعال باقی می‌ماند. حتی اگر متد Main() تمام شود اما هنوز یک رشته پیش‌زمینه در حال اجرا باشد، فرآیند برنامه تا پایان کار آن رشته فعال خواهد ماند. ایجاد یک رشته پیش‌زمینه بسیار ساده است، همان‌طور که در کد زیر می‌بینید:

var foregroundThread = new Thread(SomeMethodName);
foregroundThread.Start();

🟢 رشته‌های پس‌زمینه (Background Threads)

ایجاد رشته پس‌زمینه مشابه رشته‌های پیش‌زمینه است، با این تفاوت که شما باید به‌صورت صریح مشخص کنید که رشته در پس‌زمینه اجرا شود، همان‌طور که در مثال زیر نشان داده شده است:

var backgroundThread = new Thread(SomeMethodName);
backgroundThread.IsBackground = true;
backgroundThread.Start();

رشته‌های پس‌زمینه برای انجام کارهای جانبی و حفظ واکنش‌پذیری رابط کاربری استفاده می‌شوند. زمانی که فرآیند اصلی پایان یابد، هر رشته پس‌زمینه‌ای که در حال اجرا باشد نیز خاتمه پیدا می‌کند. اما حتی اگر فرآیند اصلی پایان یابد، رشته‌های پیش‌زمینه‌ای که در حال اجرا هستند، تا زمان کامل شدنشان اجرا خواهند شد.


➕ افزودن پارامتر به رشته‌ها (Adding Thread Parameters)

روش‌هایی که در رشته‌ها اجرا می‌شوند معمولاً پارامتر دارند. بنابراین هنگام اجرای یک متد درون یک رشته، باید بدانیم چگونه پارامترهای آن متد را به رشته ارسال کنیم.

فرض کنید متد زیر را داریم که دو عدد صحیح (int) را با هم جمع کرده و نتیجه را برمی‌گرداند:

private static int Add(int a, int b)
{
    return a + b;
}

همان‌طور که می‌بینید، این متد ساده است. دو پارامتر به نام‌های a و b دارد. این دو پارامتر باید به رشته ارسال شوند تا متد Add() به‌درستی اجرا شود.

در ادامه یک متد نمونه اضافه می‌کنیم که همین کار را انجام می‌دهد:

private static void ThreadParametersExample()
{
    int result = 0;
    Thread thread = new Thread(() => { result = Add(1, 2); });
    thread.Start();
    thread.Join();
    Message($"The addition of 1 plus 2 is {result}.");
}

در این متد، ابتدا یک متغیر int با مقدار اولیه 0 تعریف می‌کنیم. سپس یک رشته جدید ایجاد می‌کنیم که متد Add() را با مقادیر 1 و 2 فراخوانی کرده و نتیجه را در متغیر ذخیره می‌کند. رشته را اجرا می‌کنیم و با متد Join() منتظر می‌مانیم تا اجرای رشته تمام شود. در نهایت، نتیجه را در پنجره Console چاپ می‌کنیم.

برای این کار، متد Message() را اضافه می‌کنیم:

internal static void Message(string message)
{
    Console.WriteLine(message);
}

متد Message() یک رشته متنی را دریافت کرده و در پنجره کنسول نمایش می‌دهد. حالا کافی است متد Main() را به‌روزرسانی کنیم:

static void Main(string[] args)
{
    ThreadParametersExample();
    Message("=== Press any Key to exit ===");
    Console.ReadKey();
}

در متد Main()، ابتدا متد نمونه خود را فراخوانی می‌کنیم و سپس منتظر می‌مانیم تا کاربر کلیدی را فشار دهد تا برنامه بسته شود. خروجی که باید مشاهده کنید به‌صورت زیر خواهد بود: 👇

Conventions-UsedThis-Book

همان‌طور که مشاهده کردید، اعداد 1 و 2 پارامترهای متدی بودند که به متد جمع ارسال شدند و مقدار 3 خروجی‌ای بود که رشته برگرداند.


🌀 استفاده از Thread Pool (استخر رشته)

یک Thread Pool باعث بهبود عملکرد برنامه می‌شود، چون در هنگام شروع برنامه مجموعه‌ای از رشته‌ها ایجاد می‌کند. هر زمان یک رشته نیاز باشد، یکی از رشته‌های موجود در استخر برای انجام یک کار اختصاص داده می‌شود. پس از انجام آن کار، رشته دوباره به استخر برگردانده شده و قابل استفاده مجدد خواهد بود.

از آنجایی که ایجاد رشته در .NET هزینه‌بر است، می‌توان با استفاده از Thread Pool عملکرد را بهبود بخشید. هر فرآیند دارای تعداد مشخصی رشته است که براساس منابع سیستم (مثل حافظه و CPU) تعیین می‌شود. البته می‌توان تعداد رشته‌های استفاده‌شده در استخر را کم یا زیاد کرد، اما معمولاً بهتر است این مدیریت را به خود استخر بسپاریم و به‌صورت دستی تنظیمات انجام ندهیم.

روش‌های مختلف برای ایجاد یک Thread Pool عبارت‌اند از:

نکته مهم: به‌طور کلی، Thread Pool معمولاً برای برنامه‌های Server-side (سمت سرور) استفاده می‌شود. برای برنامه‌های Client-side (سمت کاربر)، بهتر است از رشته‌های Foreground و Background بسته به نیاز استفاده کنید.

در این کتاب، ما فقط به TPL و متد QueueUserWorkItem() می‌پردازیم. برای یادگیری روش‌های دیگر می‌توانید به لینک زیر مراجعه کنید:
http://www.albahari.com/threading/


⚡ کتابخانه Task Parallel Library (TPL)

در C#، یک عملیات ناهمزمان توسط یک Task (وظیفه) نمایش داده می‌شود. هر Task در C# توسط کلاس Task در TPL نمایش داده می‌شود. همان‌طور که از نامش پیداست، Task Parallelism امکان اجرای هم‌زمان چند Task را فراهم می‌کند که در بخش‌های بعدی درباره آن یاد می‌گیریم.

اولین متدی که از کلاس Parallel بررسی می‌کنیم، متد Invoke() است.


🔄 Parallel.Invoke()

در اولین مثال، سه متد مختلف را با استفاده از Parallel.Invoke() فراخوانی می‌کنیم. سه متد زیر را اضافه کنید:

private static void MethodOne()
{
    Message($"MethodOne Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

private static void MethodTwo()
{
    Message($"MethodTwo Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

private static void MethodThree()
{
    Message($"MethodThree Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

همان‌طور که می‌بینید، این سه متد تقریباً مشابه هستند و تنها نام‌ها و پیام چاپ‌شده در کنسول متفاوت است که توسط متد Message() (که قبلاً نوشتیم) نمایش داده می‌شود.

حالا متد UsingTaskParallelLibrary() را اضافه می‌کنیم تا این سه متد را به‌صورت موازی اجرا کنیم:

private static void UsingTaskParallelLibrary()
{
    Message($"UsingTaskParallelLibrary Started: Thread Id = ({Thread.CurrentThread.ManagedThreadId})");
    Parallel.Invoke(MethodOne, MethodTwo, MethodThree);
    Message("UsingTaskParallelLibrary Completed.");
}

در این متد، ابتدا پیامی در پنجره Console چاپ می‌کنیم تا نشان دهد شروع متد آغاز شده است. سپس متدهای MethodOne، MethodTwo و MethodThree را به‌صورت موازی اجرا می‌کنیم. پس از آن، پیامی در Console چاپ می‌کنیم تا مشخص شود متد به انتهای اجرای خود رسیده است و سپس منتظر می‌مانیم تا کاربر کلیدی را فشار دهد و در نهایت متد خاتمه پیدا کند.

کد را اجرا کنید و باید خروجی زیر را مشاهده کنید: 👇

Conventions-UsedThis-Book

در تصویر قبلی، مشاهده می‌کنید که رشته شماره 1 دوباره استفاده شده است. حالا بیایید به سراغ حلقه Parallel.For() برویم.


🔁 Parallel.For()

در مثال بعدی مربوط به TPL، به یک حلقه ساده Parallel.For() نگاه می‌کنیم. متد زیر را به کلاس Program در یک برنامه Console جدید با .NET Framework اضافه کنید:

private static void Method()
{
    Message($"Method Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

این متد تنها یک رشته متنی را در پنجره Console چاپ می‌کند.

حالا متدی ایجاد می‌کنیم که حلقه Parallel.For() را اجرا کند:

private static void UsingTaskParallelLibraryFor()
{
    Message($"UsingTaskParallelLibraryFor Started: Thread Id = ({Thread.CurrentThread.ManagedThreadId})");
    Parallel.For(0, 1000, X => Method());
    Message("UsingTaskParallelLibraryFor Completed.");
}

در این متد، از عدد 0 تا 1000 حلقه می‌زنیم و در هر تکرار، متد Method() را فراخوانی می‌کنیم. در طول اجرای این حلقه، خواهید دید که رشته‌ها در فراخوانی‌های مختلف دوباره استفاده می‌شوند، همان‌طور که در تصویر زیر مشخص است: 👇

Conventions-UsedThis-Book

حالا می‌خواهیم به سراغ استفاده از متد ThreadPool.QueueUserWorkItem() برویم.


🧵 ThreadPool.QueueUserWorkItem()

متد ThreadPool.QueueUserWorkItem() یک متد WaitCallback را دریافت کرده و آن را برای اجرا در صف قرار می‌دهد.
WaitCallback یک Delegate (نماینده) است که نشان‌دهنده متدی است که باید توسط یک رشته در Thread Pool اجرا شود. وقتی یک رشته آزاد شود، این متد اجرا خواهد شد.

بیایید یک مثال ساده اضافه کنیم. ابتدا متد زیر را تعریف می‌کنیم:

private static void WaitCallbackMethod(Object _)
{
    Message("Hello from WaitCallBackMethod!");
}

این متد یک پارامتر از نوع Object دریافت می‌کند، اما چون از آن استفاده نمی‌کنیم، از متغیر discard (_) استفاده شده است. سپس پیامی در پنجره Console چاپ می‌شود.

حالا کد فراخوانی این متد را اضافه می‌کنیم:

private static void ThreadPoolQueueUserWorkItem()
{
    ThreadPool.QueueUserWorkItem(WaitCallbackMethod);
    Message("Main thread does some work, then sleeps.");
    Thread.Sleep(1000);
    Message("Main thread exits.");
}

همان‌طور که می‌بینید، از کلاس ThreadPool برای قرار دادن WaitCallbackMethod() در صف Thread Pool استفاده کردیم. سپس رشته اصلی (Main thread) کمی کار انجام داده و به خواب می‌رود. در این زمان، یک رشته آزاد از استخر رشته‌ها انتخاب شده و متد WaitCallbackMethod() اجرا می‌شود. سپس رشته به استخر برگردانده شده و قابل استفاده مجدد خواهد بود. اجرای برنامه به رشته اصلی بازمی‌گردد و در نهایت برنامه خاتمه پیدا می‌کند.


🔒 استفاده از Mutex با رشته‌های همزمان

در بخش بعدی، به سراغ Mutex (شیء قفل‌کننده) می‌رویم.

در C#، یک Mutex یک شیء قفل‌کننده رشته است که می‌تواند بین چندین فرآیند کار کند. تنها فرآیندی که درخواست یا آزادسازی یک منبع را دارد، می‌تواند Mutex را تغییر دهد. زمانی که یک Mutex قفل شود، بقیه فرآیندها باید در صف منتظر بمانند. وقتی قفل آزاد شد، بقیه می‌توانند به منبع دسترسی داشته باشند. چندین رشته می‌توانند از یک Mutex استفاده کنند، اما فقط به‌صورت همزمان‌سازی شده (Synchronous).

مزایای استفاده از Mutex

معایب استفاده از Mutex


✏️ برنامه نمونه برای استفاده از Mutex

ابتدا در بالای کلاس، متغیر زیر را اضافه کنید:

private static readonly Mutex _mutex = new Mutex();

در اینجا، یک شیء ابتدایی به نام _mutex تعریف کرده‌ایم که برای همگام‌سازی بین فرآیندها استفاده خواهد شد.

حالا متدی برای نمایش همگام‌سازی رشته‌ها با استفاده از Mutex اضافه می‌کنیم:

private static void ThreadSynchronisationUsingMutex()
{
    try
    {
        _mutex.WaitOne();
        Message($"Domain Entered By: {Thread.CurrentThread.Name}");
        Thread.Sleep(500);
        Message($"Domain Left By: {Thread.CurrentThread.Name}");
    }
    finally
    {
        _mutex.ReleaseMutex();
    }
}

در این متد، رشته جاری تا زمانی که Wait Handle (دستگیره انتظار) سیگنال دریافت کند، بلوکه می‌شود. وقتی سیگنال داده شود، رشته بعدی می‌تواند وارد شود. پس از پایان کار، سایر رشته‌ها از حالت انتظار آزاد شده و امکان دسترسی به Mutex را خواهند داشت.

متد بعدی برای اجرای نمونه Mutex:

private static void MutexExample()
{
    for (var i = 1; i <= 10; i++)
    {
        var thread = new Thread(ThreadSynchronisationUsingMutex)
        {
            Name = $"Mutex Example Thread: {i}"
        };
        thread.Start();
    }
}

در این متد، 10 رشته ایجاد کرده و آن‌ها را شروع می‌کنیم. هر رشته متد ThreadSynchronisationUsingMutex() را اجرا می‌کند.

در نهایت، متد Main() را به‌روزرسانی کنید:

static void Main(string[] args)
{
    SemaphoreExample();
    Console.ReadKey();
}

متد Main() نمونه‌ی استفاده از Mutex ما را اجرا می‌کند. خروجی باید مشابه تصویر زیر باشد: 👇

Conventions-UsedThis-Book

مثالی که اجرا کردیم ممکن است هر بار اعداد مختلفی برای رشته‌ها نشان دهد. حتی اگر اعداد مشابه باشند، ممکن است ترتیب نمایش آن‌ها متفاوت باشد.

حالا که با Mutex آشنا شدیم، بیایید به Semaphore (سِمافور) بپردازیم.


🟡 کار با رشته‌های موازی با استفاده از Semaphore

در برنامه‌های چندرشته‌ای (Multi-threaded Applications)، یک عدد غیرمنفی به نام Semaphore بین رشته‌ها به اشتراک گذاشته می‌شود. این مقدار می‌تواند ۱ یا ۲ باشد که در همگام‌سازی به‌ترتیب به معنی انتظار (Wait) و سیگنال (Signal) است.
می‌توان یک Semaphore را به تعدادی Buffer (حافظه موقت) مرتبط کرد تا فرآیندهای مختلف به‌صورت هم‌زمان روی هرکدام کار کنند.

به‌طور خلاصه، Semaphore یک مکانیزم سیگنال‌دهی است که از انواع Integer و Binary بوده و با عملیات Wait و Signal قابل تغییر است. اگر منابع آزاد موجود نباشند، فرآیندهایی که نیاز به منبع دارند باید عملیات Wait را اجرا کنند تا زمانی که مقدار Semaphore بزرگ‌تر از صفر شود. Semaphore می‌تواند چندین رشته برنامه را مدیریت کند و هر شیء می‌تواند آن را تغییر دهد، منبع بگیرد یا آزاد کند.

مزایای Semaphore

معایب Semaphore


✏️ برنامه نمونه با استفاده از Semaphore

ابتدا یک متغیر Semaphore تعریف می‌کنیم:

private static readonly Semaphore _semaphore = new Semaphore(2, 4);

پارامتر اول مشخص می‌کند چند درخواست می‌توانند به‌طور هم‌زمان پذیرفته شوند.
پارامتر دوم حداکثر تعداد درخواست‌هایی را که می‌توان هم‌زمان پذیرفت مشخص می‌کند.

حالا متد StartSemaphore() را اضافه می‌کنیم:

private static void StartSemaphore(object id)
{
    Console.WriteLine($"Object {id} wants semaphore access.");
    try
    {
        _semaphore.WaitOne();
        Console.WriteLine($"Object {id} gained semaphore access.");
        Thread.Sleep(1000);
        Console.WriteLine($"Object {id} has exited semaphore.");
    }
    finally
    {
        _semaphore.Release();
    }
}

در این متد، رشته جاری تا زمانی که Wait Handle سیگنال دریافت کند بلوکه می‌شود. سپس رشته کار خود را انجام می‌دهد و در پایان Semaphore آزاد می‌شود و مقدارش به حالت قبلی برمی‌گردد.

حالا متد SemaphoreExample() را اضافه کنید:

private static void SemaphoreExample()
{
    for (int i = 1; i <= 10; i++)
    {
        Thread t = new Thread(StartSemaphore);
        t.Start(i);
    }
}

این مثال ۱۰ رشته ایجاد می‌کند که متد StartSemaphore() را اجرا می‌کنند.

در نهایت، متد Main() را به‌روزرسانی کنید:

static void Main(string[] args)
{
    SemaphoreExample();
    Console.ReadKey();
}

متد Main() متد SemaphoreExample() را فراخوانی کرده و سپس منتظر می‌ماند تا کاربر کلیدی را فشار دهد تا برنامه خاتمه پیدا کند. خروجی باید مشابه تصویر زیر باشد: 👇

Conventions-UsedThis-Book

بیایید به نحوه محدود کردن تعداد پردازنده‌ها و رشته‌ها در Thread Pool بپردازیم.


⚙️ محدود کردن تعداد پردازنده‌ها و رشته‌ها در Thread Pool

گاهی اوقات نیاز است که تعداد پردازنده‌ها و رشته‌های مورد استفاده توسط برنامه خود را محدود کنید.

محدود کردن پردازنده‌ها

برای کاهش تعداد پردازنده‌های مورد استفاده، ابتدا پردازش جاری را دریافت کرده و مقدار ProcessorAffinity آن را تنظیم می‌کنیم.
مثلاً فرض کنید یک کامپیوتر چهار هسته‌ای داریم و می‌خواهیم فقط از دو هسته اول استفاده کنیم. مقدار باینری دو هسته اول برابر است با 11 که در حالت عدد صحیح برابر با 3 است.

متدی در یک برنامه جدید .NET Framework Console ایجاد می‌کنیم و نام آن را AssignCores() می‌گذاریم:

private static void AssignCores(int cores)
{
    Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(cores);
}

در این متد، یک عدد صحیح به عنوان ورودی می‌دهیم. این عدد توسط .NET Framework به مقدار باینری تبدیل می‌شود و پردازنده‌هایی که مقدار ۱ دارند استفاده می‌شوند، در حالی که پردازنده‌هایی که مقدارشان ۰ است، استفاده نخواهند شد.
مثال‌ها:

اگر نیاز به یادآوری باینری دارید، می‌توانید به این لینک مراجعه کنید.


تنظیم حداکثر تعداد رشته‌ها

برای تنظیم حداکثر تعداد رشته‌ها، متد SetMaxThreads() از کلاس ThreadPool را فراخوانی می‌کنیم. این متد دو پارامتر می‌گیرد:

  1. حداکثر تعداد Worker Threads در Thread Pool
  2. حداکثر تعداد Asynchronous I/O Threads در Thread Pool

متد زیر را برای تنظیم حداکثر تعداد رشته‌ها اضافه می‌کنیم:

private static void SetMaxThreads(int workerThreads, int asyncIoThreads)
{
    ThreadPool.SetMaxThreads(workerThreads, asyncIoThreads);
}

همان‌طور که می‌بینید، تنظیم حداکثر تعداد رشته‌ها و پردازنده‌ها بسیار ساده است. در اغلب مواقع نیازی به انجام این کار در برنامه‌ها نیست، مگر اینکه برنامه شما با مشکلات عملکرد مواجه شود. اگر برنامه مشکلی در عملکرد ندارد، بهتر است تعداد رشته‌ها و پردازنده‌ها را تنظیم نکنید.


🛑 جلوگیری از Deadlocks (قفل بن‌بست)

یک Deadlock زمانی رخ می‌دهد که دو یا چند رشته در حال اجرا هستند و منتظر یکدیگر برای تکمیل هستند. این مشکل در برنامه‌ها باعث گیر کردن برنامه می‌شود و برای کاربر نهایی می‌تواند بسیار خطرناک باشد، چرا که ممکن است داده‌ها از بین بروند یا خراب شوند.

مثال واقعی

فرض کنید یک تراکنش بانکی بزرگ داریم که باید £1 میلیون از حساب بانکی یک مشتری برای پرداخت مالیات HMRC برداشت شود.

نتیجه: حساب بانکی کسب‌وکار £1 میلیون کاهش می‌یابد اما مالیات پرداخت نشده است و مشتری هنوز مسئول پرداخت آن است. این مثال اهمیت جلوگیری از رخ دادن Deadlocks را نشان می‌دهد.

برای ساده‌تر کردن موضوع، ما با دو رشته کار خواهیم کرد، همان‌طور که در نمودار زیر نشان داده شده است:

Conventions-UsedThis-Book

ما رشته‌هایمان را Thread 1 و Thread 2 و منابعمان را Resource 1 و Resource 2 می‌نامیم.

حالا:

نتیجه این است که هم Thread 1 و هم Thread 2 در حالت Wait قرار می‌گیرند. از آنجا که هیچ‌یک از رشته‌ها نمی‌توانند ادامه دهند تا دیگری منبعش را آزاد کند، هر دو رشته وارد وضعیت Deadlock (قفل بن‌بست) می‌شوند. زمانی که یک برنامه کامپیوتری در حالت Deadlock قرار دارد، برنامه گیر می‌کند و مجبور می‌شوید آن را خاتمه دهید.

یک مثال کد برای Deadlock بهترین راه برای توضیح این موضوع است. در بخش بعدی، یک مثال عملی از Deadlock می‌نویسیم.


🖥️ کدنویسی مثال Deadlock

بهترین روش برای فهم این موضوع، یک مثال عملی است. ما دو متد خواهیم نوشت که هر کدام دارای دو Lock مختلف هستند. این متدها اشیایی را قفل می‌کنند که متد دیگر به آن نیاز دارد. چون هر رشته منابعی را که رشته دیگر نیاز دارد قفل می‌کند، هر دو رشته وارد حالت Deadlock می‌شوند. بعد از اینکه مثال ما کار کرد، آن را اصلاح خواهیم کرد تا از وضعیت Deadlock بازیابی شود و ادامه دهد.

ابتدا یک برنامه جدید .NET Framework Console ایجاد کنید و نام آن را CH08_Deadlocks بگذارید. نیاز به دو شیء به عنوان متغیرهای عضو داریم:

static object _object1 = new object();
static object _object2 = new object();

این اشیاء به عنوان Lock Objects استفاده خواهند شد. ما دو رشته خواهیم داشت و هر رشته متد مخصوص به خود را اجرا می‌کند.


متد Thread1Method()

private static void Thread1Method()
{
    Console.WriteLine("Thread1Method: Thread1Method Entered.");
    lock (_object1)
    {
        Console.WriteLine("Thread1Method: Entered _object1 lock. Sleeping...");
        Thread.Sleep(1000);
        Console.WriteLine("Thread1Method: Woke from sleep");
        lock (_object2)
        {
            Console.WriteLine("Thread1Method: Entered _object2 lock.");
        }
        Console.WriteLine("Thread1Method: Exited _object2 lock.");
    }
    Console.WriteLine("Thread1Method: Exited _object1 lock.");
}

متد Thread2Method()

private static void Thread2Method()
{
    Console.WriteLine("Thread2Method: Thread1Method Entered.");
    lock (_object2)
    {
        Console.WriteLine("Thread2Method: Entered _object2 lock. Sleeping...");
        Thread.Sleep(1000);
        Console.WriteLine("Thread2Method: Woke from sleep.");
        lock (_object1)
        {
            Console.WriteLine("Thread2Method: Entered _object1 lock.");
        }
        Console.WriteLine("Thread2Method: Exited _object1 lock.");
    }
    Console.WriteLine("Thread2Method: Exited _object2 lock.");
}

متد DeadlockNoRecovery()

private static void DeadlockNoRecovery()
{
    Thread thread1 = new Thread((ThreadStart)Thread1Method);
    Thread thread2 = new Thread((ThreadStart)Thread2Method);
    thread1.Start();
    thread2.Start();
    Console.WriteLine("Press any key to exit.");
    Console.ReadKey();
}

در این متد:


متد Main()

static void Main()
{
    DeadlockNoRecovery();
}

هنگام اجرای برنامه، باید خروجی مشابه تصویر زیر را مشاهده کنید: 👇

Conventions-UsedThis-Book

همان‌طور که می‌بینید، چون Thread1 روی _object1 قفل دارد، Thread2 نمی‌تواند آن را قفل کند. همچنین، چون Thread2 روی _object2 قفل دارد، Thread1 نمی‌تواند آن را قفل کند. بنابراین هر دو رشته وارد Deadlock می‌شوند و برنامه گیر می‌کند.

حالا کدی می‌نویسیم که نشان دهد چگونه می‌توان از وقوع این Deadlock جلوگیری کرد. ما از متد Monitor.TryEnter() استفاده می‌کنیم تا تلاش کنیم در مدت زمان مشخصی Lock بگیریم. سپس در صورت موفقیت، با Monitor.Exit() قفل را آزاد می‌کنیم.


متد DeadlockWithRecovery()

private static void DeadlockWithRecovery()
{
    Thread thread4 = new Thread((ThreadStart)Thread4Method);
    Thread thread5 = new Thread((ThreadStart)Thread5Method);
    thread4.Start();
    thread5.Start();
    Console.WriteLine("Press any key to exit.");
    Console.ReadKey();
}

متد Thread4Method()

private static void Thread4Method()
{
    Console.WriteLine("Thread4Method: Entered _object1 lock. Sleeping...");
    Thread.Sleep(1000);
    Console.WriteLine("Thread4Method: Woke from sleep");
    
    if (!Monitor.TryEnter(_object1))
    {
        Console.WriteLine("Thread4Method: Failed to lock _object1.");
        return;
    }
    try
    {
        if (!Monitor.TryEnter(_object2))
        {
            Console.WriteLine("Thread4Method: Failed to lock _object2.");
            return;
        }
        try
        {
            Console.WriteLine("Thread4Method: Doing work with _object2.");
        }
        finally
        {
            Monitor.Exit(_object2);
            Console.WriteLine("Thread4Method: Released _object2 lock.");
        }
    }
    finally
    {
        Monitor.Exit(_object1);
        Console.WriteLine("Thread4Method: Released _object1 lock.");
    }
}

متد Thread5Method()

private static void Thread5Method()
{
    Console.WriteLine("Thread5Method: Entered _object2 lock. Sleeping...");
    Thread.Sleep(1000);
    Console.WriteLine("Thread5Method: Woke from sleep");
    
    if (!Monitor.TryEnter(_object2))
    {
        Console.WriteLine("Thread5Method: Failed to lock _object2.");
        return;
    }
    try
    {
        if (!Monitor.TryEnter(_object1))
        {
            Console.WriteLine("Thread5Method: Failed to lock _object1.");
            return;
        }
        try
        {
            Console.WriteLine("Thread5Method: Doing work with _object1.");
        }
        finally
        {
            Monitor.Exit(_object1);
            Console.WriteLine("Thread5Method: Released _object1 lock.");
        }
    }
    finally
    {
        Monitor.Exit(_object2);
        Console.WriteLine("Thread5Method: Released _object2 lock.");
    }
}

فراخوانی در Main()

static void Main()
{
    DeadlockWithRecovery();
}

کد را چند بار اجرا کنید. در اکثر موارد، خروجی مشابه تصویر زیر خواهد بود، جایی که همه Lockها با موفقیت گرفته شده‌اند. ✅

Conventions-UsedThis-Book

سپس، هر کلیدی را فشار دهید تا برنامه خارج شود. اگر برنامه را چند بار اجرا کنید، در نهایت خواهید دید که یک Lock شکست می‌خورد. برنامه نتوانست در Thread5Method() روی _object2 قفل بگیرد. با این حال، اگر کلیدی را فشار دهید، برنامه خارج می‌شود.

همان‌طور که می‌بینید، با استفاده از Monitor.TryEnter() می‌توانید تلاش کنید یک شیء را قفل کنید. اما اگر قفل گرفته نشد، می‌توانید اقدام دیگری انجام دهید بدون اینکه برنامه شما گیر کند یا Hang شود. ✅


⚡ پیشگیری از Race Conditions

زمانی که چندین رشته از یک منبع مشترک استفاده می‌کنند و خروجی‌های متفاوتی به دلیل زمان‌بندی هر رشته تولید می‌شود، این وضعیت را Race Condition می‌نامند.
در این بخش، ما این وضعیت را با یک مثال عملی نشان خواهیم داد.

در مثال ما:


متد ThreadingRaceCondition()

static void ThreadingRaceCondition()
{
    Thread T1 = new Thread(Method1);
    T1.Start();
    Thread T2 = new Thread(Method2);
    T2.Start();
}

متد Method1()

static void Method1()
{
    for (_alphabetCharacter = 'A'; _alphabetCharacter <= 'Z'; _alphabetCharacter++)
    {
        Console.Write(_alphabetCharacter + " ");
    }
}

متد Method2()

private static void Method2()
{
    for (_alphabetCharacter = 'a'; _alphabetCharacter <= 'z'; _alphabetCharacter++)
    {
        Console.Write(_alphabetCharacter + " ");
    }
}
private static char _alphabetCharacter;

به‌روزرسانی متد Main()

static void Main(string[] args)
{
    Console.WriteLine("\n\nRace Condition:");
    ThreadingRaceCondition();
    Console.WriteLine("\n\nPress any key to exit.");
    Console.ReadKey();
}

حالا کد ما آماده است تا Race Condition را نشان دهد. اگر برنامه را چندین بار اجرا کنید، خواهید دید که نتایج همان چیزی نیست که انتظار داریم و حتی ممکن است کاراکترهایی خارج از حروف الفبا نیز چاپ شوند. ⚠️

Conventions-UsedThis-Book

دقیقاً همان چیزی نبود که انتظار داشتیم، درست است؟ 😅

ما می‌خواهیم این مشکل را با استفاده از TPL حل کنیم. هدف Task Parallel Library (TPL) ساده‌سازی Parallelism و Concurrency است. با توجه به اینکه اکثر کامپیوترهای امروزی دارای دو یا چند پردازنده هستند، TPL به صورت پویا میزان Concurrency را مقیاس‌بندی می‌کند تا از تمامی پردازنده‌های موجود به بهینه‌ترین شکل استفاده شود. ⚡


راه حل این مشکل ساده است. ما یک Task داریم که ابتدا Method1() را اجرا می‌کند. سپس این Task ادامه می‌دهد و Method2() را اجرا می‌کند. در نهایت با فراخوانی Wait() منتظر می‌مانیم تا Task کامل شود.


متد ThreadingRaceConditionFixed()

static void ThreadingRaceConditionFixed()
{
    Task
        .Run(() => Method1())
        .ContinueWith(task => Method2())
        .Wait();
}

به‌روزرسانی متد Main()

static void Main(string[] args)
{
    //Console.WriteLine("\n\nRace Condition:");
    //ThreadingRaceCondition();
    Console.WriteLine("\n\nRace Condition Fixed:");
    ThreadingRaceConditionFixed();
    Console.WriteLine("\n\nPress any key to exit.");
    Console.ReadKey();
}

کد را اجرا کنید. اگر برنامه را چندین بار اجرا کنید، خواهید دید که خروجی همیشه یکسان است، همان‌طور که در تصویر زیر نشان داده شده است. 🎯

Conventions-UsedThis-Book

تا اینجا، ما دیدیم که Thread چیست و چگونه می‌توان از آن‌ها در Foreground و Background استفاده کرد. همچنین با Deadlocks و نحوه حل آن‌ها با Monitor.TryEnter() آشنا شدیم. در نهایت، با Race Conditions و نحوه حل آن‌ها با استفاده از TPL آشنا شدیم. ✅

حالا، به سراغ Static Constructors و Static Methods می‌رویم.


🔹 درک Static Constructors و Methods

اگر چندین کلاس همزمان به یک Property Instance دسترسی نیاز داشته باشند، یکی از Threadها درخواست اجرای Static Constructor (که به آن Type Initializer هم گفته می‌شود) می‌دهد.
در حالی که منتظر اجرای Type Initializer هستیم، تمام Threadهای دیگر قفل می‌شوند. وقتی Type Initializer اجرا شد، Threadهای قفل‌شده آزاد شده و قادر به دسترسی به Instance Property خواهند بود.


نکات مهم درباره Static Methods

⚠️ اما:

✅ نتیجه:


💻 اضافه کردن Static Constructors به کد نمونه

یک برنامه جدید .NET Framework Console بسازید.
یک کلاس به نام StaticConstructorTestClass اضافه کنید و یک متغیر Read-Only Static String به نام _message تعریف کنید:

public class StaticConstructorTestClass
{
    private readonly static string _message;
}

این متغیر توسط متد Message() به فراخواننده بازگردانده می‌شود.

متد Message()

public static string Message()
{
    return $"Message: {_message}";
}

Constructor

static StaticConstructorTestClass()
{
    Console.WriteLine("StaticConstructorTestClass static constructor started.");
    _message = "Hello, World!";
    Thread.Sleep(1000);
    _message = "Goodbye, World!";
    Console.WriteLine("StaticConstructorTestClass static constructor finished.");
}

به‌روزرسانی متد Main()

static void Main(string[] args)
{
    var program = new Program();
    program.StaticConstructorExample();
    Thread.CurrentThread.Join();
}

خروجی را می‌توانید در تصویر زیر مشاهده کنید. 📺

Conventions-UsedThis-Book

حالا به سراغ مثال‌هایی از Static Methods می‌رویم.


💻 اضافه کردن Static Methods به کد نمونه

اکنون قصد داریم Static Methods ایمن برای Thread و غیر ایمن را عملی مشاهده کنیم.

یک کلاس جدید به نام StaticExampleClass به یک برنامه .NET Framework Console اضافه کنید و کد زیر را بنویسید:

public static class StaticExampleClass
{
    private static int _x = 1;
    private static int _y = 2;
    private static int _z = 3;
}

Static Constructor

حالا یک Static Constructor اضافه می‌کنیم تا مقادیر این متغیرها را چاپ کند:

static StaticExampleClass()
{
    Console.WriteLine($"Constructor: _x={_x}, _y={_y}, _z={_z}");
}

Thread-Safe Method

متد اول ما یک متد Thread-Safe به نام ThreadSafeMethod() است:

internal static void ThreadSafeMethod(int x, int y, int z)
{
    Console.WriteLine($"ThreadSafeMethod: x={x}, y={y}, z={z}");
    Console.WriteLine($"ThreadSafeMethod: {x}+{y}+{z}={x+y+z}");
}

✅ یعنی چه یک Thread واحد یا حتی میلیون‌ها Thread به این متد دسترسی داشته باشند، خروجی هر Thread همان چیزی خواهد بود که انتظار دارید، حتی با وجود Context Switching.

خروجی این متد را در Screenshot بعدی مشاهده می‌کنیم. 📊

Conventions-UsedThis-Book

حال که با Thread-Safe Methods آشنا شدیم، منطقی است که نگاهی هم به Non-Thread-Safe Methods بیندازیم.

✅ تا اینجا می‌دانید که هر Static Method که روی by reference values یا Static Member Variables عمل کند، Thread-Safe نیست.


Non-Thread-Safe Method

در مثال بعدی، متدی با همان سه پارامتر ThreadSafeMethod() خواهیم داشت، اما این بار:

کد NotThreadSafeMethod() را به کلاس StaticExampleClass اضافه کنید:

internal static void NotThreadSafeMethod(int x, int y, int z)
{
    _x = x;
    _y = y;
    _z = z;
    Console.WriteLine(
        $"{Thread.CurrentThread.ManagedThreadId}-NotThreadSafeMethod: _x={_x}, _y={_y}, _z={_z}"
    );
    Thread.Sleep(300);
    Console.WriteLine(
        $"{Thread.CurrentThread.ManagedThreadId}-ThreadSafeMethod: {_x}+{_y}+{_z}={_x + _y + _z}"
    );
}

بروزرسانی Main Method

در کلاس Program، Main() را به شکل زیر بروزرسانی کنید:

static void Main(string[] args)
{
    var program = new Program();
    program.ThreadUnsafeMethodCall();
    Console.ReadKey();
}

ThreadUnsafeMethodCall

حالا ThreadUnsafeMethodCall() را به کلاس Program اضافه کنید:

private void ThreadUnsafeMethodCall()
{
    for (var index = 0; index < 10; index++)
    {
        var thread = new Thread(() =>
        {
            StaticExampleClass.NotThreadSafeMethod(index + 1, index + 2, index + 3);
        });
        thread.Start();
    }
}

خروجی نشان می‌دهد که مقادیر بین Threadها تداخل دارند و به دلیل عدم ایمنی Thread، نتایج غیرقابل پیش‌بینی است.

Conventions-UsedThis-Book

همان‌طور که می‌بینید، خروجی برنامه آن چیزی نیست که انتظار داشتیم. این به دلیل آلودگی داده‌ها توسط Threadهای مختلف رخ می‌دهد. این موضوع ما را به بخش بعدی یعنی قابلیت تغییر (Mutability)، عدم تغییر (Immutability) و ایمنی Thread می‌رساند. 🔒


قابلیت تغییر، عدم تغییر و ایمنی Thread

Mutability منبع بروز خطا در برنامه‌های چند Threadه است. یک خطای قابل تغییر معمولاً ناشی از اشتراک‌گذاری و به‌روزرسانی مقادیر بین Threadهاست.

برای حذف خطر این نوع خطاها، بهتر است از Immutable Types استفاده کنیم.

Thread Safety یعنی اجرای امن یک بخش کد توسط چند Thread به‌طور همزمان. وقتی برنامه‌های چند Threadه می‌نویسید، مهم است که کد شما Thread-Safe باشد. کد شما Thread-Safe است اگر شرایطی مانند Race Conditions و Deadlocks و مشکلات ناشی از Mutability را حذف کند.

بنابراین، وقتی با چند Thread کار می‌کنید، مهم است که اشیاء شما Immutable باشند. در فصل ۳ (Classes, Objects, and Data Structures) روش ایجاد و استفاده از ساختارهای داده غیرقابل تغییر را یاد گرفتیم.

✅ برای اطمینان از Thread Safety:

Immutable Structures روش ایمن و مناسب برای انتقال داده‌ها بین اشیاء هستند.


نمونه کد برای Mutable و غیر Thread-Safe

برای نمایش Mutability در یک برنامه چند Threadه، یک Console Application جدید ایجاد کنید. یک کلاس به نام MutableClass اضافه کنید:

internal class MutableClass
{
    private readonly int[] _intArray;
    public MutableClass(int[] intArray)
    {
        _intArray = intArray;
    }
    public int[] GetIntArray()
    {
        return _intArray;
    }
}

MutableExample Method

در کلاس Program، متد MutableExample() را اضافه کنید تا نشان دهد آرایه mutable است:

private static void MutableExample()
{
    int[] iar = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    var mutableClass = new MutableClass(iar);
    Console.WriteLine($"Initial Array: {iar[0]}, {iar[1]}, {iar[2]}, {iar[3]}, {iar[4]}, {iar[5]}, {iar[6]}, {iar[7]}, {iar[8]}, {iar[9]}");
    for (var x = 0; x < 9; x++)
    {
        var thread = new Thread(() =>
        {
            iar[x] = x + 1;
            var ia = mutableClass.GetIntArray();
            Console.WriteLine($"Array [{x}]: {ia[0]}, {ia[1]}, {ia[2]}, {ia[3]}, {ia[4]}, {ia[5]}, {ia[6]}, {ia[7]}, {ia[8]}, {ia[9]}");
        });
        thread.Start();
    }
}

بروزرسانی Main Method

static void Main(string[] args)
{
    MutableExample();
    Console.ReadKey();
}

با اجرای این برنامه، خروجی مشابه Screenshot بعدی نمایش داده خواهد شد و نشان می‌دهد که چند Thread مقادیر آرایه را همزمان تغییر می‌دهند و باعث عدم پیش‌بینی وضعیت نهایی آرایه می‌شوند. ⚡

Conventions-UsedThis-Book

همان‌طور که می‌بینید، حتی با اینکه تنها یک نمونه از MutableClass ایجاد کردیم قبل از ساخت و اجرای Threadها، تغییر آرایه محلی باعث تغییر آرایه در نمونه MutableClass می‌شود. این ثابت می‌کند که آرایه‌ها قابل تغییر (Mutable) هستند و بنابراین Thread-Safe نیستند. ⚠️


نوشتن کد Immutable و Thread-Safe

در مثال Immutable، دوباره یک برنامه .NET Framework Console Application ایجاد می‌کنیم و از همان آرایه استفاده می‌کنیم.

یک کلاس به نام ImmutableStruct اضافه کنید و کد را به شکل زیر اصلاح کنید:

internal struct ImmutableStruct
{
    private ImmutableArray<int> _immutableArray;
    public ImmutableStruct(ImmutableArray<int> immutableArray)
    {
        _immutableArray = immutableArray;
    }
    public int[] GetIntArray()
    {
        return _immutableArray.ToArray<int>();
    }
}

ImmutableExample Method

در کلاس Program، متد ImmutableExample() را اضافه کنید:

private static void ImmutableExample()
{
    int[] iar = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    var immutableStruct = new ImmutableStruct(iar.ToImmutableArray<int>());
    Console.WriteLine($"Initial Array: {iar[0]}, {iar[1]}, {iar[2]}, {iar[3]}, {iar[4]}, {iar[5]}, {iar[6]}, {iar[7]}, {iar[8]}, {iar[9]}");
    for (var x = 0; x < 9; x++)
    {
        var thread = new Thread(() =>
        {
            iar[x] = x + 1;
            var ia = immutableStruct.GetIntArray();
            Console.WriteLine($"Array [{x}]: {ia[0]}, {ia[1]}, {ia[2]}, {ia[3]}, {ia[4]}, {ia[5]}, {ia[6]}, {ia[7]}, {ia[8]}, {ia[9]}");
        });
        thread.Start();
    }
}

متد ImmutableExample() را از Main() فراخوانی کرده و برنامه را اجرا کنید. خروجی نشان می‌دهد که آرایه Immutable هیچ تغییری توسط Threadها نمی‌بیند و برنامه Thread-Safe است. ✅

Conventions-UsedThis-Book

همان‌طور که می‌بینید، محتوای آرایه با تغییر آرایه محلی تغییر نمی‌کند. این نسخه از برنامه نشان می‌دهد که برنامه ما Thread-Safe است. ✅


درک مفهوم Thread-Safety 🛡️

همان‌طور که در دو بخش قبلی دیدید، هنگام نوشتن کدهای چند نخی باید بسیار مراقب بود. نوشتن کد Thread-Safe می‌تواند به‌ویژه در پروژه‌های بزرگ بسیار دشوار باشد. شما باید به‌طور ویژه مراقب موارد زیر باشید:

بهترین شیوه‌ها برای برنامه‌های چند نخی:

  1. تنها از Immutable Types استفاده کنید.
  2. به متغیرهای عضو static دسترسی پیدا نکنید.
  3. اگر کدی وجود دارد که Thread-Safe نیست، آن را با lock، mutex یا semaphore محافظت کنید.

گرچه شما قبلاً نمونه‌هایی از این کدها را در این فصل دیده‌اید، بیایید یک مرور کوتاه با نمونه کد داشته باشیم.


نمونه نوشتن یک نوع Immutable با readonly struct

public readonly struct ImmutablePerson
{
    public ImmutablePerson(int id, string firstName, string lastName)
    {
        _id = id;
        _firstName = firstName;
        _lastName = lastName;
    }
    public int Id { get; }
    public string FirstName { get { return _firstName; } }
    public string LastName { get { return _lastName; } }
}

از آنجایی که داده‌ها پس از ایجاد قابل تغییر نیستند، این ساختار Thread-Safe است. یعنی نمی‌توان آن را از طریق Threadهای مختلف تغییر داد. تنها راه تغییر داده‌ها، ایجاد یک Struct جدید با داده‌های جدید است.


نکات تکمیلی


Collections Immutable

پیش‌تر دیدیم که Collections Mutable هستند. اما یک Namespace برای Immutable Collections وجود دارد به نام:

System.Collections.Immutable

جدول زیر برخی از عناصر مهم این Namespace را نشان می‌دهد:

Conventions-UsedThis-Book

Namespace System.Collections.Immutable شامل مجموعه‌ای از Immutable Collections است که می‌توانید به‌طور ایمن بین Threadها استفاده کنید. برای جزئیات بیشتر، به لینک زیر مراجعه کنید:
https://docs.microsoft.com/en-us/dotnet/api/system.collections.immutable?view=netcore-3.1 🌐


استفاده از Lock در C# 🔒

استفاده از lock object در C# بسیار ساده است، همان‌طور که در نمونه کد زیر می‌بینید:

public class LockExample
{
    public object _lock = new object();

    public void UnsafeMethod()
    {
        lock(_lock)
        {
            // اجرای کد غیر ایمن
        }
    }
}

یک مشکل احتمالی: Deadlock. برای جلوگیری از آن می‌توان از Mutex استفاده کرد.


استفاده از Mutex 🛡️

ابتدا متغیر Mutex را تعریف کنید:

private static readonly Mutex _mutex = new Mutex();

سپس کد محافظت‌شده را به این صورت اجرا کنید:

try
{
    _mutex.WaitOne();
    // ... انجام کار ...
}
finally
{
    _mutex.ReleaseMutex();
}

استفاده از Semaphore 🟢

Semaphore نیز شبیه Mutex عمل می‌کند، اما تفاوت اصلی این است که Mutex مکانیزم قفل است و Semaphore مکانیزم سیگنال‌دهی.

مثال تعریف Semaphore:

private static readonly Semaphore _semaphore = new Semaphore(2, 4);

استفاده در متدها:

try
{
    _semaphore.WaitOne();
    // ... انجام کار ...
}
finally
{
    _semaphore.Release();
}

نکات مهم 🔹


وابستگی‌های متد همزمان (Synchronized Method Dependencies) ⚡

برای همزمان‌سازی کدها:

[MethodImpl(MethodImplOptions.Synchronized)]
public static void ThisIsASynchronisedMethod()
{
    Console.WriteLine("Synchronised method called.");
}

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

private int i;
public int SomeProperty
{
    [MethodImpl(MethodImplOptions.Synchronized)]
    get { return i; }
    [MethodImpl(MethodImplOptions.Synchronized)]
    set { i = value; }
}

استفاده از کلاس Interlocked ⚡

در برنامه‌های چند نخی، خطاها هنگام Context Switching Threadها رخ می‌دهند، مثلاً وقتی چند Thread متغیر یکسانی را به‌روزرسانی می‌کنند.

کلاس System.Threading.Interlocked در mscorlib برای محافظت در برابر این خطاها استفاده می‌شود.

ویژگی‌ها:


متدهای موجود در Interlocked 🔧

ابتدا یک برنامه‌ی جدید .NET Framework Console Application ایجاد کنید و خطوط زیر را در بالای کلاس Program اضافه کنید:

private static long _value = long.MaxValue;
private static int _resourceInUse = 0;

متغیر _value برای نشان دادن به‌روزرسانی متغیرها با استفاده از روش‌های interlocking استفاده می‌شود. متغیر _resourceInUse برای نشان دادن این است که آیا یک منبع در حال استفاده است یا خیر.

سپس متد CompareExchangeVariables() را اضافه کنید:

private static void CompareExchangeVariables()
{
    Interlocked.CompareExchange(ref _value, 123, long.MaxValue);
}

در متد CompareExchangeVariables()، ما متد CompareExchange() را فراخوانی می‌کنیم تا _value را با long.MaxValue مقایسه کند. اگر دو مقدار برابر باشند، _value با مقدار 123 جایگزین می‌شود.

حالا متد AddVariables() را اضافه می‌کنیم:

private static void AddVariables()
{
    Interlocked.Add(ref _value, 321);
}

متد AddVariables() متد Add() را فراخوانی می‌کند تا به متغیر عضو _value دسترسی داشته باشد و آن را با مقدار _value + 321 به‌روزرسانی کند.

سپس متد DecrementVariable() را اضافه می‌کنیم:

private static void DecrementVariable()
{
    Interlocked.Decrement(ref _value);
}

این متد، متد Decrement() را فراخوانی می‌کند که مقدار متغیر عضو _value را ۱ واحد کاهش می‌دهد.

متد بعدی IncrementVariable() است:

private static void IncrementVariable()
{
    Interlocked.Increment(ref _value);
}

در IncrementVariable()، مقدار متغیر عضو _value با فراخوانی متد Increment() افزایش می‌یابد.

حالا متد ReadVariable() را می‌نویسیم:

private static long ReadVariable()
{
    // متد Read در سیستم‌های 64 بیتی لازم نیست،
    // چون عملیات خواندن 64 بیتی به‌صورت atomic انجام می‌شود.
    // در سیستم‌های 32 بیتی، عملیات خواندن 64 بیتی atomic نیست
    // مگر اینکه با Read انجام شود.
    return Interlocked.Read(ref _value);
}

از آنجایی که عملیات خواندن ۶۴ بیتی atomic است، فراخوانی متد Interlocked.Read() در سیستم‌های ۶۴ بیتی ضروری نیست. اما در سیستم‌های ۳۲ بیتی، برای اینکه خواندن ۶۴ بیتی atomic باشد، باید از Interlocked.Read() استفاده کنید.

سپس متد PerformUnsafeCodeSafely() را اضافه می‌کنیم:

private static void PerformUnsafeCodeSafely()
{
    for (int i = 0; i < 5; i++)
    {
        UseResource();
        Thread.Sleep(1000);
    }
}

متد PerformUnsafeCodeSafely() پنج بار حلقه می‌زند. در هر بار حلقه، متد UseResource() فراخوانی می‌شود و سپس نخ برای یک ثانیه به خواب می‌رود.

حالا متد UseResource() را اضافه می‌کنیم:

static bool UseResource()
{
    if (0 == Interlocked.Exchange(ref _resourceInUse, 1))
    {
        Console.WriteLine($"{Thread.CurrentThread.Name} acquired the lock");
        NonThreadSafeResourceAccess();
        Thread.Sleep(500);
        Console.WriteLine($"{Thread.CurrentThread.Name} exiting lock");
        Interlocked.Exchange(ref _resourceInUse, 0);
        return true;
    }
    else
    {
        Console.WriteLine($"{Thread.CurrentThread.Name} was denied the lock");
        return false;
    }
}

متد UseResource() جلوگیری می‌کند از اینکه اگر منبع در حال استفاده است، lock گرفته شود، همانطور که توسط متغیر _resourceInUse مشخص شده است. ابتدا مقدار _resourceInUse را با فراخوانی متد Exchange() روی ۱ تنظیم می‌کنیم. متد Exchange() یک عدد بازمی‌گرداند که با ۰ مقایسه می‌شود. اگر مقدار بازگشتی ۰ باشد، متد در حال استفاده نیست.

اگر متد در حال استفاده باشد، پیامی به کاربر نمایش داده می‌شود که نخ فعلی اجازه‌ی lock ندارد.
اگر متد آزاد باشد، پیامی به کاربر نمایش داده می‌شود که نخ فعلی lock را گرفته است. سپس متد NonThreadSafeResourceAccess() فراخوانی می‌شود و نخ برای نیم ثانیه به خواب می‌رود تا شبیه‌سازی کار انجام شود.

پس از بیدار شدن نخ، پیامی نشان می‌دهد که نخ فعلی از lock خارج شده است و سپس lock با فراخوانی Exchange() آزاد می‌شود و مقدار _resourceInUse به ۰ بازگردانده می‌شود.

متد NonThreadSafeResourceAccess() را اضافه کنید:

private static void NonThreadSafeResourceAccess()
{
    Console.WriteLine("Non-thread-safe code executed.");
}

در NonThreadSafeResourceAccess() کد غیر thread-safe در امنیت lock اجرا می‌شود. در مثال ما، فقط پیامی به کاربر نمایش داده می‌شود.

در نهایت، متد Main() را به شکل زیر به‌روزرسانی کنید:

static void Main(string[] args)
{
    CompareExchangeVariables();
    AddVariables();
    DecrementVariable();
    IncrementVariable();
    ReadVariable();
    PerformUnsafeCodeSafely();
}

متد Main() متدهایی که روش‌های Interlocked را تست می‌کنند، فراخوانی می‌کند. ✅

اگر کد را اجرا کنید، خروجی مشابه چیزی خواهد بود که انتظار داریم.

Conventions-UsedThis-Book

حال بیایید به برخی توصیه‌های عمومی بپردازیم. 📌

توصیه‌های عمومی ⚙️

در این بخش نهایی، برخی از توصیه‌های مایکروسافت برای کار با برنامه‌های multi-threaded را بررسی می‌کنیم. این توصیه‌ها شامل موارد زیر هستند:


جمع‌بندی 📝

در این فصل، ما بررسی کردیم که threading چیست و چگونه از آن استفاده کنیم. ما مشکلات deadlock و race condition را مشاهده کردیم و دیدیم چگونه می‌توان با استفاده از lock statement و کتابخانه TPL از این شرایط استثنایی جلوگیری کرد. همچنین به بررسی thread safety در static constructors، static methods، immutable objects و mutable objects پرداختیم. مشاهده کردیم که استفاده از immutable objects راهی امن برای انتقال داده بین threadها است و برخی توصیه‌های عمومی برای کار با threadها مرور شد.

همچنین دیدیم که چگونه امن کردن کد برای threadها می‌تواند مزایای زیادی داشته باشد. ✅

در فصل بعد، به طراحی APIs مؤثر خواهیم پرداخت. اما در حال حاضر می‌توانید دانش خود را با پاسخ دادن به سوالات زیر امتحان کنید و مطالعه خود را با مراجعه به لینک‌های ارائه شده ادامه دهید.


سوالات ❓

  1. Thread چیست؟
  2. در یک برنامه single-threaded چند thread وجود دارد؟
  3. چه انواعی از thread وجود دارد؟
  4. کدام thread بلافاصله پس از خروج برنامه خاتمه می‌یابد؟
  5. کدام thread حتی پس از خروج برنامه تا تکمیل ادامه می‌یابد؟
  6. چه کدی باعث می‌شود یک thread به مدت نیم میلی‌ثانیه بخوابد؟
  7. چگونه یک thread ایجاد می‌کنید که متدی به نام Method1 را فراخوانی کند؟
  8. چگونه یک thread را به background thread تبدیل می‌کنید؟
  9. Deadlock چیست؟
  10. چگونه از یک lock که با Monitor.TryEnter(objectName) گرفته شده خارج می‌شوید؟
  11. چگونه می‌توان از deadlock بازیابی کرد؟
  12. Race condition چیست؟
  13. یک روش برای جلوگیری از race condition چیست؟
  14. چه چیزی متدهای static را ناامن می‌کند؟
  15. آیا static constructors thread-safe هستند؟
  16. چه چیزی مسئول مدیریت گروه‌های thread است؟
  17. Immutable object چیست؟
  18. چرا در برنامه‌های multi-threaded، immutable objects نسبت به mutable objects ترجیح داده می‌شوند؟

مطالعه‌ی بیشتر 📚