هل فكرت يومًا في تمكين المبادلة (Swap) في بيئة الإنتاج للتعامل مع ارتفاعات الذاكرة المفاجئة؟ قد يكون هذا القرار مكلفًا، وقد تواجه مشكلة لم تتوقعها، تمامًا كما حدث لي. أكتب هذا المقال لأجنبك الصدمة التي كدت أن أواجهها بنفسي.
في تجربتي، كان لدي مجموعة تحكم تضم عمليتين أساسيتين: الأولى هي عملية Go تقوم باستدعاء io.ReadAll ومن ثم proto.Unmarshal، مما يؤدي إلى إنشاء بنية بيانية كبيرة (تم وسمها كـ scan بواسطة مُخصص Go). أما العملية الثانية فكانت خادم HTTP يبقى هادئًا في معظم الأوقات.
عندما يعمل جامع القمامة (GC)، فإنه يقرأ امتدادات الذاكرة المسماة scan، ويتخذ قرارات بشأنها. اعتقدت حينها أنه تحت ضغط الذاكرة، ستقوم نواة نظام التشغيل بطرد الصفحات إلى منطقة المبادلة، وبما أن هذا الإخلاء يتم على مستوى مجموعة cgroup وليس على مستوى العملية الواحدة، فإن صفحات كلتا العمليتين ستُطرد. ظننت أن فرصة حدوث مشكلة خطيرة من تبادل الصفحات بين النواة وجامع القمامة ستكون ضئيلة.
لكنني كنت مخطئًا. خلال التجربة، اكتشفت مشكلة قد تكون كارثية: يقرأ جامع القمامة في Go بياناته الوصفية (التي تقع خارج منطقة الكومة الرئيسية ولا يتم تحريرها عادةً) خلال فترة التوقف المؤقت (Stop-the-World)، ويمكن لهذه البيانات الوصفية نفسها أن تُنقل إلى منطقة المبادلة (Swap).
أجريتُ اختبارًا محاكاة على خادم Hetzner بنواة Linux 6.8 مع تمكين MGLRU. كان متوسط وقت التوقف المؤقت حوالي 51 ميكروثانية. ولكن، عندما كانت البيانات الوصفية لجامع القمامة مخزنة على وحدة تخزين NVMe، وصل أسوأ توقف مؤقت إلى 40 مللي ثانية كاملة. (يمكن العثور على تفاصيل التجربة والرموز هنا: https://github.com/frnsimoes/go-gc-swap-cost).
تحليل التوقف المفاجئ
للتحقق من سبب هذا التأخير الذي بلغ 40 مللي ثانية، قمت بكتابة نص برمجي بسيط باستخدام bpf لحساب أخطاء الصفحات (Page Faults) أثناء فترة توقف العالم (Stop-the-World). كانت أسوأ حالة مسجلة هي: 39902 ميكروثانية، 228 خطأً أثناءها، 39013 ميكروثانية في الأخطاء. هذا يعني أن 39 مللي ثانية من الـ 40 مللي ثانية ضاعت في معالجة 228 خطأً في الصفحة. حدثت تلك الأخطاء ضمن مسك الدفاتر في GC، في مسار استدعاء يتضمن وظائف مثل runtime.(*spanSet).reset وruntime.finishsweep_m وruntime.gcStart.
يمثل هذا الوضع نقطة ضعف محتملة. يتطلب جامع القمامة في Go إيقاف العمليات (Stop-the-World) في نقطتين أساسيتين: الأولى عند تنفيذ عملية إنهاء الكنس (finish sweep)، والثانية عند تنفيذ علامة الإنهاء (finish mark). لاحظنا حدوث 312 من هذه التوقفات خلال 30 دقيقة فقط.
إليكم السبب وراء هذه الظاهرة: يقوم وقت التشغيل (runtime) بتخصيص هذه الصفحات، ولا يتم تحريرها بالكامل، بل يُعاد استخدامها مرارًا. تُقرأ هذه الصفحات في دورات جامع القمامة. ونظرًا لأن النواة تطرد الصفحات بناءً على عمرها، فإنها تميل إلى إرسال الصفحات التي لم يُجرَ الوصول إليها مؤخرًا إلى منطقة المبادلة. عندما يبدأ جامع القمامة عمله ويوقف العالم، فإنه يحاول قراءة تلك الصفحات. لكن المشكلة تظهر هنا: لدينا خطأ كبير في الصفحة. يتطلب هذا من النواة قراءة سجلات PTEs، ثم استدعاء do_swap_page، البحث عن إطار جديد، تحميله إلى مجموعة cgroup، قراءة الصفحات من القرص، استئناف العمل، انتظار استجابة القرص، وإعادتها أخيرًا إلى الذاكرة. كل هذه الخطوات، باختصار، تستهلك وقتًا طويلاً.
الآثار المترتبة على الأداء
قد تبدو فترة الـ 40 مللي ثانية غير مؤثرة للوهلة الأولى، لكننا نتحدث عن توقف شامل لكل العمليات (Stop-the-World). هذه الـ 40 مللي ثانية تعني أن كل شيء يتوقف تمامًا. ووفقًا لمصطلحات Go، يتوقف كل P (معالج منطقي). على سبيل المثال، إذا كان goroutine ينتظر عملية إدخال/إخراج، فقد تعود هذه العملية أثناء فترة التوقف هذه ولن يكون هناك أي شيء للتعامل معها. إن فترة 40 مللي ثانية هي 800 ضعف لمتوسط فترة التوقف المؤقت، وتحدث مرتين أو ثلاث مرات مع كل ارتفاع في استخدام الذاكرة خلال الاختبار. إنها فترة زمنية طويلة جدًا.
بالإضافة إلى ذلك، لاحظت ظاهرة أخرى مثيرة للقلق: عملية إنشاء رسالة واحدة بحجم 511 كيلوبايت، والتي تستغرق عادةً ما بين 3 إلى 5 مللي ثانية، قفزت مدتها إلى 105 مللي ثانية على NVMe وإلى 903 مللي ثانية على حجم شبكة Hetzner. هذه التكلفة، لكل رسالة، تتجاوز بكثير تكلفة إيقاف البيانات الوصفية مؤقتًا. ومع ذلك، فإن goroutine الذي يقوم بالتخصيص هو وحده الذي يتحمل هذه التكلفة، مما يجعلها مشكلة محلية وليست عالمية مثل مشكلة البيانات الوصفية.
لم أتمكن من تحديد بالضبط أين يذهب كل هذا الوقت الإضافي، لكنني أردت أن أشير إلى هذه النقطة هنا، لأنها تمثل تكلفة أخرى يجب أن نأخذها في الحسبان. على الرغم من ذلك، ما زلت أتفق مع كريس داون بأن المبادلة (Swap) ليست شرًا مطلقًا. ومع ذلك، لم تكن النتائج جيدة على الإطلاق فيما يتعلق بجمع القمامة في Go، وفي بيئات الإنتاج، غالبًا ما نعتمد بشكل كبير على جمع القمامة الفعال.
تحديث: 14 سبتمبر. سألني أحدهم عما إذا كان جامع القمامة Go 1.26 (Green Tea GC) قد غيّر طريقة قراءة جامع القمامة للبيانات الوصفية. بعد إجراء القياسات، وجدت أن التأثير كان ضئيلًا جدًا.