حالة السباق 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. كل مرة رقم مختلف، وكله أقل من المتوقع. مئات الآلاف من الزيادات اتمسحت لأن خيط كتب قيمة قديمة فوق شغل التاني.