حالة السباق Race Condition: ليه خيطان يزوّدان عدّادًا معًا فتضيع نصف الزيادات
المستوى المطلوب: متوسط. بس متقلقش لو انت مبتدئ؛ الشرح بيبدأ بمثال بسيط جدًا من الحياة اليومية، وبعدين يتعمّق للتعريف العلمي والكود القابل للنسخ.
لو عندك خيطان (threads) بيزوّدان نفس المتغير، وطلعت لك نتيجة أقل من المفروض من غير أي رسالة خطأ، المشكلة مش في العتاد. ده أشهر بق في البرمجة المتزامنة اسمه حالة السباق (Race Condition). في المقال ده هتعرف ليه بيحصل بالظبط، تشوفه بعينك بكود شغّال، وتصلحه في سطرين.
المشكلة باختصار
لما أكتر من خيط يقرأ ويعدّل نفس البيانات في نفس الوقت، ترتيب تنفيذ العمليات بيبقى غير محدد. فأحيانًا خيط بيمسح شغل خيط تاني من غير ما يقصد. النتيجة: أرقام ناقصة، حجوزات مكررة، أو مخزون بيتباع بالزيادة. والأخطر إن البق ده بيظهر ويختفي حسب التوقيت، فصعب تمسكه.
مثال بسيط: ماكينتا صراف وحساب واحد
تخيّل حساب بنكي رصيده 1000 جنيه، وانت ومراتك عندكم كارت لنفس الحساب. في نفس اللحظة، انت بتسحب 700 من ماكينة، وهي بتسحب 700 من ماكينة تانية.
الماكينة بتعمل 3 خطوات: تقرأ الرصيد (1000)، تطرح المبلغ (1000 ناقص 700 يساوي 300)، تكتب الرصيد الجديد (300). المشكلة إن الماكينتين قرأتا 1000 قبل ما أي واحدة تكتب. فالاتنين حسبتا 300، والاتنين كتبتا 300. المفروض الرصيد يبقى بالسالب، لكنه بقى 300، والبنك خسر 700 من غير ما حد ياخد باله. دي بالظبط حالة السباق.
التعريف العلمي
حالة السباق هي وضع بيبقى فيه ناتج البرنامج معتمدًا على الترتيب الزمني غير المحدد لتنفيذ خيوط بتشترك في بيانات قابلة للتعديل. جوهر المشكلة إن عملية زيادة العدّاد مش عملية ذرّية (atomic)؛ هي فعليًا 3 خطوات منفصلة: اقرأ القيمة، زوّدها واحد، اكتبها. الجزء ده من الكود اللي لازم يتنفّذ من غير مقاطعة اسمه المقطع الحرج (critical section).
شوفها بعينك: كود Python يوريك التحديث الضائع
معلومة مهمة: حتى في CPython اللي فيه GIL، سطر زيادة العدّاد مش محمي، لأنه بيتترجم لأكتر من تعليمة bytecode، والمترجم ممكن يبدّل الخيوط في النص. جرّب الكود ده بنفسك:
import threading
counter = 0
ITER = 1_000_000
def add():
global counter
for _ in range(ITER):
counter += 1 # اقرأ، زوّد، اكتب
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start(); t2.start()
t1.join(); t2.join()
print("expected:", 2 * ITER)
print("actual :", counter)
المفروض يطبع 2,000,000. لكن على تشغيل حقيقي عندي طلعت النتيجة 1,373,915، وفي تشغيلة تانية 1,642,806. كل مرة رقم مختلف، وكله أقل من المتوقع. مئات الآلاف من الزيادات اتمسحت لأن خيط كتب قيمة قديمة فوق شغل التاني.
الحل: اقفل المقطع الحرج بـ Lock
القفل (Lock) بيضمن إن خيط واحد بس يدخل المقطع الحرج في المرة. أي خيط تاني بيستنى دوره.
import threading
counter = 0
ITER = 1_000_000
lock = threading.Lock()
def add():
global counter
for _ in range(ITER):
with lock: # خيط واحد جوّه في المرة
counter += 1
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 2000000 في كل مرة
دلوقتي النتيجة 2,000,000 بالظبط، ثابتة في كل تشغيل. القفل حوّل الخطوات الثلاث لعملية واحدة غير قابلة للمقاطعة.
سيناريو واقعي: آخر قطعة في المخزون
لو عندك متجر بيستقبل 300 طلب في الثانية وفي آخر قطعة (المخزون يساوي 1)، ممكن طلبين يوصلوا في نفس اللحظة. الاتنين يقرآ المخزون 1، الاتنين يعدّي شرط التحقق من التوفّر، والاتنين ينقّصا فيبقى المخزون بالسالب. النتيجة: بعت قطعة مش موجودة، وعميل هيتلغي طلبه ويزعل. نفس البق بالظبط، بس بفلوس ورضا عملاء.
الـ trade-offs: القفل مش ببلاش
القفل بيحل الصحة، لكن له ثمن. أول حاجة: التسلسل (serialization)؛ الخيوط بتقف في طابور على القفل، فبتخسر جزء من التوازي. في القياس فوق، نسخة القفل خدت وقت أطول بحوالي 2 إلى 4 مرات من النسخة الغلط (اللي بتخلّص بسرعة لأنها بتنتج نتيجة خاطئة أصلًا). تاني حاجة: خطر الجمود (deadlock) لو أقفلت أكتر من قفل بترتيب غير متّسق. القاعدة: خلّي المقطع الحرج أصغر ما يمكن، وأقفل بأقل مدة ممكنة.
متى لا تشغل بالك
لو البيانات غير مشتركة بين الخيوط أصلًا (كل خيط بمتغيراته)، أو لو البرنامج بخيط واحد فقط، فمفيش حالة سباق ومفيش داعي للقفل. كمان لو الشغل مبني على بيانات ثابتة (immutable) بتتقري بس ومتتكتبش، فانت في أمان بلا أقفال. القفل حل لمشكلة الكتابة المتزامنة على حالة مشتركة تحديدًا؛ لو مفيش كتابة مشتركة، القفل بيبقى overhead بلا فايدة. وفي حالات العدّ البسيط في Python فيه بدائل أخف زي تجميع نتيجة كل خيط لوحده وجمعها في الآخر.
الخطوة التالية
افتح أقرب كود عندك فيه متغيّر مشترك بيتعدّل من أكتر من خيط أو أكتر من طلب (زي عدّاد، رصيد، أو مخزون). حدّد المقطع الحرج، ولفّه بقفل عبر with lock، أو استخدم SELECT FOR UPDATE لو التعديل على قاعدة بيانات. بعدين شغّل نسخة بحمل عالي (زي مثال المليون فوق) وقارن النتيجة قبل وبعد. لو الرقم بقى ثابت، القفل شغّال صح.
المصادر
- توثيق Python الرسمي — وحدة threading و threading.Lock: docs.python.org/3/library/threading.html
- Python FAQ — سؤال "What kinds of global value mutation are thread-safe?": docs.python.org/3/faq/library.html
- PostgreSQL Documentation — Explicit Locking و SELECT FOR UPDATE: postgresql.org/docs/current/explicit-locking.html
- Herlihy and Shavit — The Art of Multiprocessor Programming.