عودة إلى أخبار المطوّرين

Signals in prod: dangers and pitfalls

٢٧ سبتمبر ٢٠٢٢بواسطة‏‎Chris Down‎‏

في منشور المدونة هذا، يناقش كريس داون، مهندس Kernel في Meta، العقبات المصاحبة لاستخدام إشارات Linux في بيئات إنتاج Linux ولماذا يجب على المطوّرين تجنب استخدام الإشارات كلما أمكن ذلك.

ما المقصود بإشارات Linux؟

الإشارة هي حدث معين تعمل أنظمة Linux على إنشائه استجابةً لحالة ما. يمكن إرسال الإشارات من جانب kernel إلى عملية أو من جانب عملية إلى عملية أخرى أو إلى العملية إلى ذاتها. وعند تلقي إشارة محددة، يمكن للعملية اتخاذ إجراء بناءً عليها.

تعد الإشارات جزءًا أساسيًا من بيئات التشغيل المشابهة لـ Unix وهي موجودة منذ البداية وليس مستحدثة. وهي بمثابة ركيزة للعديد من المكونات الأساسية في نظام التشغيل، معالجة الملفات الأساسية وإدارة مسار العملية، وغير ذلكن وبشكل عام، فقد صمدت جيدًا على مدى الخمسين عامًا أو نحو تلك المدة التي استخدمناها فيها. على هذا النحو، عندما يقترح شخص ما أن استخدامها للتواصل بين العمليات (IPC) من المحتمل أن يكون خطيرًا، فقد يعتقد المرء أن هذه هي مجرد ترهات من جانب شخص لا يفضل اتباع النهج نفسه. ومع ذلك، تهدف هذه المقالة إلى توضيح الحالات التي تكون فيها الإشارات هي السبب وراء مشكلات الإنتاج وتقدم بعض إجراءات التخفيف والبدائل المحتملة.

قد تبدو الإشارات جذابة نتيجة النمط الموحد الذي يميزها، والتوّفر على نطاق واسع وحقيقة أنها لا تتطلب أي تبعيات إضافية بخلاف ما يوفره نظام التشغيل. ومع ذلك، قد يكون من الصعب استخدامها بأمان. توفر الإشارات عددًا كبيرًا من الافتراضات التي يجب على الشخص أن يكون حريصًا عند التحقق من صحتها لمطابقة متطلباتها، وإذا لم يكن الأمر كذلك، فيجب أن يحرص الشخص على تكوينها بشكل صحيح. في الواقع، توجد العديد من التطبيقات التي لا تقوم بذلك، حتى تلك المعروفة على نطاق واسع، وقد تتعرض لحوادث يصعب معالجتها في المستقبل نتيجةً لذلك.

دعونا نلقي نظرة على الحادث الأخير الذي وقع في بيئة إنتاج Meta، مع التركيز على العقبات المصاحبة لاستخدام الإشارات. سنتكلم بإيجاز عن تاريخ بعض الإشارات وكيف ساهمت في الوصول إلى ما نحن عليه اليوم، ثم سنقارن ذلك باحتياجاتنا الحالية والمشكلات التي نواجهها فيما يتعلق بالإنتاج.

الحادث

أولاً، دعونا نسترجع الأحداث قليلاً. قام فريق LogDevice بتنظيف قاعدة الرموز لإزالة الرموز والميزات غير المستخدمة. كانت إحدى هذه الميزات التي تم إيقاف استخدامها هي نوع من أنواع السجل الذي يوثق عمليات معينة تتم من جانب الخدمة. لم تعد هناك حاجة إلى استخدام هذه الميزة في النهاية، ولم يكن يستهدفها المستهلكون وبالتالي تمت إزالتها. يمكنك الاطلاع على التغيير هنا على GitHub. حتى الآن يبدو أن كل شيء يسير جيدًا.

بعد فترة وجيزة من تطبيق التغيير دون ظهور أي مشكلات تستدعي ذكرها، ظل مسار الإنتاج مستمرًا بثبات ويعالج عمليات استخدام الشبكة كالمعتاد. وبعد بضعة أسابيع، تم تلقي تقرير يفيد بفقدان عُقد الخدمات بمعدل هائل. كان الأمر متعلق بطرح الإصدار الجديد، ولكن لم يكن سبب الخطأ ذاته واضح. ما الأمر المختلف الآن الذي أدى إلى تدهور الأمور؟

عمل الفريق المذكور على تضييق نطاق البحث عن أسباب المشكلة والتوصل إلى تغيير الرمز الذي ذكرناه سابقًا، مما أدى إلى إيقاف استخدام هذه السجلات. ولكن ما السبب؟ ما مشكلة هذا الرمز؟ إذا كنت لا تعرف الإجابة بالفعل، فنحن ندعوك لإلقاء نظرة على هذا الاختلاف ومحاولة اكتشاف الخطأ لأنه ليس واضحًا بشكل مباشر، وهو خطأ يمكن أن يرتكبه أي شخص.

logrotate، الظهور على الساحة

logrotate هي بشكل أو بآخر الأداة القياسية لتدوير السجلات عند استخدام Linux. فهي موجودة منذ ما يقرب من ثلاثين عامًا، وتتضمن مفهومًا بسيطًا: يتلخص في إدارة مسار السجلات عن طريق تدويرها وتفريغ البيانات منها.

لا ترسل logrotate أي إشارات من جانبها، لذلك لن تعثر على أي معلومات حولها، إن وجد، في صفحة logrotate الأساسية أو الوثائق المرتبطة بها. ومع ذلك، بإمكان logrotate اتخاذ أوامر عشوائية لتنفيذها قبل عمليات التدوير أو بعدها. كمثال أساسي من تكوين logrotate الافتراضي في CentOS، يمكنك الاطّلاع على هذا التكوين:

/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    sharedscripts
    postrotate
        /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
    endscript
}

يبدو ضعيفًا بعض الشيء، لكننا سنتغاضى عن ذلك ونفترض أنه يعمل على النحو المنشود. يشير هذا التكوين إلى أنه بعد تدوير logrotate لأي من الملفات المدرجة، يجب أن ترسل SIGHUP إلى pid المضمن في /var/run/syslogd.pid، والذي يجب أن يكون تابعًا للمثيل syslogd الموجود قيد التشغيل.

كل هذا يبدو جيدًا بالنسبة لشيء يتضمن API عامة مستقرة مثل سجل النظام، ولكن ماذا عن شيء داخلي حيث يكون تنفيذ SIGHUP عبارة عن تفاصيل تنفيذ داخلي يمكن أن تتغير في أي وقت؟

تاريخ من الانقطاعات

تتمثل إحدى المشكلات هنا في أنه باستثناء الإشارات التي لا يمكن حصرها في مساحة المستخدم وبالتالي لها معنى واحد فقط، مثل SIGKILL وSIGSTOP، فإن المعنى الدلالي للإشارات متروك لمطوّري التطبيقات والمستخدمين لتفسيرها وبرمجتها. في بعض الحالات، يكون الفرق نظريًا إلى حدٍ كبير، مثل SIGTERM، والذي يُفهم عمومًا إلى حد كبير على أنه يعني "الإنهاء بأمان في أسرع وقت ممكن." ومع ذلك، في حالة SIGHUP، يصبح المعنى أقل وضوحًا بشكل ملحوظ.

SIGHUP هي إشارة تم اختراعها للخطوط التسلسلية واستخدمت في الأصل للإشارة إلى أن الطرف الآخر من الاتصال قد ترك رسالة. في الوقت الحاضر، لا نزال نتبع النهج المعتاد بالطبع، لذلك ما زلنا نرسل SIGHUP مقابل ما يعادله من الإشارات الحديثة: حيث يتم إغلاق محطة طرفية صورية أو افتراضية (وبالتالي أدوات مثل nohup، التي تحاكيها).

في بداية استخدام Unix، كانت توجد حاجة إلى تنفيذ إعادة تحميل البرنامج الخفي. على الأقل يتضمن هذا عادةً إعادة فتح ملف التكوين/السجل دون إعادة التشغيل، وبدت الإشارات وكأنها طريقة خالية من التبعية لتحقيق ذلك. بالطبع، لم تكن هناك إشارة لمثل هذا الشيء، ولكن نظرًا لأن البرامج الخفية هذه ليس لها محطة طرفية متحكمة، فلا ينبغي أن يكون هناك سبب لتلقي SIGHUP، لذلك بدا الأمر وكأنه إشارة ملائمة للاعتماد عليها من دون ملاحظة أي تأثيرات جانبية.

وبالرغم من ذلك هناك عقبة صغيرة في هذه الخطة. حيث تكون الحالة الافتراضية للإشارات ليست "متجاهلة"، لكنها خاصة بالإشارة. على سبيل المثال، لا يتعين على البرامج تكوين SIGTERM يدويًا لإنهاء التطبيق. طالما لم يتم تعيين أي معالج إشارة آخر، فإن kernel ينهي برنامجه مجانًا، دون الحاجة إلى توفير أي رمز في مساحة المستخدم. الأمر في غاية السهولة!

تكمن العقبة على الرغم من ذلك، في أن الإشارة SIGHUP لديها أيضًا السلوك الافتراضي لإنهاء البرنامج على الفور. يعمل هذا بشكل رائع مع حالة الانقطاع الأصلية، حيث من المحتمل ألا تكون هناك حاجة إلى هذه التطبيقات بعد الآن، ولكنها ليست رائعة جدًا لهذا المعنى الجديد.

سيكون هذا جيدًا بالطبع، إذا قمنا بإزالة كل الأماكن التي من المحتمل أن ترسل SIGHUP إلى البرنامج. والمشكلة هي أنه في أي قاعدة رموز كبيرة ومعروفة، يكون هذا صعبًا. حيث لا تشبه الإشارة SIGHUP استدعاء التواصل بين العمليات (IPC) الذي يتم التحكم فيه بشكل موثوق والذي يمكنك بسهولة تنفيذ الأمر grep في قاعدة الرموز لديه. من الممكن أن تأتي الإشارات من أي مكان وفي أي وقت، وتوجد بعض عمليات التحقق في العمليات المرتبطة بها (بخلاف العمليات الأساسية "هل أنت ذلك المستخدم أو لديك CAP_KILL"). خلاصة القول هي أنه من الصعب تحديد من أين يمكن أن تأتي الإشارات، ولكن في وجود حالات تواصل بين العمليات (IPC) أكثر وضوحًا، سنحدد أن هذه الإشارة لا تعني أي شيء بالنسبة لنا ويجب تجاهلها.

من الانقطاع إلى الخطر

في الوقت الحالي، من المفترض أن تكون قد بدأت في تخمين ما حدث. بدأ إصدار LogDevice بعد المرور بفترة عصيبة، حيث يحتوي على تغيير الرمز المذكور أعلاه. في البداية، لم يتغير أي شيء عن المتوقع، ولكن في منتصف ليل اليوم التالي، بدأ كل شيء يتدهور بشكل غامض. وكان السبب هو المقطع التالي في تكوين logrotate للجهاز، والذي يرسل الآن إشارات SIGHUP غير معالجة (وهو أمر كارثي) إلى برنامج logdevice الخفي:

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

يُعد فقدان مقطع قصير واحد فقط من تكوين logrotate أمرًا سهلاً وشائعًا عند إزالة ميزة كبيرة. ولسوء الحظ، من الصعب أيضًا التأكد من إزالة كل آثار وجودها مرة واحدة. حتى في الحالات التي يكون التحقق فيها أسهل من ذلك، فمن الشائع ترك بعض الآثار عن طريق الخطأ عند تنظيف الرمز. ومع ذلك، عادةً ما يتم ذلك من دون أي عواقب كارثية، حيث تكون المهملات المتبقية هي مجرد رمز منتهي أو رمز متوقف عن العمل.

من الناحية النظرية، فإن الحل يبدو بسيطًا بقدر ما يبدو الحدث: لا ترسل إشارات SIGHUP، وارسل المزيد إجراءات LogDevice بمرور الوقت (أي، لا تقم بتشغيل هذا في منتصف الليل بالضبط). ومع ذلك، يجب ألا يتم التركيز هنا على الفروق الدقيقة في هذا الحادث فقط. يجب أن يكون هذا الحادث، أكثر من أي شيء آخر، بمثابة أساسًا لتقليل استخدام الإشارات في الإنتاج لأي شيء بخلاف الحالات الأساسية والضرورية.

مخاطر الإشارات

مزايا الإشارات

أولاً، يُعد استخدام الإشارات كآلية لتطبيق التغييرات في حالة عمل نظام التشغيل أساسًا سليمًا للاستناد إليه. يتضمن ذلك إشارات مثل SIGKILL، والتي من المستحيل تثبيت معالج إشارة لها وتقوم بما تتوقعه بالضبط وسلوك kernel الافتراضي فيما يتعلق بالإشارة SIGABRT وSIGTERM وSIGINT وSIGSEGV وSIGQUIT وما إلى ذلك، والتي يفهمها المستخدمون والمبرمجون جيدًا على نحوٍ عام.

يتمثل القاسم المشترك لدى كل هذه الإشارات في أنه بمجرد تلقيها، ستتقدم كل الإشارات نحو الحالة النهائية للمحطة الطرفية داخل kernel ذاته. أي أنه لن يتم تنفيذ المزيد من تعليمات مساحة المستخدم بمجرد حصولك على الإشارة SIGKILL أو SIGTERM بدون وجود معالج إشارة مساحة المستخدم.

تُعد الحالة النهائية للمحطة الطرفية هامة لأنها تعني عادةً أنك تعمل على تخفيض تعقيد المكدس والرموز التي يتم تنفيذها حاليًا. غالبًا ما تؤدي الحالات المرغوبة الأخرى إلى زيادة التعقيد ويصبح من الصعب التفكير في الأمر حيث يتخذ كل من التزامن ودفق الرمز مسارًا أكثر تشويشًا.

خطورة السلوك الافتراضي

قد تلاحظ أننا لم نذكر بعض الإشارات الأخرى التي تنتهي أيضًا بشكل افتراضي. فيما يلي قائمة بكل الإشارات القياسية التي تنتهي افتراضيًا (باستثناء إشارات التفريغ الأساسي مثل SIGABRT أو SIGSEGV، نظرًا لأنها حساسة بعض الشيء):

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

للوهلة الأولى، قد تبدو هذه الإشارات منطقية ولكن فيما يلي بضعة إشارات العشوائية:

  • SIGHUP: إذا تم استخدام هذه الإشارة فقط لخدمة الغرض المصممة من أجله، فسيكون إنهاء الإشارة بشكل افتراضي أمرًا منطقيًا. ومع الاستخدام المختلط الحالي الذي يعني "إعادة فتح الملفات"، يُعد هذا أمرًا خطيرًا.
  • SIGPOLL وSIGPROF: هاتان الإشارتان موجودتان في مجموعة تحت عنوان "يجب معالجتهما داخليًا بواسطة بعض الوظائف القياسية بدلاً من البرنامج لديك." ومع ذلك، وعلى الرغم من أن الأمر قد يكون غير ضار، فإن سلوك إنهاء الإشارة بشكل افتراضي لا يزال يبدو غير مثالي.
  • SIGUSR1 وSIGUSR2: هذه "إشارات يحددها المستخدم" يمكنك استخدامها ظاهريًا كيفما تشاء. ولكن نظرًا لأنها تُعد وحدات طرفية بشكل افتراضي، إذا قمت بتنفيذ USR1 لبعض الاحتياجات المحددة ولم تكن بحاجة إلى ذلك لاحقًا، فلا يمكنك إزالة الرمز بأمان. عليك أن تفكر بحرص نحو تجاهل الإشارة بشكل صريح. الأمر الذي لن يكون واضحًا في الحقيقية، حتى بالنسبة لأكثر المبرمجين خبرة.

هذا هو ما يقرب من ثلث إشارات المحطة الطرفية، والتي تشكل موضع تساؤل في أفضل الأحوال، وفي أسوأ الأحوال تكون خطرة بشكل كبير حيث إن احتياجات البرنامج تتغير. والأسوأ من ذلك، حتى الإشارات المفترض أن "يحددها المستخدم" تُعد قنبلة موقوتة عندما ينسى الشخص تنفيذ الإشارة SIG_IGN فيها بشكل صريح. حتى أن إشارات مثل SIGUSR1 أو SIGPOLL غير الضارة قد تتسبب في وقوع حوادث.

ولا يتعلق الأمر بمدى معرفتك بالإشارات. بغض النظر عن مدى معرفتك بكيفية عمل الإشارات، لا يزال من الصعب للغاية كتابة رمز صحيح للإشارة في المرة الأولى لأنه على الرغم من مظهرها، تظل الإشارات أكثر تعقيدًا مما تبدو عليه.

دفق الرمز والتزامن ومغالطة SA_RESTART

لا يقضي المبرمجون عمومًا يومهم بالكامل في التفكير في كيفية عمل الإشارات. وهذا يعني أنه عندما يتعلق الأمر بتنفيذ معالجة الإشارة بالفعل، فغالبًا ما يرتكبون أخطاء دون دراية.

ولا أتحدث هنا عن الحالات "الطفيفة" فحسب، مثل السلامة في وظيفة معالجة الإشارات، والتي يتم حلها في الغالب عن طريق إدخال الأمر sig_atomic_t أو استخدام عناصر تقييد الإشارات الرقمية في C++. على العكس، فهذا في الغالب يسهُل البحث عنه ويمكن تحديده كخطأ من قبل أي شخص بعد المرور بمشكلات الإشارات لأول مرة. لكن الأمر الأصعب من ذلك بكثير هو فهم دفق رمز الأجزاء الاسمية في البرنامج المعقد عند تلقيه إشارة. ويتطلب إجراء ذلك التفكير بشكل دائم وصريح في الإشارات من حيث كل جزء في مسار التطبيق (ولكن ماذا عن EINTR، هل تكون الإشارة SA_RESTART كافية هنا؟ ما الدفق الذي يجب اتباعه إذا تم إنهاء هذه الإشارة مبكرًا؟ لدي الآن برنامج متزامن، ما هي الآثار المترتبة على ذلك؟)، أو يمكن إعداد sigprocmask أو pthread_setmask لجزء من مسار التطبيق لديك على أمل ألا يتغير دفق الرمز أبدًا (وهو بالتأكيد ليس تخمينًا جيدًا في عصر التطور السريع). يمكن أن يساعد signalfd أو تشغيل sigwaitinfo في سلسلة مخصصة إلى حد ما هنا، ولكن كلاهما يتضمن حالات عنصر ربط كافية ومخاوف بشأن قابلية الاستخدام مما يجعل التوصية بهما أمرًا صعبًا.

نود أن نصدق أن معظم المبرمجين من ذوي الخبرة يدركون الآن أنه حتى مجرد المثال الطريف على كتابة رمز آمن من خلال السلسلة بشكل صحيح يُعد أمرًا صعبًا للغاية. لكن إذا كنت تعتقد أن كتابة رمز آمن من خلال السلسلة بشكل صحيح كان صعبًا، فإن الإشارات أصعب بكثير. يجب ألا تعتمد معالجات الإشارات إلا على رمز غير مقيد مع بنيات البيانات الرقمية، على التوالي، نظرًا لأن الدفق الرئيسي للتنفيذ يكون معلقًا ولا نعرف التقييدات التي يحتويها، ولأن الدفق الرئيسي للتنفيذ يمكن أن يؤدي إلى إجراء عمليات غير رقمية. يجب أيضًا أن تكون قابلة لإعادة الإدخال بالكامل، أي يجب أن يكون من الممكن تضمينها داخل ذاتها نظرًا لأن معالجات الإشارة يمكن أن تتداخل إذا تم إرسال إشارة عدة مرات (أو عند إرسال إشارة واحدة، تتضمن SA_NODEFER). هذا أحد الأسباب التي تجعلك لا تستطيع استخدام وظائف مثل printf أو malloc في معالج الإشارة لأنها تعتمد على كائنات استبعاد التشارك العامة فيما يتعلق بالمزامنة. إذا كان لديك نوع التقييد هذا عند تلقي الإشارة ثم قمت باستدعاء وظيفة تتطلب هذا التقييد مرة أخرى، فسيؤدي ذلك توقف التطبيق تمامًا. ويصعب فهم ذلك الأمر حقًا. لهذا السبب يكتب الكثير من الأشخاص شيئًا كالتالي لمعالجة الإشارة:

static volatile sig_atomic_t received_sighup; 

static void sighup(int sig __attribute__((unused))) { received_sighup = 1; }

static int configure_signal_handlers(void) {
  return sigaction(
    SIGHUP,
    &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART},
    NULL);
}

int main(int argc, char *argv[]) {
  if (configure_signal_handlers()) {
       /* failed to set handlers */
  }

  /* usual program flow */

  if (received_sighup) {
    /* reload */
    received_sighup = 0;
  }

  /* usual program flow */
}

تكمن المشكلة في أنه في حين أن signalfd أو المحاولات الأخرى لمعالجة الإشارات غير المتزامنة قد تبدو بسيطة وقوية إلى حد ما، فإنها تتجاهل حقيقة أن نقطة الانقطاع لا تقل أهمية عن الإجراءات التي يتم تنفيذها بعد تلقي الإشارة. على سبيل المثال، لنفترض أن رمز مساحة المستخدم يُجري عمليات إدخال/إخراج أو يغير بيانات تعريف الكائنات الواردة من kernel (مثل inodes أو FDs). وفي هذه الحالة، من المحتمل أن تكون في مكدس مساحة kernel في وقت الانقطاع. على سبيل المثال، فيما يلي الشكل الذي قد تبدو عليه السلسلة عندما تحاول إغلاق واصف ملف:

# cat /proc/2965230/stack
 [<0>] schedule+0x43/0xd0
 [<0>] io_schedule+0x12/0x40
 [<0>] wait_on_page_bit+0x139/0x230
 [<0>] filemap_write_and_wait+0x5a/0x90
 [<0>] filp_close+0x32/0x70
 [<0>] __x64_sys_close+0x1e/0x50
 [<0>] do_syscall_64+0x4e/0x140
 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

هنا، يكون __x64_sys_close هو متغير x86_64 لاستدعاء نظام close، والذي يعمل على إغلاق واصف الملف. في هذه المرحلة من تنفيذه، ننتظر تحديث مساحة تخزين النسخ الاحتياطي (أي wait_on_page_bit). نظرًا لأن عمل الإدخال/الإخراج عادةً ما يكون أبطأ بعدة مرات من حيث الحجم من العمليات الأخرى، فإن schedule هنا طريقة للتلميح طواعيةً إلى جدولة وحدة المعالجة المركزية في kernel بأننا على وشك تنفيذ عملية بزمن استجابة عالٍ (مثل إدخال/إخراج البيانات في القرص أو الشبكة) وأنه ينبغي التفكير في إيجاد عملية أخرى لجدولتها بدلاً من العملية الموجودة في الوقت الحالي. هذا أمر جيد لأنه يسمح لنا بالإشارة إلى kernel أنه من الجيد المضي قدمًا واختيار عملية تستفيد بالفعل من وحدة المعالجة المركزية بدلاً من إضاعة الوقت في عملية لا يمكن أن تستمر حتى تنتهي من انتظار استجابة من جانب شيء قد يستغرق بعض الوقت.

تخيل أننا نرسل إشارة إلى العملية التي كنا نجريها. والإشارة التي أرسلناها لديها معالج مساحة مستخدم في السلسلة التي يتم تلقيها، لذلك سنستأنف العمل في مساحة المستخدم. تتمثل إحدى الطرق العديدة التي يمكن من خلالها إنهاء هذا التعارض في أن kernel سيحاول الخروج من schedule، ثم استرجاع المكدس وإرجاع خطأ ESYSRESTART أو EINTR في النهاية لمساحة المستخدم للإشارة إلى أنه تمت مقاطعتنا. لكن إلى أي مدى وصلنا إلى الانتهاء من ذلك؟ وما حالة واصف الملف الآن؟

والآن بعد الرجوع إلى مساحة المستخدم، سنشغل معالج الإشارة. عندما يخرج معالج الإشارة، سنرسل الخطأ إلى أداة التضمين close لدى libc في مساحة المستخدم، ثم إلى التطبيق، والذي من الناحية النظرية، يمكنه فعل شيء ما حيال الموقف الذي يواجهنا. ونقول "من الناحية النظرية" حيث إنه من الصعب للغاية معرفة ما يجب القيام به حيال الكثير من هذه المواقف التي تتضمن الإشارات، ولا تعالج الكثير من الخدمات في الإنتاج حالات عناصر الربط هنا بشكل جيد. قد يكون هذا الأمر مقبولاً في بعض التطبيقات حيث لا تكون سلامة البيانات مهمة بهذا الشكل. ومع ذلك، يمثل هذا الأمر في تطبيقات الإنتاج التي تهتم بسلامة البيانات واستمراريتها مشكلة جسيمة: لا يعرض kernel أي طريقة واضحة لتحديد مدى التقدم وما النتائج التي حققتها وما لم تحققه وما يجب علينا فعله بشأن الموقف. والأسوأ من ذلك، إذا كانت الإشارة close ترجع EINTR، فستكون حالة واصف الملف الآن غير محددة:

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

حظًا سعيدًا في محاولة التفكير حول كيفية إدخال ذلك الأمر بسلامة وأمان في تطبيقك. بشكل عام، تُعد معالجة EINTR أمرًا معقدًا حتى بالنسبة إلى syscalls التي تعمل من دون مشاكل. توجد الكثير من المشكلات الخفية التي تشكل جزءًا كبيرًا من سبب عدم كفاية SA_RESTART. ليست كل استدعاءات النظام قابلة لإعادة التشغيل، وتوقع أن يفهم كل مطوّري تطبيقك الفروق الدقيقة في الحصول على إشارة لكل syscall في كل موقع استدعاء، ويحاول تخفيف حدتها، سيؤدي إلى انقطاعات. من man 7 signal:

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

وبالمثل، فإن استخدام sigprocmask وتوقع بقاء دفق الرمز ثابتًا هو أمر يسبب المتاعب لأن المطوّرين لا يقضون وقتًا كبيرًا عادةً في التفكير في حدود معالجة الإشارة أو كيفية إنتاج رمز بإشارة صحيح أو الحفاظ عليه. ينطبق الشيء ذاته على التعامل مع الإشارات في سلسلة مخصصة تتضمن sigwaitinfo، الأمر الذي قد يؤدي في النهاية إلى تعذر تصحيح أخطاء العملية من جانب GDB (مصحح جنو) والأدوات الأخرى المشابهة. يمكن أن تؤدي تدفقات الرموز الخاطئة أو معالجة الأخطاء إلى حدوث أخطاء وأعطال وصعوبة تصحيح حالات الفشل وحالات التوقف التام والعديد من المشاكل الأخرى التي ستجعلك تتجه مباشرةً إلى أداة إدارة الحوادث التي تفضل استخدامها.

التعقيد العالي في البيئات متعددة السلاسل

إذا كنت تعتقد أن كل هذا الحديث عن التزامن وإعادة الإدخال والرقميات كان سيئًا بدرجة كافية، فإن إضافة السلاسل المتعددة إلى هذه المجموعة يجعل الأمور أكثر تعقيدًا. يُعد هذا الأمر مهمًا عند النظر في حقيقة أن العديد من التطبيقات المعقدة تقوم بتشغيل سلاسل منفصلة بشكل ضمني، على سبيل المثال كجزء من jemalloc أو GLib أو ما شابه ذلك. تقوم بعض هذه المكتبات بتثبيت معالجات الإشارات بذاتها، الأمر الذي يفتح المجال لظهور مشكلة أخرى.

بشكل عام، فإن man 7 signal تعرض ما يلي في هذا الشأن:

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

بشكل أكثر إيجازًا، "بالنسبة إلي معظم الإشارات، يرسل kernel الإشارة إلى أي سلسلة لا يتم حظر هذه الإشارة فيها من خلال sigprocmask". تمثل SIGSEGV وSIGILL وما يشبههما تراكبات وتعمل على توجيه الإشارة بشكل واضح نحو السلسلة المخالفة. ومع ذلك، على الرغم مما قد يعتقده الشخص، لا يمكن إرسال معظم الإشارات صراحةً إلى سلسلة واحدة ضمن مجموعة سلاسل، حتى من خلال tgkill أو pthread_kill.

هذا يعني أنه لا يمكنك تغيير إجمالي خصائص معالجة الإشارة بشكل طفيف بمجرد أن تتوفر لديك مجموعة من السلاسل. إذا كانت توجد خدمة تحتاج إلى إجراء حظر دوري للإشارة من خلال sigprocmask في السلسلة الرئيسية، فأنت في حاجة إلى التواصل بطريقة ما مع السلاسل الأخرى خارجيًا لتوضيح كيفية معالجة ذلك. بخلاف ذلك، يمكن استيعاب الإشارة بواسطة سلسلة أخرى، ولن تظهر بعد ذلك. بالطبع، يمكنك حظر الإشارات في السلاسل التابعة لتجنب ذلك، ولكن إذا احتاجت السلاسل إلى معالجة الإشارات لديها، حتى بالنسبة للإشارات البسيطة مثل waitpid، فسينتهي الأمر بتعقيد الأمور أكثر.

تمامًا كما هو الحال مع كل شيء آخر هنا، فإنه يمكن حل هذه المشكلات من الناحية التقنية. ومع ذلك، قد يكون الشخص متهاونًا في تجاهل حقيقة أن التعقيد الذي تفرضه المزامنة المطلوبة لتنفيذ هذا العمل بشكل صحيح تشكل عبئًا ثقيلاً ويسهل من ظهور الأخطاء وحدوث الارتباك والأسوأ من ذلك.

غياب التعريف والافتقار إلى أساليب الإشارة إلى النجاح أو الفشل

يتم إرسال الإشارات بشكل غير متزامن في kernel. يتم إرجاع syscall kill بمجرد تسجيل الإشارة المعلقة بالنسبة للعملية أو سلسلة task_struct المعنية. وبالتالي، ليس هناك ما يضمن التسليم في وقت محدد، حتى إذا لم يتم حظر الإشارة.

وحتى إن كان هناك تسليم في وقت محدد للإشارة، فلا توجد طريقة لإرسال حالة طلب الإجراء مرة أخرى إلى جهة إصدار الإشارة. على هذا النحو، لا ينبغي تسليم أي إجراء معقول عن طريق الإشارات، نظرًا لأنها تنفذ أسلوب التشغيل فقط مع عدم وجود آلية حقيقية للإبلاغ عن نجاح أو فشل التسليم والإجراءات اللاحقة. كما رأينا أعلاه، حتى الإشارات التي تبدو غير ضارة يمكن أن تكون خطيرة عندما لا يتم تكوينها في مساحة المستخدم.

قد يواجه أي شخص يستخدم Linux لفترة كافية بلا شك حالة يريد فيها إنهاء عملية ما ولكنه وجدها لا تستجيب حتى للإشارات المفترض أنها فادحة التأثير دائمًا مثل SIGKILL. تكمن المشكلة في أن الغرض من kill(1) ليس إنهاء العمليات، وهو ما يتم فهمه بشكل خاطئ، ولكن فقط لوضع طلب ما في قائمة الانتظار لدى kernel (مع عدم وجود إشارة حول موعد تقديم الخدمة) يفيد بأن شخصًا ما قد طلب اتخاذ بعض الإجراءات.

يكمن هدف syscall kill في تحديد الإشارة كمعلقة في بيانات تعريف مهمة kernel، وهو ما يقوم به بنجاح حتى عند عدم انتهاء مهمة SIGKILL. في حالة استخدام الإشارة SIGKILL تحديدًا، يضمن kernel عدم تنفيذ المزيد من تعليمات وضع المستخدم، ولكن قد نضطر إلى تنفيذ التعليمات في وضع kernel لإكمال الإجراءات التي قد تؤدي بخلاف ذلك إلى تلف البيانات أو فقدان الموارد. ولهذا السبب، تظل العملية ناجحة حتى إذا كانت الحالة D (حالة سكون غير منقطع). لا تفشل الإشارة Kill بحد ذاتها ما لم يتم إدخال إشارة غير صالحة، حيث إنه لا يتوفر لديك الإذن اللازم لإرسال تلك الإشارة أو أن pid الذي طلبته لإرسال إشارة غير موجود ولذلك لا يُعد مفيدًا لإرسال الحالات غير المرتبطة بمحطات طرفية بشكل موثوق إلى التطبيقات.

خلاصة القول

  • تُعد الإشارات جيدة لحالة المحطة الطرفية التي تتم معالجتها في kernel بالكامل من دون الحاجة إلى معالج مساحة المستخدم. بالنسبة إلى الإشارات التي تريد استخدامها بالفعل لإنهاء البرنامج على الفور، اترك هذه الإشارات لكي يتعامل معها kernel. هذا يعني أيضًا أن kernel قد يكون قادرًا على الخروج مبكرًا من العمل، مما يؤدي إلى تحرير موارد البرنامج بسرعة أكبر، بينما يتعين على طلب التواصل بين العمليات (IPC) لمساحة المستخدم انتظار جزء مساحة المستخدم لبدء التنفيذ مرة أخرى.
  • تتمثل إحدى الطرق لتجنب الوقوع في مشكلة معالجة الإشارات في عدم معالجتها على الإطلاق. ومع ذلك، بالنسبة إلي التطبيقات التي تتعامل مع معالجة الحالة التي يجب أن تفعل شيئًا حيال حالات مثل SIGTERM، من الأفضل استخدام API عالية المستوى مثل folly::AsyncSignalHandler حيث أصبح التعامل مع العديد من أدوات warts أكثر سهول بالفعل.

  • تجنب إرسال طلبات التطبيق من خلال الإشارات. استخدم الإشعارات ذاتية الإدارة (مثل inotify) أو RPC لمساحة المستخدم مع جزء مخصص من مسار التطبيق لمعالجتها بدلاً من التسبب في مقاطعة التطبيق.
  • حيثما أمكن، يمكنك تقييد نطاق الإشارات على قسم فرعي من البرنامج أو السلاسل باستخدام sigprocmask، مما يقلل من مقدار الرمز الذي يلزم فحصه بانتظام للتأكد من صحة الإشارة. ضع في اعتبارك أنه إذا تغيرت تدفقات الرمز أو تغيرت إستراتيجيات السلسلة، فقد لا تخدم المحاكاة التأثير الذي تريده.
  • عند بدء البرنامج الخفي، يمكنك إخفاء إشارات المحطات الطرفية التي لم يتم استيعابها بشكل موحد ويمكن إعادة توجيهها في مرحلة من برنامجك لتجنب العودة إلى سلوك kernel الافتراضي. واقتراحنا هو كما يلي:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

يعتبر سلوك الإشارة معقدًا للغاية من ناحية التفكير فيه، حتى في البرامج ذات الرموز المكتملة، ويشكل استخدامها خطرًا غير ضروري في التطبيقات في حين تتوفر بدائل أخرى. بشكل عام، لا تستخدم إشارات للتواصل مع جزء مساحة المستخدم في برنامجك. وبدلاُ من ذلك، اجعل البرنامج يعالج الأحداث بشفافية (على سبيل المثال، من خلال inotify)، أو استخدم آلية تواصل في مساحة المستخدم يمكنها الإبلاغ عن الأخطاء في جهة الإصدار وتكون معدودة وواضحة في وقت التجميع، مثل Thrift أو gRPC أو ما شابه ذلك.

نأمل أن يكون هذا المقال قد أظهر لك أن الإشارات في حين أنها قد تبدو بسيطة ظاهريًا، هي في الواقع غير ذلك تمامًا. إن جماليات البساطة التي تعزز استخدامها باعتبارها API لبرامج مساحة المستخدم تتناقض مع سلسلة من قرارات التصميم الضمنية التي لا تناسب معظم حالات استخدام الإنتاج في العصر الحديث.

لنكن واضحين: توجد بعض حالات استخدام الإشارات الصالحة. حيث تُعد الإشارات جيدة في عمليات التواصل الأساسية مع kernel حول حالة العملية المطلوبة عندما لا يكون هناك مكون في مساحة مستخدم، على سبيل المثال، عندما يتعين إنهاء العملية. ومع ذلك، من الصعب كتابة رمز يتضمن إشارة صحيحة في المرة الأولى عندما يكون من المتوقع أن تشكل الإشارات عائقًا في مساحة المستخدم.

قد تبدو الإشارات جذابة بسبب توحيد المعايير والتوّفر على نطاق واسع وقلة التبعيات، ولكنها دائمًا ما تكون مصحوبة بعدد كبير من العقبات التي ستزيد من مخاوفك بالتزامن مع نمو مشروعك. نأمل أن تكون هذه المقالة قد زودتك ببعض إجراءات التخفيف والإستراتيجيات البديلة التي ستتيح لك الاستمرار في تحقيق أهدافك، ولكن بطريقة أكثر أمانًا وأقل تعقيدًا وأكثر سهولة.

للتعرف على المزيد حول Meta Open Source، تفضل بزيارة الموقع مفتوح المصدر أو اشترك في قناتنا على يوتيوب، أو تابعنا على تويتر وفيسبوك ولينكدإن.