Logo

التزامن بين الـ Threads في C#

22 دقيقة قراءة
شرائح الدرس
1 / 15

نظم التشغيل 1 - الدرس السادس

التزامن بين الـ Threads في C#

لماذا يحتاج الكود المشترك إلى حماية، وكيف يوفرها lock وMonitor، وأين ينتهي مداهما.

بنهاية الدرس تصبح قادرا على حماية الـ shared data من الـ race conditions باستخدام lock و Monitor، والتعرف على الـ deadlock وإصلاحه، وشرح لماذا لا تستطيع هذه الأدوات التنسيق بين threads في processes مختلفة.

الأهداف

  • تشرح لماذا يجب التحكم في الوصول المتزامن إلى الـ shared data، من خلال race condition على shared counter ونظام حجز تذاكر.
  • تحدد الـ critical section وتعرف كيف تجد واحدة منها في الكود.
  • تستخدم lock بشكل صحيح وتصف ما يولده الـ compiler من أجلها.
  • تستخدم Monitor مباشرة (Enter، Exit، TryEnter، Wait، Pulse، PulseAll) وتربطها بـ lock.
  • تتعرف على لماذا لا يستطيع lock/Monitor التنسيق بين threads في processes مختلفة، وتسمي الأدوات (Mutex، Semaphore، SemaphoreSlim، ReaderWriterLockSlim، و SpinLock) المحجوزة لجلسة قادمة.
  • تتعرف على deadlock سببه اثنان من الـ lock بترتيب معكوس، وتزيله بترتيب ثابت للـ acquisition.

المتطلبات السابقة

  • راحة في إنشاء Thread، وتمرير delegate له (بما في ذلك كـ lambda expression)، واستخدام Start()، Join()، Sleep()، وتتبع thread عبر ThreadState الخاص به.
  • مشروع C# console يعمل (.NET SDK) ومحرر نصوص يمكن تشغيله من خلاله.
  • راحة مع كتل try/finally.

لماذا التزامن مطلوب

عندما يقرأ ويكتب اثنان أو أكثر من الـ threads في نفس الـ data في نفس الوقت، تعتمد النتيجة على الترتيب الذي جدول به نظام التشغيل الـ threads: يسمى هذا race condition، حيث يمحو الـ thread الذي يكتب آخرا عمل الآخر بدون أن يظهر أي تنبيه.

كمثال، ليكن هناك shared ticket counter يبدأ من 10، يحجز Thread A ثلاث تذاكر ويحجز Thread B تذكرتين، في نفس الوقت تقريبا.

sequenceDiagram
    participant TA as Thread A
    participant V as AvailableTickets = 10
    participant TB as Thread B
    TA->>V: Read (10)
    TB->>V: Read (10)
    TA->>TA: Compute 10 - 3 = 7
    TB->>TB: Compute 10 - 2 = 8
    TA->>V: Write 7
    TB->>V: Write 8 (overwrites A)

قرأ كلاهما القيمة 10 قبل أن يكتب أي منهما. القيمة النهائية المتوقعة هي 5، لكن القيمة الفعلية هي 8، لأن كتابة Thread B مسحت كتابة Thread A، وكلاهما يظن أن حجزه نجح. هذا الخلل غير ظاهر في الكود، ويظهر فقط تحت ترتيب scheduling سيء الحظ، وهذا سبب اختلاف الناتج من تشغيل إلى آخر. نفس المشكلة يمكن أن تؤثر على أي متغير أو method أو ملف مشترك.

Critical Section

Critical section هي الكود الذي يلمس shared resource ويجب ألا يعمل على أكثر من thread واحد في نفس الوقت؛ في مثال التذاكر هي القسم الذي يفحص AvailableTickets ويحدثه. التزامن يحميها: بعد أن يدخل thread، يجب على أي thread آخر يريد الدخول أن ينتظر حتى يخرج. تخدم غرضين: atomicity، تشغيل القسم كوحدة واحدة غير قابلة للتقسيم بحيث لا يرى أي thread تحديثا غير مكتمل (مثال التذاكر)، وordering، جعل الـ threads تنفذ الخطوات بترتيب نسبي محدد بدلا من حماية الـ data فقط.

الـ Keyword lock

private static readonly object lockObject = new object();
 
static void SomeMethod()
{
    lock (lockObject)
    {
        // critical section: only one thread executes this at a time
    }
}

lockObject هو object مخصص يتم إنشاؤه فقط لكي يتم الـ lock عليه: private بحيث لا يستطيع الكود الخارجي عمل lock عليه أو استبداله، و readonly بحيث لا يمكن إعادة تعيينه مرة أخرى أبدا، وإن حدث ذلك فسيكسر الضمان. الـ static method يحتاج static lock object بحيث تشارك كل الـ calls lock واحدا؛ instance method يمكنه استخدام instance field من نوع private readonly.

الـ thread الذي يصل إلى lock (lockObject) يحاول أن يأخذ exclusive lock: إذا كان فارغا، يدخل فورا؛ وإذا كان مشغولا، ينتظر في queue حتى يتركه الـ thread الذي يشغله. الـ scheduler هو الذي يحدد أي thread منتظر يدخل بعده، لا الكود.

lock و Monitor هما أداتا exclusive-locking التي يغطيهما هذا الدرس. توجد أسماء أخرى لاحتياجات تزامن مختلفة: Mutex، Semaphore، SemaphoreSlim، ReaderWriterLockSlim، و SpinLock، وهي مغطاة في جلسة قادمة (انظر "حدود ما يستطيع lock و Monitor الوصول إليه" أدناه).

إلى ماذا يتحول lock

flowchart TD
    A["lock (lockObject) { body }"] --> B["object temp = lockObject;\nbool lockTaken = false;"]
    B --> C["try: Monitor.Enter(temp, ref lockTaken)"]
    C --> D["run body"]
    D --> E["finally block runs unconditionally"]
    E --> F{"lockTaken == true?"}
    F -->|Yes| G["Monitor.Exit(temp)"]
    F -->|No| H["skip Exit: lock was never acquired"]

يخزن الـ compiler الـ lock object في متغير مؤقت و bool lockTaken (يبدأ بـ false)، ثم يلف الـ body في try { Monitor.Enter(temp, ref lockTaken); body }. الـ ref هنا يمرر lockTaken by reference، بحيث تستطيع Monitor.Enter أن ترجع فتكتب في متغير الـ caller نفسه بدلا من الكتابة في نسخة local مهدرة؛ بدون ref، يمكن أن تجعل Monitor.Enter قيمة lockTaken الداخلية الخاصة بها true لكن الـ caller لن يرى هذا التغيير أبدا. تجعل Monitor.Enter قيمة lockTaken تساوي true فقط للـ thread الذي استلم الـ lock. يستدعي الـ finally block دالة Monitor.Exit فقط إذا كانت lockTaken صحيحة، وبسبب أن finally يعمل دائما، لا يمكن أن يترك thread حدث فيه crash الـ lock مشغولا إلى الأبد.

lock بالتالي يعطي acquire و release صحيحا وآمنا من الـ exceptions تلقائيا؛ الـ timeout أو الـ signaling بين الـ threads يحتاج Monitor مباشرة.

الـ Class Monitor

Monitor هو static class ينسق الوصول إلى object. أعضاؤه الأساسيون هم نفس الأعضاء التي يستخدمها lock بالفعل:

  • Monitor.Enter(object): يأخذ exclusive lock؛ يبدأ الـ critical section.
  • Monitor.Exit(object): يترك الـ lock؛ ينهي الـ critical section. لا يكون لنداء Exit معنى إلا بعد Enter ناجحة على نفس الـ object، ولذلك يتابع الكود lockTaken (أو يعتمد على الفحص الذي يقوم به الـ compiler في lock) لمعرفة ما إذا كان يحتاج إلى نداء Exit أصلا.

لا تلف Enter/Exit نفسيهما في try/finally، فالكود الذي ينادي عليهما مباشرة يجب أن يكتب هذا بنفسه، بنفس الطريقة التي يفعلها الـ compiler لـ lock.

ثلاث عمليات أخرى في Monitor لا يستطيع lock التعبير عنها:

  • TryEnter: يأخذ الـ lock مع timeout (بالميلي ثانية أو TimeSpan)، ويرجع true إذا تم الاستلام في الوقت أو false إذا لم يتم؛ thread انتهى وقته يمضي فقط بدلا من أن ينتظر إلى الأبد.
  • Wait(object): يترك الـ lock الذي يشغله الـ thread الحالي ويضعه في waiting queue حتى يوقظه thread آخر.
  • Pulse(object): يبلغ thread واحدا منتظرا بأنه يمكنه أن يحاول استلام الـ lock مرة أخرى؛ لا يترك الـ lock بنفسه، فيجب على الـ caller أيضا أن ينادي Wait (أو يخرج من الـ block) لكي يعمل الـ thread المبلغ فعليا.
  • PulseAll(object): نفس signal الذي يرسله Pulse، لكنه يصل إلى كل الـ threads في الـ waiting queue بدلا من التالي في الدور فقط، بحيث تتنافس كلها على الـ lock لا واحد فقط.
stateDiagram-v2
    [*] --> ReadyQueue: Monitor.Enter
    ReadyQueue --> CriticalSection: Scheduler grants the lock
    CriticalSection --> WaitingQueue: Monitor.Wait (after Pulse)
    WaitingQueue --> ReadyQueue: Pulsed thread re-competes for the lock
    CriticalSection --> [*]: Monitor.Exit

تجعل Wait و Pulse و PulseAll معا الـ threads تتبادل السيطرة ذهابا وعودة؛ Exit، على العكس، معناها أن الـ thread انتهى تماما من الـ lock.

حدود ما يستطيع lock و Monitor الوصول إليه

ينسق lock و Monitor فقط بين threads داخل نفس الـ process. الـ thread الذي ينشئه كودك بـ new Thread(...) هو thread داخلي؛ الـ thread الذي يشغل Main، والذي يوفره نظام التشغيل لحظة بدء الـ process، هو thread خارجي. نفس نوع الـ thread الخارجي موجود في كل نسخة أخرى تعمل من البرنامج، واحد لكل process، ولا يستطيع أي منهم أن يرى، أو حتى يتحبس بـ، lock مكتوب في نسخة أخرى من كود process مختلفة. إذا أنشأت console program وشغلت الـ compiled executable الخاص به مباشرة مرتين أو ثلاث مرات، فكل تشغيل هو process مستقل، بذاكرته الخاصة، و counter خاص به، و lock object خاص به. لا توجد طريقة يمكن بها لـ lock المكتوب في نسخة كود process معينة أن يرى، أو حتى يحبس، thread يعمل داخل نسخة كود process أخرى، لأنه لا يوجد object يمكن للطرفين مشاركته لكي يعملا عليه lock.

وهذا معناه أن أهدافا مثل "نسخة واحدة فقط تعمل من هذا البرنامج في نفس الوقت" أو "حد أقصى اثنين من client threads عبر كل النسخ العاملة" لا يمكن تحقيقها بـ lock/Monitor وحدهما. توجد خمس أدوات مسماة لمشاكل من هذا النوع ومرتبطة به: Mutex، Semaphore، SemaphoreSlim، ReaderWriterLockSlim، و SpinLock. الـ constructors والـ methods وسلوكها الدقيق متروكة لجلسة بعد هذا الدرس، فلا توجد تفاصيل عنها هنا. إحدى الـ tasks أدناه توضح هذا الحد نفسه، وهو أن عدة processes لا يستطيع lock الوصول إليها، بدون الحاجة إلى أي من هذه الخمس أدوات.

Deadlock

يحدث Deadlock عندما يشغل thread lock واحدا وهو منتظر lock ثانيا، وفي نفس الوقت يشغل thread آخر الـ lock الثاني وهو منتظر الأول. لا يستطيع أي منهما التحرك؛ يسمى هذا circular wait.

sequenceDiagram
    participant TA as Thread A
    participant L1 as Lock 1
    participant L2 as Lock 2
    participant TB as Thread B
    TA->>L1: Acquire (success)
    TB->>L2: Acquire (success)
    TA->>L2: Try acquire (blocked by B)
    TB->>L1: Try acquire (blocked by A)
    Note over TA,TB: Both wait forever

يحدث هذا تحديدا عندما يأخذ اثنان من الـ threads نفس الـ 2 locks بترتيب معكوس. التعرف على هذا الشكل، اثنان من الـ lock بترتيب معكوس، كاف لمعرفة أن deadlock يمكن أن يحدث. المعيار الذي يستخدمه .NET runtime أو نظام التشغيل فعليا لاختيار "victim thread" وإرغامه على ترك lock واحد، هو موضوع منفصل وأكثر تقدما ومتروك لجلسة قادمة، وغير مشروح هنا. الـ task الخاصة بالـ deadlock أدناه تبني مباشرة على الشكل أعلاه: تعيد إنتاج الـ deadlock بالترتيب المعكوس في الكود، ثم تزيله بترتيب ثابت لاستلام الـ locks عبر كل الـ threads، وهذا fix عام ينطبق على هذا النوع كله من الـ bugs.

تمرير Arguments لـ Thread باستخدام Lambda

يأخذ constructor الـ Thread delegate من نوع ThreadStart، وهي method لا تأخذ parameters وترجع void. method مثل BookTicket(string name, int wantedTickets) تحتاج إلى 2 parameters، فلا يمكن تمريرها إلى new Thread(...) مباشرة، ويحل هذا القيد نفسه في حالات أخرى بـ ParameterizedThreadStart وclass مساعد.

توفر Lambda expression طريقة أخرى للتحايل على هذا. () => show.BookTicket("Thread 1", 1) هي نفسها method لا تأخذ parameters، متطابقة مع ThreadStart تماما، والـ statement الواحدة الخاصة بها تنادي بالصدفة BookTicket بـ 2 arguments ثابتة. تعلن () => "method لا تأخذ parameters"؛ وكل ما بعدها هو الـ statement التي ستشغلها هذه الـ method مرة واحدة عند بدء الـ thread. تقوم الـ lambda بعملية capture لـ show و "Thread 1" و 1 من الـ method المحيطة، بحيث تبقى هذه القيم موجودة لكي يستخدمها الـ thread الجديد لاحقا، حتى لو أنشأتها الـ method المحيطة قبل أن يبدأ الـ thread العمل.

Thread t1 = new Thread(() => show.BookTicket("Thread 1", 1));

اقرأها هكذا: أنشئ thread نقطة دخوله lambda؛ عند بدئه، تنادي هذه الـ lambda show.BookTicket بالـ arguments الحرفية "Thread 1" و 1. كل task من هنا يبدأ thread على method تأخذ arguments يستخدم هذا النمط بدلا من ParameterizedThreadStart.

Lab Tasks

لكل task أدناه مشروع console خاص به، مرقم لكي يطابق الـ task: SyncLab1 لـ Task 1، SyncLab2 لـ Task 2، SyncLab3 لـ Task 3، SyncLab4 لـ Task 4، و SyncLab6 لـ Task 6 (لا تنشئ Task 5 مشروعا جديدا؛ فهي تعيد استخدام SyncLab1). إذا طلبت خطوة إعادة كتابة أو إعادة استخدام كود "من Task N"، أنشئ المشروع الجديد أولا وضع الكود بالضبط المسمى من مشروع Task N كنقطة بداية، ثم طبق التغيير الموصوف في هذه الخطوة فوق النسخة. لن يتم لمس SyncLab1 مرة أخرى بعد انتهاء Task 1؛ تبني Task 5 وتشغل SyncLab1 بالضبط كما تركتها Task 1، فلا يوجد شيء تم في Tasks 2 إلى 4 يفترض أن يغيره.

Task 1: إعادة إنتاج Race Condition على Shared Counter

الخطوة 1. أنشئ مشروع console اسمه SyncLab1 (dotnet new console -n SyncLab1).

الخطوة 2. استبدل Program.cs:

using System;
using System.Threading;
 
class Program
{
    static int counter = 0;
 
    static void Main()
    {
        // Call the method sequentially first, on the main thread alone
        IncrementCounter();
        IncrementCounter();
        IncrementCounter();
        Console.WriteLine($"Sequential result: {counter}");
 
        // Reset, then call the identical method concurrently
        counter = 0;
        Thread t1 = new Thread(IncrementCounter);
        Thread t2 = new Thread(IncrementCounter);
        Thread t3 = new Thread(IncrementCounter);
        t1.Start(); t2.Start(); t3.Start();
        t1.Join(); t2.Join(); t3.Join();
        Console.WriteLine($"Concurrent result: {counter}");
    }
 
    static void IncrementCounter()
    {
        for (int i = 0; i < 10; i++) counter++;
    }
}

يزيد IncrementCounter الـ shared counter 10 مرات. أول ما تفعله Main هو أن تناديها 3 مرات مباشرة، تنتهي call واحدة قبل أن تبدأ الأخرى، كلها على الـ main thread، وتطبع النتيجة. ثم ترجع counter إلى 0 وتنادي نفس الـ method من 3 threads منفصلة تعمل في نفس الوقت؛ نداءات Join() الثلاثة تجعل الـ main thread ينتظر الـ3 children قبل أن تطبع النتيجة الثانية.

الخطوة 3. شغل البرنامج 5 مرات، ودون القيمتين المطبوعتين بعد كل تشغيل.

الناتج المتوقع. النتيجة sequential تطبع دائما Sequential result: 30 (10 + 10 + 10)، لأن thread واحدا يشغل الـ3 calls واحدة تلو الأخرى بدون أي تقاطع. لا يضمن أن تكون النتيجة concurrent هي 30: يقرأ counter++ القيمة الحالية، ويزيدها واحدا، ويكتبها مرة أخرى كـ3 خطوات منفصلة، فيمكن أن يقرأ 2 threads نفس القيمة قبل أن يكتب أي منهما القيمة مرة أخرى، فتضيع زيادة دون أن يلاحظ ذلك أحد. عبر 5 تشغيلات، تكون النتيجة concurrent أقل من 30 عادة وتتغير من تشغيل إلى آخر؛ تعتمد القيمة الظاهرة بالضبط على ترتيب scheduling سيء الحظ لهذا التشغيل بالذات، فدون ما تطبعه تشغيلاتك الخمس فعليا بدلا من توقع رقم ثابت.

Task 2: حماية Critical Section بـ lock، وحجز التذاكر

الخطوة 1. أنشئ مشروعا جديدا، SyncLab2 (dotnet new console -n SyncLab2)، وضع field الـ counter الخاص بـ Task 1، و IncrementCounter، و Main بدون تغيير. من هنا، اعمل فقط في SyncLab2؛ يبقى SyncLab1 تماما كما تركته Task 1 لـ Task 5. في SyncLab2، صحح المسار concurrent بعمل lock على الـ increment (تصل الـ calls sequential إلى 30 كل مرة بالفعل ولا تحتاج أي تغيير):

private static readonly object counterLock = new object();
static int counter = 0;
 
static void IncrementCounter()
{
    for (int i = 0; i < 10; i++)
    {
        lock (counterLock) { counter++; }
    }
}

الخطوة 2. شغله 5 مرات، ودون القيمتين المطبوعتين بعد كل تشغيل.

الناتج المتوقع. Concurrent result: 30 كل مرة، مطابقة للنتيجة sequential بدون أي اختلاف، لأنه لا يمكن أن يشغل counter++ إلا thread واحد في كل مرة.

الخطوة 3. أضف سيناريو حجز التذاكر. هذا عرض منفصل عن الـ counter أعلاه: في SyncLab2، أزل field الـ counter، و IncrementCounter، و الـ Main الخاصة بالخطوتين 1 و 2، لأن الـ Main أدناه ستستبدلهم تماما ولا يعمل هذان العرضان معا في نفس الـ Main. لا يضيع شيء: تضع Task 3 الـ IncrementCounter المقفولة من جديد في مشروعها الخاص. أضف الـ class BookMyShow:

class BookMyShow
{
    private static readonly object lockObject = new object();
    public int AvailableTickets = 3;
 
    public void BookTicket(string name, int wantedTickets)
    {
        lock (lockObject)
        {
            if (wantedTickets <= AvailableTickets)
            {
                Console.WriteLine($"{wantedTickets} ticket(s) booked by {name}.");
                AvailableTickets -= wantedTickets;
            }
            else
            {
                Console.WriteLine($"{name}: not enough tickets available.");
            }
        }
    }
}

AvailableTickets هو public field، مضبوط مباشرة على 3 في مكان تعريفه بدلا من constructor، ويتابع كم تذكرة باقية. BookTicket هي الـ critical section كلها: فرع if هو المسار الناجح، حيث توجد تذاكر كافية باقية، فيطبع تأكيدا ويخصم wantedTickets؛ وفرع else هو مسار الرفض، يطبع عندما لا تكون هناك تذاكر كافية باقية. يحدث الفحص والتحديث داخل نفس الـ lock، فلا يمكن لأي thread أن ينزلق بينهما، وهذا ما كان يسبب اللخبطة الأولى. استبدل الـ Main الخاصة بـ SyncLab2 بالنسخة أدناه، وهي تنادي BookTicket من 3 threads، واحد لكل حجم حجز، مقابل pool من 3 تذاكر إجمالي:

static void Main()
{
    BookMyShow show = new BookMyShow();
    Thread t1 = new Thread(() => show.BookTicket("Thread 1", 1));
    Thread t2 = new Thread(() => show.BookTicket("Thread 2", 2));
    Thread t3 = new Thread(() => show.BookTicket("Thread 3", 3));
    t1.Start(); t2.Start(); t3.Start();
    t1.Join(); t2.Join(); t3.Join();
}

يريد Thread 1 تذكرة واحدة، ويريد Thread 2 تذكرتين، ويريد Thread 3 ثلاث تذاكر؛ مجتمعين يريدون 6 تذاكر مقابل pool من 3، فيجب أن يفشل واحد على الأقل.

الناتج المتوقع. إجمالي ما يتم حجزه فعليا لا يتجاوز 3، لكن الـ threads التي تنجح تعتمد بالكامل على أي طلب lock يسمح له الـ scheduler بأن يمر أولا، لا على الكود. إذا مر Thread 3 أولا، يأخذ الثلاث تذاكر كلها ويفشل الاثنان الآخران:

3 ticket(s) booked by Thread 3.
Thread 1: not enough tickets available.
Thread 2: not enough tickets available.

إذا مر Thread 3 آخرا، يستخدم Threads 1 و 2 الـ pool بالضبط (1 + 2 = 3) ويفشل Thread 3:

1 ticket(s) booked by Thread 1.
2 ticket(s) booked by Thread 2.
Thread 3: not enough tickets available.

شغله 5 مرات لكي ترى النمطين يظهران عبر تشغيلات مختلفة؛ ما لا يتغير هو أن عدد التذاكر المحجوزة لا يتجاوز 3.

Task 3: استخدام الـ Class Monitor مباشرة

الخطوة 1. أنشئ مشروعا جديدا، SyncLab3 (dotnet new console -n SyncLab3)، وضع field الـ counter، و IncrementCounter، و Main بالضبط كما كانوا في الخطوة 1 من Task 2، قبل أن تستبدلهم Main الخاصة بحجز التذاكر في SyncLab2. في SyncLab3، أعد كتابة IncrementCounter باستخدام Monitor.Enter/Monitor.Exit مع try/finally و lockTaken، وخذ الـ lock واتركه مرة واحدة في كل iteration، بالضبط مثل lock (counterLock) { counter++; } الخاصة بـ Task 2، لا مرة واحدة لكل الـ10 iterations (فذلك سيترك الـ lock مشغولا الوقت كله ويغير ما نقيسه):

private static readonly object counterLock = new object();
static int counter = 0;
 
static void IncrementCounter()
{
    for (int i = 0; i < 10; i++)
    {
        bool lockTaken = false;
        try
        {
            Monitor.Enter(counterLock, ref lockTaken);
            counter++;
        }
        finally
        {
            if (lockTaken) Monitor.Exit(counterLock);
        }
    }
}

يفترض أن يستمر في طباعة 30 كل مرة، دليلا على أن lock ونداءات Monitor الصريحة تتصرف بنفس الشكل تماما.

الخطوة 2. أضف عرض TryEnter: 3 threads كل واحد يحاول دخول critical section واحدة مع timeout ثانية واحدة، وthread دخل يبقى يشغلها 5 مرات نوم 100 ميلي ثانية (حوالي 500 ميلي ثانية):

private static readonly object lockObject = new object();
 
static void TryEnterDemo(string name)
{
    Console.WriteLine($"{name} trying to enter the critical section.");
    bool lockTaken = false;
    try
    {
        lockTaken = Monitor.TryEnter(lockObject, TimeSpan.FromSeconds(1));
        if (lockTaken)
        {
            Console.WriteLine($"{name} entered the critical section.");
            for (int i = 0; i < 5; i++)
            {
                Thread.Sleep(100);
            }
        }
        else
        {
            Console.WriteLine($"{name}: lock was not acquired within the timeout.");
        }
    }
    finally
    {
        if (lockTaken)
        {
            Monitor.Exit(lockObject);
            Console.WriteLine($"{name} exiting the critical section.");
        }
    }
}

يعلن كل thread المحاولة قبل أن ينادي TryEnter، سواء نجح أو لم ينجح. الـ thread الذي استلم الـ lock يعلن الدخول، ويبقى ماسكا لها خلال الـ5 iterations من النوم، ثم يعلن أنه خرج؛ الـ thread الذي لم يستطع استلام الـ lock خلال ثانية يتجاوز الـ loop تماما ويقول أن الـ lock لم يتم أخذها. استبدل الـ Main الخاصة بـ SyncLab3 بالنسخة أدناه، والتي تبدأ 3 threads تنادي TryEnterDemo بالأسماء "Thread 1"، "Thread 2"، "Thread 3" (لا شيء بعد هذا في الدرس يعيد استخدام كود counter الخاص بـ SyncLab3 مرة أخرى):

static void Main()
{
    Thread t1 = new Thread(() => TryEnterDemo("Thread 1"));
    Thread t2 = new Thread(() => TryEnterDemo("Thread 2"));
    Thread t3 = new Thread(() => TryEnterDemo("Thread 3"));
    t1.Start(); t2.Start(); t3.Start();
    t1.Join(); t2.Join(); t3.Join();
}

الناتج المتوقع. كل حجز مدته حوالي 500 ميلي ثانية يترتب بسهولة داخل انتظار ثانية واحدة، فأول 2 threads يصلان إلى TryEnter يدخلان عادة واحدا بعد الآخر؛ thread ثالث يصل بعد أن ينتهي حجز الاثنين فعلا لا يبقى له عادة وقت كاف في انتظاره الثانية ويقول أن الوقت انتهى. تشغيل محتمل، مع دخول Thread 1 و Thread 2 كلاهما قبل أن تنتهي window الخاصة بـ Thread 3:

Thread 1 trying to enter the critical section.
Thread 2 trying to enter the critical section.
Thread 1 entered the critical section.
Thread 3 trying to enter the critical section.
Thread 1 exiting the critical section.
Thread 2 entered the critical section.
Thread 3: lock was not acquired within the timeout.
Thread 2 exiting the critical section.

أي thread ينتهي به الأمر مقفولا بالخارج يتغير بين التشغيلات، لأنه يعتمد على الترتيب بالضبط الذي سمح فيه الـ scheduler لـ3 نداءات TryEnter بأن تمضي.

Task 4: تنسيق 2 Threads بـ Monitor.Wait و Monitor.Pulse

الخطوة 1. أنشئ مشروعا جديدا، SyncLab4 (dotnet new console -n SyncLab4). أنشئ 2 methods يجب أن تتبادلا بشكل صارم: أرقاما زوجية وأرقاما فردية، مطبوعة بالترتيب من 0 إلى 10.

using System;
using System.Threading;
 
class Program
{
    private static readonly object lockObject = new object();
    private const int Limit = 10;
 
    static void Main()
    {
        Thread evenThread = new Thread(() => PrintSequence(0));
        Thread oddThread = new Thread(() => PrintSequence(1));
        evenThread.Start();
        Thread.Sleep(100); // let 0 print first
        oddThread.Start();
        evenThread.Join();
        oddThread.Join();
        Console.WriteLine("Done.");
    }
 
    static void PrintSequence(int start)
    {
        lock (lockObject)
        {
            for (int i = start; i <= Limit; i += 2)
            {
                Console.WriteLine(i);
                bool isLast = i + 2 > Limit;
                Monitor.Pulse(lockObject);
                if (!isLast) Monitor.Wait(lockObject);
            }
        }
    }
}

يشغل كل من الـ threadين PrintSequence، واحد يبدأ من 0 والآخر من 1. الـ Sleep(100) الأول يجعل الـ even thread يطبع 0 أولا. بعد الطباعة، ينادي كل thread Pulse لكي يسمح للآخر بمحاولة أخذ الـ lock، ثم Wait، فيترك الـ lock ويضع نفسه في الانتظار حتى يتم إيقاظه مرة أخرى. isLast مهمة: في آخر iteration يجب ألا يعمل الـ thread Wait، لأنه لا يوجد من يوقظه مرة أخرى. تجعل نداءات Join() الاثنين الـ main thread ينتظر كل الـ11 رقم قبل أن يطبع "Done."

الخطوة 2. شغل البرنامج.

الناتج المتوقع.

0
1
2
3
4
5
6
7
8
9
10
Done.

تظهر الأرقام بترتيب صارم برغم أنها قادمة من 2 threads منفصلين، لأن Wait/Pulse ترغمهما على تبادل السيطرة ذهابا وعودة رقما واحدا في كل مرة.

Task 5: مراقبة لماذا لا يستطيع lock و Monitor الوصول عبر Processes

الخطوة 1. ابن مشروع SyncLab1 الخاص بـ Task 1، بالضبط كما تركته Task 1 (لا SyncLab2 أو SyncLab3 أو SyncLab4، والتي أعيدت كتابتها لـ tasks لاحقة)، لتحصل على executable مستقل: من داخل SyncLab1، شغل dotnet build -c Release، ثم حدد مكان الـ binary المبني تحت bin/Release/net<version>/ (SyncLab1 على Linux/macOS، SyncLab1.exe على Windows). اعرف القيمة الحقيقية لـ <version> بفحص قيمة <TargetFramework> في SyncLab1.csproj (مثلا net8.0)، أو اسرد الـ folder مباشرة.

الخطوة 2. افتح 3 terminals وفي نفس الوقت تقريبا، شغل الـ binary المبني هذا مباشرة في كل واحد: ./SyncLab1 على Linux/macOS (شغل chmod +x SyncLab1 أولا إذا رفض التنفيذ) أو SyncLab1.exe على Windows. استخدم الـ binary المبني، لا dotnet run: يعيد dotnet run بناء المشروع ويقفل مجلد الـ build-output الخاص به كل مرة، فيمكن أن تنتهي 3 نداءات dotnet run بدأت من نفس مجلد المشروع بأن تتسلسل على هذا الـ lock المشترك بدلا من أن تعمل فعليا جنبا إلى جنب.

الناتج المتوقع. تبدأ 3 processes منفصلة من SyncLab1، كل واحدة بـ counter خاص بها يبدأ من 0 و lock object خاص بها في ذاكرتها. توضح قائمة الـ processes 3 processes مستقلة تعمل في نفس الوقت، وكل واحدة تطبع سطور Sequential result و Concurrent result الخاصة بها على جدولها الخاص، بدون أن تتأثر بما تفعله الاثنتان الأخريان. حمى lock الـ3 threads داخل process واحدة من بعضها في Task 2، لكنه أصلا لا يرى هذه الـ processes الأخرى: لا شيء هنا يوقف مئة نسخة من نفس البرنامج عن العمل جنبا إلى جنب.

الخطوة 3. لا يوجد كود لهذه الخطوة. في جملة أو جملتين، حدد الـ object الواحد الذي يحتاج lock و Monitor أن يشاركه threadان لكي ينسقا بينهما، واشرح لماذا لا يمكن أن يتحقق هذا المطلب أبدا بين 2 threads تابعين لـ2 processes مختلفة تم تشغيلهما بالطريقة التي فعلتها الخطوة 2. (هذا تماما الفراغ الذي سميت Mutex و Semaphore و SemaphoreSlim من أجله في أقسام المفاهيم أعلاه؛ استخدامها الفعلي متروك لجلسة قادمة.)

الإجابة المتوقعة. يحتاج lock و Monitor كلاهما إلى أن يتزامن الـ2 threads على نفس الـ object reference بالضبط في الذاكرة، بنفس الطريقة التي شارك بها كل thread في Task 2 lock object واحدا. لا يمكن أبدا لـ2 threads تابعين لـ2 processes مختلفة تم تشغيلهما بالطريقة التي فعلتها الخطوة 2 أن يشاركا هذا الـ reference، لأن كل process يأخذ مساحة ذاكرة منفصلة خاصة به لحظة بدئه؛ لا شيء تم داخل ذاكرة process معينة يمكن لأي طرف الوصول إليه من ذاكرة process أخرى. بدون object reference واحد يمكن للطرفين أن يمسكاه، لا يبقى شيء لأي نداء lock أو Monitor من الطرفين أن يتزامن عليه.

Task 6: Deadlock (إعادة إنتاج وإصلاح)

باقي هذه الـ task يطبق تعريف الـ deadlock من أقسام المفاهيم أعلاه بشكل عملي: إعادة إنتاج الـ deadlock بالترتيب المعكوس في الكود، ثم إزالته بترتيب ثابت لاستلام الـ locks.

الخطوة 1. أنشئ مشروعا جديدا، SyncLab6 (dotnet new console -n SyncLab6). أعد إنتاج deadlock بـ2 locks مأخوذين بترتيب معكوس:

using System;
using System.Threading;
 
class Program
{
    private static readonly object lockA = new object();
    private static readonly object lockB = new object();
 
    static void Main()
    {
        Thread t1 = new Thread(FirstThenSecond);
        Thread t2 = new Thread(SecondThenFirst);
        t1.Start(); t2.Start();
        t1.Join(); t2.Join();
        Console.WriteLine("Both threads finished.");
    }
 
    static void FirstThenSecond()
    {
        lock (lockA)
        {
            Console.WriteLine("Thread 1 acquired Lock A, waiting for Lock B...");
            Thread.Sleep(500);
            lock (lockB) { Console.WriteLine("Thread 1 acquired Lock B."); }
        }
    }
 
    static void SecondThenFirst()
    {
        lock (lockB)
        {
            Console.WriteLine("Thread 2 acquired Lock B, waiting for Lock A...");
            Thread.Sleep(500);
            lock (lockA) { Console.WriteLine("Thread 2 acquired Lock A."); }
        }
    }
}

يقفل FirstThenSecond الـ lockA ثم، بعد نوم، يحاول lockB؛ ويفعل SecondThenFirst العكس. بعد أن تنتهي النومتان، يبقى كل thread منتظرا الـ lock الذي يشغله الآخر بالفعل.

الناتج المتوقع. يطبع السطران "acquired ... waiting for ..."، ثم يعلق البرنامج إلى الأبد؛ لا تطبع Both threads finished. أبدا. أوقفه يدويا (Ctrl+C أو زر الإيقاف في الـ IDE).

الخطوة 2. صححه بجعل كل من الـ methodين يأخذ lockA قبل lockB:

static void SecondThenFirst()
{
    lock (lockA)
    {
        Console.WriteLine("Thread 2 acquired Lock A, waiting for Lock B...");
        Thread.Sleep(500);
        lock (lockB) { Console.WriteLine("Thread 2 acquired Lock B."); }
    }
}

الناتج المتوقع. يكمل البرنامج دائما ويطبع Both threads finished. أي طرف يأخذ lockA أولا مضمون له أن يأخذ lockB بعده، لأن الطرف الآخر لا يمكنه أن يشغل lockB بدون أن يأخذ lockA أولا؛ لا يمكن أن يتشكل الـ circular wait مرة أخرى.

الملخص

  • يحدث الـ race condition عندما تقرأ وتكتب threads في shared data بدون حماية؛ تعتمد النتيجة على scheduling غير متوقع وتختلف بين التشغيلات.
  • الـ critical section هي الكود الذي يلمس shared data ويجب أن يعمل atomically، thread واحد في كل مرة.
  • يتحول lock (obj) { ... } إلى try/finally حول Monitor.Enter/Monitor.Exit، فيترك الـ lock دائما حتى إذا رمى الكود المحمي exception.
  • يضيف Monitor TryEnter (استلام مع timeout) و Wait/Pulse/PulseAll (بروتوكول signaling بين الـ threads) أكثر من ما يستطيع lock وحده أن يعبر عنه.
  • ينسق lock/Monitor فقط بين threads داخل process واحدة؛ process منفصلة تعمل نفس البرنامج غير مرئية لهما، لأن كل process لها lock object خاص بها في ذاكرتها. Mutex، Semaphore، SemaphoreSlim، ReaderWriterLockSlim، و SpinLock هي أدوات مسماة لمشاكل تعبر هذا الحد، أو تحتاج إلى السماح بأكثر من واحد لكن أقل من كل الـ threads، لكن هذا الدرس يقف عند تسميتها.
  • يحدث الـ deadlock عندما يشغل كل واحد من 2 threads الـ lock الذي يحتاجه الآخر، مأخوذين بترتيب معكوس؛ ترتيب ثابت لاستلام الـ locks عبر كل الـ threads يمنعه.