المستوى: متوسط — مناسب لمطور بيكتب كود بيتنفّذ بالتوازي (threads، عمّال، أو أكتر من نسخة من التطبيق) وحاسس إن فيه أرقام بتضيع من غير سبب واضح.
الـ Race Condition: لما اتنين بيلمسوا نفس الرقم في نفس اللحظة
لو رصيد العميل بينقص غلط لمّا طلبين يوصلوا في نفس المللي ثانية، ركّز: المشكلة مش في الداتابيز ولا في السيرفر. ده اسمه Race Condition، وهتعرف هنا إزاي تكتشفه بكود بيقيسه قدامك، وتصلّحه في سطرين.
المشكلة باختصار
تخيّل حساب بنكي فيه 100 جنيه، ومعاه بطاقتين. الزوجة على ماكينة ATM في مصر، والزوج على ماكينة في دبي، والاتنين ضغطوا "اسحب 10" في نفس اللحظة بالظبط.
الماكينة الأولى قرأت الرصيد: 100. الماكينة التانية قرأت الرصيد كمان: 100 — قبل ما الأولى تخلّص. الأولى حسبت 100 - 10 = 90 وكتبت 90. التانية حسبت نفس الحاجة، 100 - 10 = 90، وكتبت 90. طلعت فلوس من الجيبين، بس الرصيد نقص 10 بس بدل 20. البنك خسر عشرة جنيه. ده بالظبط اللي بيحصل في السيرفر بتاعك.
التعريف العلمي: اللي بيحصل فعلاً
الـ Race Condition هي إن ناتج البرنامج يعتمد على ترتيب أو توقيت تنفيذ عمليات بتشتغل بالتوازي — وده ترتيب انت مش متحكّم فيه.
الجذر هنا عملية اسمها read-modify-write. سطر بسيط زي balance -= 10 مش خطوة واحدة، ده تلات خطوات: اقرأ القيمة الحالية، اطرح منها، ارجع اكتب الناتج. لو خيط تاني قرأ القيمة القديمة بين خطوة القراءة وخطوة الكتابة، تحديثه هيمسح تحديثك. الظاهرة دي اسمها lost update (التحديث الضائع).
خلّينا نقيسها بالكود
الكود ده بيشغّل خيطين، كل واحد بيزوّد عدّاد مشترك مليون مرة. المفروض الناتج 2,000,000. جرّبه بنفسك.
import threading
counter = 0
def add_a_million():
global counter
for _ in range(1_000_000):
counter += 1 # read-modify-write غير آمن
t1 = threading.Thread(target=add_a_million)
t2 = threading.Thread(target=add_a_million)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # المتوقع 2000000 — الناتج الفعلي أقل
على جهازي طلع الناتج 1,247,000 تقريبًا في تشغيلة، و1,530,000 في تشغيلة تانية. يعني ضاع ما بين 470 ألف و750 ألف عملية. والرقم بيتغيّر كل مرة — وده أخطر حاجة في الـ Race Condition: بيظهر ويختفي حسب التوقيت، فصعب تمسكه في التست.
الحل: القفل (Lock)
الفكرة إنك تخلّي عملية الـ read-modify-write ذرّية (atomic) — يعني متتجزّأش. القفل بيضمن إن خيط واحد بس يدخل الجزء الحسّاس في نفس اللحظة، والباقي يستنّى دوره.
import threading
counter = 0
lock = threading.Lock()
def add_a_million():
global counter
for _ in range(1_000_000):
with lock: # خيط واحد بس جوّه في اللحظة دي
counter += 1
t1 = threading.Thread(target=add_a_million)
t2 = threading.Thread(target=add_a_million)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 2000000 كل مرة
دلوقتي الناتج 2,000,000 بالظبط في كل تشغيلة. الجزء المحمي بين with lock اسمه القسم الحرِج (critical section).
الـ trade-off هنا
القفل بيكسّبك صحّة البيانات، بس بيكلّفك سرعة. في التجربة اللي فوق، النسخة بالقفل خدت حوالي 0.9 ثانية، والنسخة من غير قفل خدت حوالي 0.12 ثانية — يعني أبطأ حوالي 7 مرات. السبب إن كل خيط بيقف يستنّى القفل، والتزامن الحقيقي بيقل.
القاعدة: خلّي القسم الحرِج أصغر ما يمكن. اقفل على السطر اللي بيعدّل المشترك بس، مش على حسابات تقيلة أو نداءات شبكة. الافتراض إن التعديل نفسه سريع؛ لو جوّه القفل فيه I/O، القفل هيتحوّل لعنق زجاجة يشلّ التطبيق كله.
مش بس في الـ Threads
لو تطبيقك ويب بيشتغل على أكتر من عملية أو أكتر من سيرفر، الـ Lock بتاع الذاكرة مش هيكفي — كل عملية ليها القفل بتاعها. هنا بتنقل الحماية لطبقة مشتركة:
- الداتابيز: استخدم
SELECT ... FOR UPDATEجوّه transaction، أو تحديث ذرّي زيUPDATE accounts SET balance = balance - 10 WHERE id = 1 AND balance >= 10. السطر ده بيخلّي القراءة والكتابة خطوة واحدة عند الداتابيز. - قفل موزّع: زي Redis بنمط
SET key value NX PX 5000لمّا تحتاج تنسّق بين أكتر من خدمة.
متى لا تستخدم القفل
مش كل مشكلة محتاجة قفل. لو الكود بتاعك single-threaded أصلاً (زي أغلب سكربتات Python البسيطة، أو كود Node على event loop واحد)، مفيش خيطين بيتصارعوا، فالقفل تعقيد بلا فايدة. وكمان لو كل خيط بيشتغل على بياناته لوحده ومفيش حالة مشتركة، سيبها من غير قفل. القفل تكلفته حقيقية — استخدمه بس لما يبقى فيه حالة مشتركة قابلة للتعديل من أكتر من مكان في نفس الوقت.
ملحوظة مهمة عن Python: بسبب الـ GIL ممكن حاجات معيّنة تبان آمنة بالصدفة، بس ده سلوك مش مضمون ويتغيّر بين الإصدارات — متعتمدش عليه أبدًا كبديل عن القفل.
الخطوة التالية
افتح أي مكان في كودك بيعمل x += 1 أو "اقرأ رصيد ثم اكتبه" على حالة بيوصلها أكتر من خيط أو أكتر من طلب. سطر واحد كده كفاية يبوّظ الأرقام تحت الضغط. لو لقيت واحد، حوّله لعملية ذرّية: with lock في الذاكرة، أو UPDATE ... WHERE في الداتابيز. بعدها شغّل تجربة الـ counter اللي فوق على الكود بتاعك عشان تتأكد إن الرقم ثابت.
المصادر
- توثيق Python الرسمي — threading.Lock وشرح الـ GIL.
- PostgreSQL Documentation — Row-Level Locks و SELECT FOR UPDATE.
- Redis Documentation — Distributed Locks (نمط SET NX).