Deadlock و Lock Ordering للمحترف: ليه تحويل فلوس بين حسابين بيجمّد خدمتك للأبد
المستوى المطلوب: محترف. هذا المقال يفترض إنك تعرف الـ mutex والـ threads/goroutines، وعندك خدمة فيها أكثر من قفل بيُكتسب في نفس العملية.
سطر واحد بيرتّب اكتساب الأقفال هيمنع نوع من التجمّد بيوقّف خدمتك بالكامل بدون ما يرمي أي exception. هنا هتشوف ليه بيحصل بالظبط، وإزاي تقيسه، وإزاي تقفله نهائياً.
المشكلة باختصار
تخيّل اتنين بيحوّلوا فلوس في نفس اللحظة: الأول بيحوّل من حساب A لحساب B، والتاني بيحوّل من B لـ A. كل واحد قفل الحساب اللي بيحوّل منه، وبعدين استنّى يقفل الحساب التاني. الأول ماسك A ومستنّي B، والتاني ماسك B ومستنّي A. محدش هيسيب، ومحدش هيكمّل. ده اسمه Deadlock.
علمياً: الـ Deadlock هو حالة بيتعطّل فيها مجموعة من العمليات لأن كل واحدة بتمسك مورداً وتنتظر مورداً تمسكه عملية تانية في نفس المجموعة. حدّد Coffman وزملاؤه سنة 1971 أربعة شروط لازم تتحقق كلها مع بعض عشان يحصل: حصرية الوصول (mutual exclusion)، المسك والانتظار (hold and wait)، عدم السحب القسري (no preemption)، والانتظار الدائري (circular wait). اكسر أي شرط واحد منهم، يستحيل الـ Deadlock.
الكود اللي بيفشل فعلاً
دي دالة تحويل تبدو سليمة في Go. كل goroutine بتقفل الحساب المصدر ثم الهدف:
type Account struct {
id int
mu sync.Mutex
balance int
}
func Transfer(from, to *Account, amount int) {
from.mu.Lock() // (1) قفل المصدر
to.mu.Lock() // (2) قفل الهدف
from.balance -= amount
to.balance += amount
to.mu.Unlock()
from.mu.Unlock()
}
// goroutine 1: Transfer(A, B, 100) -> يقفل A ثم ينتظر B
// goroutine 2: Transfer(B, A, 50) -> يقفل B ثم ينتظر A
تحت الضغط، أول ما الاتنين يوصلوا للسطر (2) في نفس اللحظة، يتكوّن الانتظار الدائري وتقف الـ goroutines. المشكلة الأخبث: Go runtime بيكتشف الـ deadlock الكامل فقط (لما كل الـ goroutines نايمة) ويعمل panic. لكن لو باقي الخدمة شغّالة بتستقبل requests، الاتنين دول هيفضلوا معلّقين بصمت، والـ connection pool يتآكل goroutine ورا التانية لحد ما الخدمة كلها تقف.
الحل: Global Lock Ordering
اكسر شرط الانتظار الدائري. لو كل خيط اكتسب الأقفال بنفس الترتيب الكلي الثابت، يستحيل تتكوّن حلقة. أبسط ترتيب: اقفل دايماً المورد ذا الـ id الأصغر أولاً.