Menu
flag Ar iconالعربيةdown icon

العبارة lock في C#: حالات السباق وInterlocked والجمود

تسمح العبارة lock لخيط واحد فقط في كل مرة بتشغيل كتلة شيفرة. شاهد حالة سباق تفسد عدّادًا، وأصلحها بـ lock، وتعلّم أي كائن تقفل عليه، واستخدم Interlocked للعدّادات البسيطة، وتجنّب الجمود الناتج عن ترتيب الأقفال، واستخدم SemaphoreSlim حين تنتظر الشيفرة داخلها بـ await.

تحتوي هذه الصفحة على محررات قابلة للتشغيل - حرّر، شغّل، وشاهد النتيجة فوراً.

حين تقرأ عدة خيوط البيانات نفسها وتكتبها في الوقت نفسه، قد تضيع التحديثات أو تُرى نصف منجزة. تجعل العبارة lock كتلة شيفرة حصرية: ما دام خيط داخلها، ينتظر كل خيط آخر يصل إلى lock على الكائن نفسه دوره.

المشكلة: حالة سباق

تضيف أربع مهام كل منها 100,000 إلى عدّاد مشترك. يجب أن تكون الإجابة 400,000:

مخرجات على سبيل المثال:

Expected 400000, got 245609

يخرج المجموع عادة ناقصًا، وبمقدار مختلف في كل تشغيل (على جهاز بنواة واحدة قد يكون صحيحًا أحيانًا، وهذا ما يجعل هذه الأخطاء صعبة الالتقاط). تبدو count++ عملية واحدة لكنها ثلاث: اقرأ count، أضف واحدًا، اكتبها. يمكن لخيطين أن يقرأا كلاهما 500، ويحسبا كلاهما 501، ويكتبا كلاهما 501، فتضيع زيادة.

الإصلاح: lock

غلّف القراءة والتعديل والكتابة في lock على كائن مشترك:

المخرجات:

Expected 400000, got 400000

الآن لا يمكن إلا لخيط واحد في كل مرة أن يكون داخل الكتلة، فتكتمل كل زيادة قبل أن تقرأ التالية القيمة. ويضمن القفل أيضًا الرؤية: الخيط الذي يدخل القفل يرى كل كتابة أجراها الممسك السابق قبل أن يغادر.

لا تعمل الحماية إلا إن مرّ كل وصول إلى count عبر القفل نفسه. وcount++ واحدة غير مقفلة في مكان آخر من البرنامج تعيد السباق.

ما تُترجم إليه lock

lock اختصار للصنف Monitor، مع try/finally كي يُحرَّر القفل حتى حين ترمي الكتلة استثناءً:

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

يوفّر Monitor أيضًا TryEnter(obj, timeout)، التي تستسلم بعد مهلة بدل الانتظار إلى الأبد، وWait/Pulse للإشارة بين الخيوط. والخيط الذي يمسك قفلًا أصلًا يمكنه دخوله مجددًا (الأقفال قابلة لإعادة الدخول)، فيمكن لدالة مقفلة استدعاء دالة مقفلة أخرى على الكائن نفسه.

اختيار كائن القفل

الكائن في lock (...) مجرد رمز تتفق عليه الخيوط. القواعد:

  • استخدم حقلًا خاصًا للقراءة فقط من نوع object. private readonly object sync = new object();. خاص يعني أن لا شيفرة خارجية تستطيع أخذ القفل نفسه؛ وreadonly يعني أن الرمز لا يمكن استبداله بينما يمسكه خيط.
  • لا تستخدم lock (this) أبدًا. كل من يمسك مرجعًا إلى كائنك يستطيع القفل عليه أيضًا، فتحجز شيفرته شيفرتك أو تسبّب جمودًا معها.
  • لا تقفل على نص أو typeof(...) أبدًا. القيم النصية الحرفية مُدمجة (كل "orders" في العملية هي الكائن نفسه)، وكائنات Type مشتركة عبر التطبيق كله، فقد تنتهي شيفرة غير مرتبطة بالتنافس على القفل نفسه.
  • لا تقفل على نوع قيمة أبدًا. لا تُترجم lock (count) على int (الخطأ CS0185)، وتغليفه يدويًا ينشئ كائنًا جديدًا في كل مرة، فلن يقفل شيئًا.
  • استخدم قفلًا واحدًا لكل مجموعة بيانات يجب أن تبقى متّسقة، حقل قفل ساكن للبيانات الساكنة، وحقل مثيل لبيانات كل مثيل.

حماية العمليات متعددة الخطوات

تكون lock أشد لزومًا حيث يجب أن يحدث فحص وفعل معًا. يُظهر حساب آمن مع الخيوط نمط افحص ثم افعل ولقطة متّسقة لحقلين:

المخرجات:

10 left after 33 withdrawals

دون القفل يمكن لخيطين أن يريا كلاهما رصيدًا قدره 40، ويجتازا كلاهما الفحص، ويسحبا كلاهما 30، فيصبح الحساب سالب 20. وتقفل الدالة Summary أيضًا، فلا تبلّغ أبدًا عن balance من بعد سحب مع عدد withdrawals من قبله.

Dictionary<TKey, TValue> وList<T> ليسا آمنين مع الخيوط أيضًا: قد تفسد الكتابات المتزامنة مصفوفاتهما الداخلية، لا أن تضيّع التحديثات فحسب. احمهما بقفل أو استخدم ConcurrentDictionary<TKey, TValue> والأنواع الأخرى في System.Collections.Concurrent.

Interlocked للقيم المفردة

حين تكون الحالة المشتركة عددًا صحيحًا واحدًا أو مرجعًا واحدًا والتحديث خطوة واحدة، يؤديه الصنف Interlocked بشكل ذري دون قفل:

المخرجات:

Visits: 10000
Bytes: 5120000
Peak: 10000

تقابل Increment وDecrement وAdd وExchange عمليات معالج ذرية (تعليمة واحدة على x64). وتكتب CompareExchange(ref location, newValue, expected) فقط إن كان الموضع ما زال يحمل expected، وتعيد ما وجدته، وهذا يتيح لك بناء أي تحديث كحلقة إعادة محاولة. أما أي شيء يلمس متغيّرين معًا فما زال يحتاج إلى lock.

الجمود

يحدث الجمود حين يمسك خيطان كل منهما قفلًا يحتاجه الآخر:

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

إن أخذ الخيط 1 accountA بينما أخذ الخيط 2 accountB، ينتظر كل منهما الآخر إلى الأبد. لا استثناء ولا مهلة؛ يتعلق البرنامج. والتحويل بين حسابين الذي يقفل "من" ثم "إلى" ينتج هذا بالضبط حين يُنفَّذ تحويلان معاكسان في الوقت نفسه.

الدفاعات القياسية:

  • اقفل بترتيب عام ثابت. في التحويل اقفل الحساب ذا المعرّف الأصغر أولًا، أيًّا كان اتجاه المال.
  • أمسك الأقفال لفترة قصيرة وأدِّ العمل البطيء (الإدخال والإخراج والتسجيل واستدعاءات الشبكة) خارجها.
  • لا تستدعِ شيفرة مجهولة أثناء إمساك قفل: قد تأخذ الأحداث ودوال الاستدعاء الراجع والدوال الافتراضية أقفالًا خاصة بها.
  • استخدم Monitor.TryEnter مع مهلة حيث يكون التعلّق أسوأ من الفشل.

لا await داخل lock: استخدم SemaphoreSlim

await غير مسموحة داخل كتلة lock (خطأ المترجم CS1996). بعد await قد تستمر الدالة على خيط مختلف، وقفل Monitor يجب أن يحرّره الخيط الذي أخذه. وللشيفرة غير المتزامنة يعمل SemaphoreSlim بعدد 1 كقفل متوافق مع البرمجة غير المتزامنة:

المخرجات:

5 saved, one at a time

اقرن دائمًا WaitAsync بـ Release في finally، وإلا يترك استثناء البوابة مغلقة إلى الأبد. ويسمح SemaphoreSlim(3, 3) بدخول ثلاثة مستدعين معًا، وهكذا تحدّ الطلبات المتزامنة إلى واجهة API محدودة المعدّل.

System.Threading.Lock (.NET 9)

تضيف .NET 9 مع C# 13 نوعًا مخصصًا System.Threading.Lock. حين يكون الكائن في عبارة lock من نوع Lock يستخدم المترجم واجهته الأسرع EnterScope بدل Monitor:

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

قواعد اختيار الكائن تبقى نفسها. وفي الإصدارات الأقدم يكون private readonly object الخيار الصحيح.

أخطاء شائعة

  • قفل بعض الوصولات لا كلها. كل قراءة وكتابة للبيانات المشتركة يجب أن تأخذ القفل نفسه.
  • lock (this) وlock (typeof(X)) وlock ("name"). تستطيع الشيفرة الخارجية أخذ القفل نفسه.
  • أداء إدخال وإخراج بطيء داخل قفل. ينتظر كل خيط آخر؛ أبقِ الكتلة قصيرة.
  • أخذ قفلين بترتيبين مختلفين في مكانين مختلفين. الجمود التقليدي.
  • استخدام lock حول await. لا يُترجم؛ استخدم SemaphoreSlim.
  • قفل لكل استدعاء. لا تحمي lock (new object()) شيئًا؛ يجب أن يكون الكائن مشتركًا.

الأسئلة الشائعة

ماذا تفعل lock في C#؟

تسمح lock (obj) { ... } لخيط واحد فقط في كل مرة بتشغيل الكتلة لكائن قفل معين. والخيط الثاني الذي يصل إلى lock على الكائن نفسه ينتظر حتى يغادر الأول الكتلة. ويُحرَّر القفل حتى إن رمت الكتلة استثناءً، لأن lock تُترجم إلى Monitor.Enter وMonitor.Exit داخل try/finally.

على أي كائن أقفل في C#؟

حقل خاص مخصص: private readonly object _sync = new object();. يجب أن يكون نوعًا مرجعيًا، يتشاركه كل خيط يلمس البيانات المحمية، ولا يمكن الوصول إليه من خارج صنفك. لا تقفل أبدًا على this أو Type (typeof(MyClass)) أو نص، لأن شيفرة أخرى تستطيع القفل على الكائن نفسه فتحجزك أو تسبّب جمودًا معك.

متى أستخدم Interlocked بدل lock؟

حين تكون الحالة المشتركة رقمًا واحدًا أو مرجعًا واحدًا والعملية خطوة واحدة: Interlocked.Increment(ref count) وInterlocked.Add(ref total, x) وInterlocked.Exchange أو CompareExchange. هذه عمليات عتاد ذرية وهي أسرع من القفل. أما لأي شيء يلمس عدة حقول أو مجموعة فاستخدم lock.

هل يمكنني استخدام await داخل lock في C#؟

لا، يرفض المترجم await داخل كتلة lock (الخطأ CS1996)، لأن الشيفرة بعد await قد تستأنف على خيط غير الذي يمسك القفل. استخدم SemaphoreSlim بعدد 1 بدلًا من ذلك: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.

كيف يحدث الجمود مع lock؟

يمسك الخيط 1 القفل A وينتظر القفل B، بينما يمسك الخيط 2 القفل B وينتظر القفل A. لا يستطيع أي منهما المتابعة، ويتعلق البرنامج دون أي استثناء. امنع ذلك بأخذ الأقفال المتعددة دائمًا بالترتيب العام نفسه، وبإمساك الأقفال أقصر وقت ممكن، وبعدم استدعاء شيفرة مجهولة أبدًا أثناء إمساك قفل.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن