أقفال eBPF Spinlock وفن تصحيح أخطاء النواة
كيف تظهر أخطاء spinlock في eBPF داخل نواة Linux، ولماذا يصعب العثور عليها، وما الذي تكشفه عملية تصحيحها عن نموذج الأمان في eBPF.

خطأ spinlock في eBPF لا يعلن عن نفسه برسالة خطأ أو stack trace. يعلن عن نفسه بجهاز يتوقف عن الاستجابة. لا سجلات، لا تحذيرات، لا تدهور تدريجي، فقط نواة معالج تدور بلا نهاية على قفل لن يُفتح أبدًا. إن كنت محظوظًا، يُطلق مؤقت المراقبة (watchdog) ويظهر kernel panic مع trace. وإن لم تكن محظوظًا، يتجمد الجهاز ببساطة ويتعين عليك إعادة تشغيله يدويًا.
من المفترض أن يكون eBPF آمنًا. يعمل داخل النواة لكنه يمر عبر verifier يتحقق من أن البرامج تنتهي، ولا تصل إلى ذاكرة غير صالحة، ولا تُفسد حالة النواة. فكيف تتسرب أخطاء spinlock من هذا الفلتر؟ لأن الـ verifier يفحص البرامج كلٌّ على حدة، بينما تنشأ أخطاء spinlock من تفاعل البرامج والـ maps وقرارات جدولة النواة، وهي أمور لا يستطيع أي verifier ثابت التنبؤ بها بالكامل.
ما الغرض من spinlocks في eBPF؟
تعمل برامج eBPF في سياق النواة، وغالبًا على عدة معالجات في وقت واحد. عندما يحتاج برنامجان إلى تحديث القيمة نفسها داخل map، كعداد أو بنية بيانات أو آلة حالة، يلزمهما آلية تزامن. يوفر eBPF لهذا الغرض الدالتين bpf_spin_lock وbpf_spin_unlock.
الـ spinlock هو أبسط أنواع الأقفال: تحاول الحصول عليه، وإذا كان مأخوذًا تدور في حلقة ضيقة حتى يُحرَّر. لا نوم، لا تنازل عن المعالج، لا طابور انتظار. وهذا مناسب لـ eBPF لأن برامجه تعمل في سياقات يُمنع فيها النوم، مثل معالجات المقاطعات وsoftirqs والأقسام التي تكون فيها preemption معطلة. لو نام المتصل بقفل عادي لانهارت النواة، أما الـ spinlock فيحرق دورات المعالج فقط حتى يصبح القفل متاحًا.
struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}
يبدو هذا بسيطًا، وفي الحالات البسيطة مثل: اقفل، حدّث قيمة، ثم حرّر، يعمل بشكل جيد. تبدأ المشاكل عندما تصبح أنماط القفل أعقد، أو عندما تخلق التفاعلات بين برامج eBPF وأقفال النواة نفسها تبعيات غير متوقعة.
كيف تظهر أخطاء الـ Spinlock
الخطأ الكلاسيكي للـ spinlock هو deadlock: البرنامج A يمسك القفل 1 ويحاول أخذ القفل 2، بينما يمسك البرنامج B القفل 2 ويحاول أخذ القفل 1. كلاهما يدور للأبد منتظرًا الآخر. في سياق النواة، يعني ذلك أن أنوية المعالج هذه خرجت من الخدمة تمامًا، ولن تنفذ أي شيء آخر.
يمنع الـ verifier بعض هذه الحالات، فهو يرفض البرامج التي تمسك أكثر من spinlock في الوقت نفسه، وهذا يقضي على deadlock الكلاسيكي من نوع AB-BA. لكنه لا يستطيع منع كل سيناريوهات الـ deadlock، لأن بعضها ينشأ من العلاقة بين spinlocks الخاصة بـ eBPF وأقفال النواة نفسها.
لنفترض هذا السيناريو: برنامج eBPF مرتبط بـ tracepoint يأخذ spinlock داخل map. وبينما القفل مأخوذ، تقع مقاطعة عتادية على المعالج نفسه. يشغّل معالج المقاطعة برنامج eBPF آخر يحاول أخذ الـ spinlock ذاته. النتيجة deadlock: لا تستطيع المقاطعة أن تنتهي قبل الحصول على القفل، والقفل لا يمكن تحريره قبل أن يستأنف البرنامج المقاطَع عمله، وهذا لن يحدث قبل أن تنتهي المقاطعة.
Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck
تتعامل أقفال النواة الخاصة مع هذا بتعطيل المقاطعات أثناء حمل الـ spinlock (spin_lock_irqsave). وتفعل spinlocks الخاصة بـ eBPF الشيء نفسه تقريبًا، فـ bpf_spin_lock يعطّل preemption، وعلى نوى 5.1 فما بعد يعطّل softirqs أيضًا. لكن ليست كل سياقات المقاطعات مشمولة، وليست كل إصدارات النواة تتعامل مع الأمر بالطريقة ذاتها، وهنا تكمن الأخطاء الدقيقة.
مشكلة التصحيح
تصحيح مشاكل spinlock في eBPF صعب، ولأسباب محددة.
إعادة الإنتاج غير حتمية. تعتمد أخطاء spinlock على التوقيت: أي معالج يشغّل أي برنامج، ومتى تقع المقاطعات، وكم يستغرق القسم الحرج. اختبار ينجح 999 مرة قد يتعطل في المرة الألف. ولا يمكنك إعادة إنتاجها بثقة في بيئة التطوير، لأن جهازك يختلف في طوبولوجيا المعالجات وتكرار المقاطعات وأنماط الحمل عن بيئة الإنتاج.
فشل يمحو الأدلة. عند حدوث deadlock في spinlock، تتوقف المعالجات المتأثرة عن تنفيذ أي شيء، بما في ذلك بنية التسجيل والمراقبة التي كان يمكن أن تخبرك بما حدث. إن كان الـ deadlock على معالج واحد، قد يستمر الجهاز في العمل بصعوبة. أما إن تعطلت عدة معالجات، أو إن كان المعالج المقفل يمسك موارد تحتاجها معالجات أخرى، فيتعطل الجهاز كله.
أدوات التصحيح التقليدية لا تفيد كثيرًا. لا يمكنك ربط debugger بالنواة لفحص الحالة المتجمدة (صحيح أن KGDB موجود، لكنه يتطلب إعداد اتصال تسلسلي قبل وقوع الـ deadlock). أما dmesg فلا فائدة منه لأن لا شيء يكتب فيه. والمعلومة الموثوقة الوحيدة غالبًا تأتي من مخرجات كاشف التجمد (lockup detector)، إن أطلق إنذاره قبل أن يتوقف الجهاز تمامًا.
استراتيجيات تعمل فعلًا
رغم هذه التحديات، يجد الناس أخطاء spinlock في eBPF ويصلحونها. وهذه بعض الطرق.
تحليل ترتيب الأقفال. قبل تشغيل أي شيء، حلّل سلوك القفل لكل برنامج eBPF. ما الـ maps التي يصل إليها كل برنامج؟ وما الأقفال التي يأخذها؟ وهل يمكن لبرنامجين يلمسان القفل نفسه أن يعملا على المعالج ذاته (نفس الـ tracepoint، أو tracepoint مقابل مقاطعة)؟ هذا تحليل يدوي مرهق، لكنه يلتقط أكثر أنماط الـ deadlock شيوعًا قبل أن تحدث.
مراقبة زمن الاحتفاظ بالقفل. اجعل برامج eBPF تقيس المدة التي يبقى فيها كل spinlock مأخوذًا. القفل الذي يُمسك لأكثر من بضعة ميكروثانية علامة إنذار، لأنه يوسّع نافذة الـ deadlock الناتج عن المقاطعات، ويجعل المعالجات الأخرى تحرق دوراتها في الدوران. الأقسام الحرجة القصيرة أسرع وأأمن في الوقت نفسه.
// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) { // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}
بنى بيانات لكل معالج. أفضل خطأ في الـ spinlock هو الذي لا يمكن أن يقع أصلًا. إذا عمل كل معالج على نسخته الخاصة من البيانات ثم جمعتها لاحقًا، فلن يكون هناك تنافس ولا حاجة للأقفال. وقد صُممت BPF_MAP_TYPE_PERCPU_HASH وBPF_MAP_TYPE_PERCPU_ARRAY لهذا تحديدًا. العدادات والمدرجات التكرارية والمجمّعات نادرًا ما تحتاج إلى spinlocks للحالة المشتركة.
عمليات ذرية بدل الأقفال. للعمليات البسيطة، كزيادة عداد أو المقارنة والاستبدال لقيمة، تكون __sync_fetch_and_add وما شابهها من العمليات الذرية أسرع وخالية من الـ deadlock. لا تحتاج إلى spinlock لزيادة عداد. احتفظ بالـ spinlocks للعمليات التي تحتاج فعلًا إلى تحديث عدة حقول دفعة واحدة بشكل ذري.
ماذا يكشف عن نموذج الأمان في eBPF
قصة الأمان في eBPF مبهرة لكنها ليست بسيطة. يضمن الـ verifier أن البرامج الفردية آمنة: تنتهي، ولا تصل إلى ذاكرة غير صالحة، ولا تُفسد حالة النواة. لكن البرامج "الآمنة" قد تتحول إلى نظام غير آمن عندما تخلق تفاعلاتها تبعيات زمنية لا يستطيع الـ verifier تحليلها.
هذا قيد جوهري في التحليل الساكن. يرى الـ verifier كل برنامج بمعزل عن غيره، ولا يعرف أي البرامج الأخرى محمّلة، ولا الـ maps التي تتشاركها، ولا سياقات النواة التي تعمل فيها. البرنامج الذي يأخذ spinlock آمن بمعنى أنه يأخذه ويحرره بشكل صحيح. لكن ما إذا كان آمنًا عند اقترانه بكل برنامج eBPF آخر محمّل فيعتمد على ظروف وقت التشغيل.
يعالج مجتمع النواة هذا تدريجيًا. أضافت التصحيحات الأخيرة قيودًا على أنواع برامج eBPF التي يمكنها استخدام spinlocks، وعلى المدة التي يمكن أن تستغرقها الأقسام الحرجة، وعلى العمليات الممنوعة أثناء حمل spinlock. كل قيد يضيّق نافذة الأخطاء، لكنه يحدّ أيضًا مما تستطيع برامج eBPF فعله.
قواعد عملية للأقفال في eBPF
بعد تصحيح عدد كافٍ من مشاكل spinlock، تظهر أنماط تساعد على تجنبها.
- فضّل الـ maps لكل معالج. إذا أمكن تقسيم بياناتك حسب المعالج ثم تجميعها في فضاء المستخدم، فافعل ذلك. لا أقفال، ولا تنافس، ولا deadlock. هذا يغطي 80% من حالات الاستخدام، كالعدادات وسجلات الأحداث والمدرجات التكرارية.
- فضّل العمليات الذرية على spinlocks. إذا احتجت إلى عداد مشترك أو عملية مقارنة واستبدال، فاستخدم العمليات الذرية. فهي خالية من الأقفال ولا يمكن أن تسبب deadlock.
- إذا كان لا بد من spinlocks، فأبقِ الأقسام الحرجة صغيرة جدًا. زد عدادًا، أو حدّث طابعًا زمنيًا، أو بدّل مؤشرًا، ولا شيء أكثر من ذلك. لا تستدعِ helper functions أبدًا وأنت تمسك قفلًا، فأنت لا تعرف ما الذي تفعله من أقفال داخليًا.
- لا تمسك القفل نفسه من نوعين مختلفين من برامج eBPF. إذا كان برنامج kprobe وبرنامج tracepoint يقفلان قيمة الـ map نفسها، فأنت تبعد مقاطعة واحدة فقط عن deadlock. استخدم maps منفصلة أو بيانات لكل معالج.
- اختبر تحت الحمل. أخطاء spinlock تعتمد على التوقيت. تظهر تحت التنافس، أي عند استخدام عالٍ للمعالج، ومقاطعات متكررة، وكثرة برامج eBPF تعمل في الوقت نفسه. اختبر بحمل إنتاجي واقعي، لا على أجهزة تطوير خاملة.
غيّر eBPF بشكل جوهري طريقة تفاعلنا مع نواة Linux، إذ منح برامج فضاء المستخدم وصولًا آمنًا إلى قدرات المراقبة والشبكات على مستوى النواة. لكن "الأمان" له حدود. فالـ verifier يجعل eBPF أكثر أمانًا من كتابة وحدات النواة الخام، وبفارق كبير. لكنه لا يجعله آمنًا بالطريقة التي تكون بها البرمجة في فضاء المستخدم آمنة. ومعرفة أين تقع تلك الحدود، خصوصًا فيما يتعلق بالحالة المشتركة والأقفال، هي ما يفصل برامج eBPF التي تعمل في الإنتاج عن تلك التي تعمل في الاختبار فقط.


