وبائيات تفريغ الذاكرة: إصلاح علة عمرها 18 عامًا
استخدام تحليل على مستوى المجتمع لتصحيح أعطال معقدة في بنية بياناتنا التحتية.
تعتمد نماذج OpenAI ووكلاؤها أكثر فأكثر على بنية بيانات قابلة للتوسع للبحث عن البيانات ذات الصلة وقت الاستدلال: حين تفكر النماذج في سؤالك. بعض هذه الخدمات مكتوب بلغة C++، التي يتيح تحكمها منخفض المستوى في النظام تعظيم الأداء وتقليل استخدام الذاكرة. هذه المكاسب في الكفاءة مهمة مع توسعنا، لكن افتقار C++ إلى أمان الذاكرة يعني أن العلل قد تسبب أعطالًا بالكتابة إلى عناوين ذاكرة خاطئة أو غير موجودة.
قبل بضعة أشهر لاحظنا أعطالًا من داخل خدمة Rockset، وهي جزء مخصص من بنية بيانات ChatGPT لدينا، ومحورية لكثير من إضافات البيانات وللبحث في المحادثات. في كل عطل منها، بدا أن دالة C++ عادية تنهي عملها ثم تعود إلى عنوان زائف، ما جعل النواة توقف البرنامج لأن مؤشر التعليمة لم يعد يشير إلى كود. أحيانًا كانت خانة عنوان العودة في إطار المكدس تساوي NULL. وأحيانًا بدا سجل مؤشر المكدس في CPU نفسه منزاحًا 8 بايتات، كما لو أن %rsp نُقص بطريقة ما أثناء التنفيذ العادي. في الحالتين كان العطل يحدث عند العودة.
هذه ليست أنماط فشل عادية لكود التطبيقات. الكتابة الشاردة التي تصيب عنوان عودة محفوظًا فقط ممكنة، لكنها شديدة الندرة. والعلة التي تزيح %rsp بمقدار 8 من دون تجميع مضمّن أو setcontext أو longjmp (ولا نستخدم أيًا منها) أغرب حتى، لأن الكود المترجم لا يعدّل هذا السجل مباشرة إلا في مقدمة الدالة وخاتمتها. كل فرضية خطرت لنا (أو لـ ChatGPT) كانت تواجه أدلة قوية ضدها، لذا بدت العلة مستحيلة.
ما افترضناه مشكلة واحدة اتضح في النهاية أنه علتان غير مرتبطتين، اكتُشفتا مصادفة في الوقت نفسه. الأولى: تلف عتادي صامت في مضيف Azure واحد، حيث كان CPU لا يجري الحسابات بشكل صحيح. والثانية: حالة تسابق عمرها 18 عامًا في GNU libunwind، علة غير ملحوظة في مكتبة مفتوحة المصدر واسعة الاستخدام.
تروي هذه التدوينة كيف حددنا أعطالًا بدت عصية على التفسير وأصلحناها، بالتفكير كاختصاصي وبائيات وبناء مجموعة بيانات عالية الجودة عن مجتمع الأعطال كله.
لنبدأ بالتعمق أكثر في Rockset. إنه نظام بيانات سحابي أصيل للبحث والتحليلات الفورية، نستخدمه في OpenAI لكثير من الحالات الداخلية، مثل موصلات المزامنة (استحوذت OpenAI على Rockset في 2024). تُستخدم التحديثات المتدفقة للحفاظ على فهرس محدث لقاعدة معرفة مساحة عمل مشتركة، كي يتمكن ChatGPT من البحث عن المعلومات ذات الصلة عند الإجابة عن الأسئلة أو تنفيذ الإجراءات.
طبقة التنفيذ في Rockset مكتوبة بلغة C++. تتيح C++ وصولًا منخفض المستوى إلى CPU، وهو مفيد للأداء والكفاءة، لكنه يعني أن علل التطبيقات قد تؤدي إلى وصولات ذاكرة غير صالحة وأخطاء تجزئة. وللمساعدة في تعقبها نستخدم معالج الإشارات القاتلة في folly لتسجيل تتبع المكدس عند حدوث عطل، ونرفع تفريغات الذاكرة المقابلة (لقطة لحالة البرنامج عند تعطله) إلى تخزين Azure blob لتحليلها لاحقًا. كل أوراق معالجة الاستعلامات في Rockset مكررة، ما يقلل أثر العطل على العميل. ومع ذلك، يقابل كل خطأ تجزئة علة يجب إصلاحها لتحقيق أهدافنا في الموثوقية والجودة.
كان نهجنا الأولي أن نتعامل مع هذه التفريغات كمشكلة تصحيح تقليدية: نفحص بعضها بدقة، ونضع فرضيات، ونستبعدها واحدة تلو الأخرى.
حدثت معظم الأعطال في طريقة اسمها DocumentTree::updateDocument. في هذه الأعطال بدا أن updateDocument استدعت دالة مجهولة X، وتلف المكدس أثناء نشاط X، ثم عادت X إلى عنوان ليس كودًا قابلًا للتنفيذ. في بعض الحالات بدا إطار X الذي أُزيل للتو صالحًا، باستثناء أن عنوان العودة المحفوظ كان NULL. وفي حالات أخرى بدا مؤشر المكدس نفسه خاطئًا، لكن الإطار الصالح التالي ظل يبدو أنه updateDocument.
لم نكن نعرف متى يتلف المكدس، ما ترك مساحة بحث هائلة. updateDocument طريقة كبيرة يحدث فيها كثير من الدمج المضمّن، لذا كان عدد المرشحين لـ X طاغيًا.
هل كانت هذه علة في كود C++ لدينا؟ مشكلة في المصرّف أو الربط؟ مشكلة في إحدى مكتبات وقت التشغيل لدينا؟ علة في نواة Linux حول تسليم الإشارات أو تبديل السياق؟ أم شيء أندر من ذلك؟ إذا كانت كتابة شاردة، فلماذا لم تلتقطها بيئة التجهيز ASAN لدينا؟
حاولنا استخدام سجلات مستوى التطبيق لتحديد كل مرات حدوث المشكلة، لكن علل تلف المكدس يصعب تصنيفها من السجلات وحدها لأن تتبعات المكدس المسجلة نفسها تالفة أو مفقودة. لم نتمكن من بناء استعلام سجلات يخلو من الإيجابيات الكاذبة والسلبيات الكاذبة معًا. فحصنا يدويًا مزيدًا من التفريغات ووجدنا أمثلة إضافية، لكن العملية كانت مرهقة أكثر من أن تمنحنا مجموعة بيانات موثوقة.
في هذه المرحلة من التحقيق استبعدنا (خطأً) علة عتادية، لأننا رأينا أعطالًا عبر مناطق متعددة وأنواع عتاد متعددة، فظل بحثنا محصورًا في أسباب برمجية. لبضعة أيام تعمقنا جدًا في عطل واحد كان فيه %rsp غير محاذى، وأعدنا بناء التاريخ السابق للعطل من محتويات المكدس والسجلات. أنتج ذلك خيوطًا محتملة، لكن لأننا لم نتخل عن استنتاجنا الأولي بأن كل العلل لها السبب نفسه، لم يخرجنا ذلك من المأزق.
قبل الوصول إلى نقطة التحول في تحقيقنا، من المهم شرح نوع المعلومات التي كنا نستخرجها من ملفات التفريغ.
يُترجم Rockset مع -fno-omit-frame-pointer، لذا يمكن دائمًا الوصول إلى إطار المكدس النشط عبر %rbp، ويكوّن المستدعون قائمة مترابطة من مؤشرات الإطارات.
على Linux x86_64، يحجز AMD64 System V ABI أيضًا 128 بايتًا أسفل %rsp باسم المنطقة الحمراء. هذه المنطقة متاحة لكود فضاء المستخدم، والأهم أن النواة تتعهد بعدم الكتابة فوقها عند تسليم إشارة، ضمن عقد ABI.
كانت المنطقة الحمراء محورية في تصحيح عطل بعد العودة، لأنها تحفظ بعض معلومات ما قبل العودة. عند إطلاق SIGSEGV، يعمل معالج الإشارات القاتلة في folly على مكدس الخيط المتعطل. ستُطمس إطارات المكدس التي لم تعد نشطة (لأن دالتها عادت) بواسطة معالج الإشارة، باستثناء آخر 128 بايتًا. لذلك نستطيع قول أمور مثل «بدا إطار مكدس X الذي أُزيل للتو صالحًا، باستثناء عنوان عودة NULL». فالمنطقة الحمراء تحفظ بعض الإطارات غير النشطة، أو أحيانًا ذيل إطار غير نشط واحد فقط.
وجدنا عطلًا واحدًا بمكدس غير محاذى كانت كل الدوال المتورطة فيه صغيرة جدًا. سمح لنا ذلك برؤية أن %rsp صار غير محاذى أثناء تنفيذ دالة بسيطة نسبيًا، وأن استدعاءات أخرى نجحت بعد ذلك. لم يتعطل البرنامج إلا عندما حاولت الدالة النشطة أخيرًا أن تعود. لم يستخدم أي من تلك المسارات استثناءات أو تجميعًا مضمّنًا أو setcontext أو longjmp؛ لذا إن كان مؤشر المكدس قد تغير حقًا كما أوحى التفريغ، فلا توجد علة معقولة في كود فضاء المستخدم تفسر ذلك.
دفعنا ذلك نحو النواة.
يستخدم Rockset الإشارات بكثافة أكبر من معظم البرامج. يُقسَّم تنفيذ الاستعلام إلى مهام خفيفة كثيرة تتبادل البيانات. هذا مهم للتعامل بكفاءة مع أحمال QPS العالية، لكنه يجعل حساب CPU لكل استعلام مربكًا لأن عمل استعلامات كثيرة يُضاعَف على تجمع الخيوط نفسه.
حلنا هو ما نسميه coarse_thread_cputime_clock، وهو يقارب clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) بتكلفة منخفضة تكفي لأخذ عينة عند كل حد مهمة. يمكن استخدام واجهة timer_create لجدولة تسليم إشارة دورية بناءً على عدة مفاهيم لمرور الزمن، منها تراكم وقت CPU. نجدول إشارة (SIGUSR2) لتُسلَّم كل بضعة ميلي ثانية من وقت CPU، وعندها يحدّث معالج الإشارة قيمة محلية للخيط. مع أن كثيرًا من المهام لا ترى الساعة التقريبية تتقدم أثناء تنفيذها، فإن جمع كل الفروق يعطي تقديرًا غير متحيز لوقت CPU الفعلي للاستعلام.
ولأننا نسلم الإشارات كثيرًا، بدا احتمال وجود علة نادرة في النواة حول تبديل السياق أو تسليم الإشارات معقولًا. قضينا وقتًا في قراءة تقارير العلل وكود النواة وتصحيحات النواة الخاصة بـ Azure. جربنا اختبارات إجهاد. لم نستطع العثور على شيء يبدو ذا صلة.
عندها قررنا التراجع خطوة وتجربة نهج مختلف.
هناك طريقتان عامتان لتصحيح مشكلة كهذه.
الأولى أن تتصرف كطبيب نوعًا ما: تركز على مريض واحد، تجري اختبارات كثيرة، وتحاول تشخيص حالة واحدة من أدلة تفصيلية.
والأخرى أن تتصرف أكثر كاختصاصي وبائيات: تنظر إلى المجتمع كله وتسأل هل توجد أنماط لا تكشفها حالة واحدة. هل بدأت العلة مع إصدار محدد؟ هل ترتبط بوحدة SKU عتادية واحدة (طراز CPU والخادم المحددين)، أو منطقة واحدة، أو إصدار نواة واحد؟ هل توجد عناقيد متعددة متميزة مختبئة داخل ما يبدو متلازمة واحدة؟
كنا في الغالب في نمط الطبيب. كان التحول الحاسم هو قرارنا أننا بحاجة إلى جمع بيانات مجتمعية عالية الجودة.
فشلت محاولاتنا السابقة للعثور آليًا على كل حالات المشكلة لأننا كنا نحاول استخدام البحث النصي في السجلات. تحتوي تفريغات الذاكرة نفسها على معلومات أكثر بكثير، لكن فحصها يدويًا لم يكن قابلًا للتوسع. قررنا استثمار الجهد لبناء خط معالجة يستطيع تحليل تفريغات الذاكرة تلقائيًا.
طلبنا من ChatGPT كتابة سكربت ينزل بادئة كل ملف تفريغ، ويستخرج السجلات، ويرشح الإيجابيات الكاذبة المعروفة باستخدام السجلات، ويصنف العطل تلقائيًا كعودة إلى NULL أو مكدس غير محاذى أو غير ذلك. ثم شغلنا ذلك السكربت بالتوازي على كل تفريغات Rockset الإنتاجية من العام السابق.
كانت هذه نقطة التحول.
بمجرد أن أصبحت لدينا مجموعة بيانات نظيفة، ظهرت الارتباطات فورًا. ما كنا نتعامل معه كعلة غريبة واحدة كان في الحقيقة مجتمعَي أعطال منفصلين اثنين.
كانت تفريغات العودة إلى NULL موزعة على عناقيد ومناطق جغرافية كثيرة. زاد تواترها مؤخرًا، لكن لم يكن هناك تاريخ بدء حاد ولا حد بنية تحتية واضح.
أما أعطال المكدس غير المحاذى فبدت مختلفة تمامًا. كلها جاءت من منطقة واحدة، ولها تاريخ بدء واضح، ولم تحدث قط على عُقد كانت تعمل منذ وقت طويل. ومع أنها شملت عدة آلات افتراضية من Azure (آلات افتراضية مستضافة في السحابة)، بدا النمط كأنه آلة فعلية واحدة ذات عتاد معيب تسبب مشكلات لأي آلة افتراضية تقع عليها.
كانت تلك اللحظة التي أدركنا فيها أننا كنا ندمج علتين ذهنيًا. ولأننا كنا نخلط أمثلة مضادة من العلتين، لم نستطع إيجاد تفسير واحد متماسك.
بعد أن امتلكنا قائمة نظيفة بعُقد Kubernetes والطوابع الزمنية، استطعنا تتبع أعطال المكدس غير المحاذى إلى مضيف فعلي واحد، وكان من السهل وضعه في قائمة الحظر.
لم نستطع إعادة إنتاج تلف السجلات على ذلك المضيف في بيئة مضبوطة، حتى بعد أسابيع عدة من اختبارات الإجهاد. لكن بمجرد إخراج المضيف الإشكالي من الخدمة، اختفت أعطال المكدس غير المحاذى.
إزالة المضيف السيئ ليست حلًا دائمًا، بمعنى أنها لا تمنع تكرار المشكلة نفسها من جديد. لكن يمكننا تغيير البرمجيات بحيث إذا تكررت مشكلة مشابهة، يسهل اكتشافها والتعامل معها. حسّنا معالج الإشارات القاتلة ليشمل حالة السجلات، كي نتمكن من اكتشاف التكرار من السجلات فقط (من دون الحاجة إلى تفريغ ذاكرة). غيّرنا مستوى التحكم بحيث تُعاد عادةً استخدام الآلات الافتراضية بدل تدويرها، ما يجعل اكتشاف العُقدة السيئة أسهل بكثير على مستوانا من مكدس البنية التحتية. كما حدّثنا أدلة التشغيل لدينا (ونماذج فريقنا الذهنية) لتشمل هذا الاحتمال.
بعد فصل أعطال المضيف السيئ، صار من الأسهل بكثير التفكير في تفريغات العودة إلى NULL المتبقية. كنا قد استبعدنا سابقًا فك مكدس الاستثناءات لأننا ظننا أن لدينا أمثلة مضادة: أعطالًا في مسارات كود لا تُستخدم فيها الاستثناءات قطعًا. لكن تلك الأمثلة المضادة كلها كانت من عنقود التلف العتادي.
عندما أعدنا النظر في التفريغات المتبقية مع أخذ ذلك في الحسبان، وجدنا أن ذلك الاستنتاج كان معكوسًا تمامًا: كل الأعطال كانت تحدث أثناء فك مكدس الاستثناءات.
عندما ترمي C++ استثناءً، يجب على وقت التشغيل اكتشاف كتلة catch التي ينبغي أن تستقبله، وأي مهدمات أو معالجات تنظيف يجب تشغيلها في الطريق. يصدر المصرّف هذه البيانات الوصفية، لكن المطابقة الفعلية تحدث ديناميكيًا في وقت التشغيل.
لا تنفذ الدالة التي تستدعي throw فعليًا فك مكدس الاستثناء، بل تنفذه دوال مساعدة يستدعيها الكود المترجم الناتج. تفحص روتينات وقت التشغيل هذه المكدس، وتجلب بيانات وصفية عن الدوال الموجودة عليه، وتبحث ديناميكيًا عن معالجات التنظيف وكتل catch، ثم تنقل التحكم إلى أحد تلك المواضع. يشمل نقل التحكم فك كل إطارات المكدس الواقعة بينهما (بما فيها إطارات الدوال المساعدة).
تشغيليًا، يشبه ذلك longjmp أو تبديل الألياف أكثر بكثير من استدعاء وعودة عاديين. يجب استعادة سجلات الحفظ لدى المستدعى، وكذلك سجلات إطار المكدس %rbp و%rsp.
يرتبط ملفنا الثنائي بمكتبتين تحتويان تنفيذات للدوال التي تفك مكدس استثناءات C++: libgcc وGNU libunwind. كانت تعريفات GNU libunwind هي التي اختارها الرابط الديناميكي. فاجأنا ذلك؛ فقد توقعنا أن يفوز تنفيذ libgcc بسبب قواعد ترقيم إصدارات الرموز، لكن فحص الملفات الثنائية العاملة أظهر أن الأمر ليس كذلك.
عند هذه النقطة تغيرت فرضيتنا العملية، إذ خففنا افتراضًا آخر كنا تبنيناه حين ظننا أن هناك علة واحدة فقط.
ربما لم نكن نرى عودة دالة عادية إلى NULL. ربما كنا نرى نقل فك مكدس—أي فعليًا استعادة سجلات بأسلوب setcontext—حيث صار مؤشر تعليمة الوجهة NULL قبل نقل التحكم. بعبارة أخرى، بيانات خاطئة من مكتبة فك المكدس لا خانة عنوان عودة خاطئة على المكدس.
ضيّق ذلك المشكلة كثيرًا. إما أن GNU libunwind كانت تحسب حالة وجهة خاطئة، أو أنها كانت تحسب الحالة الصحيحة ثم يفسدها شيء قبل تطبيقها.
قرأنا مصدر GNU libunwind ووجدنا أنها تركّب ucontext_t على المكدس، وتملأ حالة السجلات المطلوبة لإطار معالج التنظيف، ثم تمرر مؤشرًا إلى ذلك البنيان إلى روتين تجميع داخلي: _Ux86_64_setcontext.
عند هذه النقطة كانت كل القطع بين أيدينا.
يعيش ucontext_t المركّب في أحد إطارات المكدس التي يفكها _Ux86_64_setcontext أثناء تنفيذ تلك الدالة. هل كان _Ux86_64_setcontext يقرأ من البنيان بعد أن غيّر %rsp، حين لم يعد البنيان جزءًا من المكدس النشط؟ هذا يجعله عرضة للطمس عند تسليم إشارة، مثل SIGUSR2 المتكرر لدينا.
كانت الإجابة نعم.
هذه هي التعليمات الست الأخيرة في _Ux86_64_setcontext في نسخة GNU libunwind التي كنا نستخدمها، وتتكون غالبًا من تعليمات mov تحمل من الذاكرة إلى سجل وجهة:
(%rdi يشير إلى ucontext_t المخصص على المكدس، وماكروهات UC_MCONTEXT_* تتمدد فقط إلى الإزاحة الثابتة التي يُخزّن عندها سجل معين.)
التعليمة الأولى هي بداية نافذة التسابق. إنها تحدّث %rsp ليشير إلى القاع الجديد للمكدس النشط. حالما يحدث ذلك، لا يعود البنيان الذي يشير إليه %rdi جزءًا من المكدس النشط (ولا من المنطقة الحمراء)، ولا يعود محظورًا على النواة.
عادةً لا يسبب هذا مشكلات، لكن إذا وصلت إشارة في اللحظة الصحيحة تمامًا (الخاطئة؟)، ستبني النواة إطار الإشارة عند %rsp-128. يمكن لذلك أن يكتب فوق الذاكرة التي يشير إليها %rdi.
إذا حدث ذلك قبل أن تقرأ التعليمة التالية UC_MCONTEXT_GREGS_RIP(%rdi)، فقد يتلف مؤشر التعليمة المستعاد. في أعطالنا، صار NULL.
تلك هي العلة.
يفسر هذا التجميع أيضًا إحدى الملاحظات التي أربكتنا: لماذا كان لدى الدالة X قيمة NULL في خانة عنوان العودة لإطار المكدس السابق.
كُتبت setcontext لاستعادة كل السجلات، بما فيها %rdi، لذا لا يمكنها استخدام ذلك السجل لقراءة UC_MCONTEXT_GREGS_RIP(%rdi) في اللحظة الأخيرة من نقل التحكم. بدلًا من ذلك تقرأ القيمة مبكرًا، وتحفظها على المكدس، وتستعيد بضعة سجلات أخرى، ثم تستخدم retq لقراءة القيمة المحفوظة ونقل التحكم.
ما بدا في التفريغات كأن «دالة عادت إلى NULL» كان في الواقع أن «مفكك المكدس ركّب عنوان عودة هدفًا على المكدس، لكن ذلك الهدف تلف قبل اكتمال النقل». افترضنا أن تلف خانة عنوان العودة لا بد أن يحدث في مكانها، لأننا لم نكن نعرف مواضع تُكتب فيها بيانات (قابلة للتلف) عمدًا إلى خانة عنوان العودة.
ما يجعل هذه العلة تبدو عبثية هو ضيق نافذة التسابق. في هذا النوع من حالات التسابق، يجب أن يقع الحدث الخارجي (الإشارة) بين خطوتين ينفذهما خيط آخر. كلما اقتربت الخطوتان إحداهما من الأخرى، قل احتمال حدوث حالة التسابق.
في هذه الحالة، النافذة المعرضة للخطر عرضها تعليمة واحدة حرفيًا! يجب أن تُسلَّم إشارة بعد تغيير %rsp، لكن قبل أن تحمل التعليمة التالية %rip. يمكن تنفيذ عدة تعليمات بسيطة كهذه في كل دورة على CPU حديث فائق التدرج وخارج الترتيب، لذا فنافذة التسابق تقارب مئة بيكوثانية.
عندما وجدنا هذا التسابق، كان رد فعلنا الأول أنه لا بد أن يكون نادرًا جدًا بحيث لا يفسر معدل الأعطال المرصود. كنا نرى أكثر من عشرة أعطال عودة إلى NULL يوميًا عبر الأسطول. هل يمكن حقًا لتسابق من تعليمة واحدة أثناء تنظيف الاستثناءات أن يفسر ذلك؟
لجأنا إلى تقدير فيرمي. إذا كانت النافذة المعرضة للخطر في حدود ثانية، وتصل SIGUSR2 كل ثانية من وقت CPU، فاحتمال أن يخسر كل معالج تنظيف استثناء أو كتلة catch التسابق يقارب .
يستخدم Rockset الاستثناءات كجزء من آلية الضغط الخلفي الداخلية للإدخال. يمكن لمضيف واحد مثقل أن يرمي نحو استثناءات في الثانية. وهذا يعني أن متوسط الزمن بين أعطال مضيف يستخدم الضغط الخلفي هو ثوانٍ، أي عطل واحد كل بضع ساعات. وعلى نطاق الأسطول، يكفي ذلك تمامًا لتفسير تواتر الأعطال المرصود.
علة GNU libunwind قديمة—عمرها أكثر من 18 عامًا، وموجودة في أول نسخة x86_64 دعمت فك مكدس استثناءات C++.
فلماذا ظهرت الآن؟
معدل الأعطال يتناسب تقريبًا مع عدد الاستثناءات المرمية وعدد الإشارات المسلَّمة. ويعتمد أيضًا على مقدار المكدس الذي يستهلكه معالج الإشارة.
Rockset غير معتاد على المحاور الثلاثة كلها. نرمي الاستثناءات بمعدلات عالية كجزء من التحكم العادي في الحمل الزائد؛ ونسلم SIGUSR2 كثيرًا على نحو غير مألوف بسبب coarse_thread_cputime_clock؛ وفي وقت سابق هذا العام جعلنا معالج SIGUSR2 يستخدم مكدسًا أكثر بإضافة استدعاء إلى timer_getoverrun كي نحسب الإشارات المدمجة.
يبدو أن ذلك التغيير الأخير كان مهمًا. إذا استخدم المعالج قدرًا قليلًا بما يكفي من المكدس، فقد لا يصل إلى ذاكرة ucontext_t القديمة ولا يكتب فوقها. قبل ذلك التغيير، لم نرصد هذه الأعطال إطلاقًا. بعد التغيير ظل المعدل منخفضًا حتى زدنا الحمل لبعض حالات الاستخدام التي ضغطت على آلية الضغط الخلفي.
بعبارة أخرى، كانت علة libunwind موجودة دائمًا، لكن حاصل ضرب معدل الاستثناءات لدينا ومعدل الإشارات واستخدام مكدس المعالج لم يتجاوز إلا مؤخرًا العتبة التي جعلتها ظاهرة تشغيليًا.
تفسر هذه الآلية أيضًا المصادفة التي جعلت العلة العتادية وعلة libunwind تتعطلان غالبًا داخل DocumentTree::updateDocument. كانت أعطال libunwind منحازة بشدة إلى هذه الطريقة، لأنها تكون نشطة دائمًا عند النقطة التي نرمي فيها استثناءً لتطبيق الضغط الخلفي على الإدخال. كما انتُقيت بشدة أعطال عدم محاذاة %rsp لأنها كانت على عُقدة عتادية سيئة من SKU نستخدمها للإدخال الدفعي، الذي يقضي معظم وقت CPU في تلك الطريقة.
كان تخفيفنا الفوري هو الانتقال من GNU libunwind إلى مفكك المكدس في libgcc. وكان ذلك خيارًا جيدًا بحد ذاته: فقد استفاد تنفيذ libgcc من عمل كثير لتقليل تنازع الأقفال، وهو مهم عند التوسع إلى آلات افتراضية كبيرة.
كما رفعنا إلى المنبع أداة إعادة إنتاج مستقلة وإصلاحًا(يفتح في نافذة جديدة) إلى GNU libunwind، وتحققنا من أن مفككات المكدس الأخرى لا تعاني مشكلة مشابهة.
علمتنا رحلة التصحيح هذه الكثير عن تفاصيل محددة في الربط الديناميكي، وبيانات DWARF الوصفية لفك المكدس، وتسليم إشارات Linux، وSystem V ABI، وآلية استثناءات C++. لكن الدرس الرئيسي كان أبسط من ذلك كله.
لم تكن الخطوة الأهم قراءة التجميع بذكاء ولا المعرفة العميقة بالتفاصيل. بل كانت بناء مجموعة بيانات عالية الجودة. في غياب هذه المجموعة، كنا نمزج ظاهرتين متميزتين في قصة واحدة ونحاول الخروج من الارتباك بالاستدلال وحده. بمجرد أن حصلنا على بيانات مجتمعية دقيقة وكاملة، اتضح هيكل المشكلة: مجتمع أعطال يعود إلى مضيف سيئ، والآخر إلى تسابق في libunwind. عندما تحسنت البيانات، صار التصحيح أسهل.
بالنسبة إلى أنظمة بنية تحتية مثل Rockset، فهذا مهم جدًا. عزز هذا التحقيق التزامنا بالقياس العميق، والتحقيقات المؤتمتة، والتحسين المستمر لأدوات التشغيل لدينا. الموثوقية ليست مجرد إصلاح العلل بعد وقوعها؛ بل بناء البيانات وسير العمل والمهارات التي تحول المشكلات المستحيلة إلى مشكلات قابلة للتشخيص والحل.
المؤلفون
By Nathan Bronson وMember of Technical Staff


