الأدلة التقنية

Therac-25: حين أصبح خطأ برمجي خطراً على المرضى

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

Therac-25: حين أصبح خطأ برمجي خطراً على المرضى

بين يونيو 1985 ويناير 1987 تعرض ستة مرضى لجرعات إشعاع أكبر بكثير مما وصفه أطباؤهم، وكانوا يتلقون علاجاً إشعاعياً للسرطان على جهاز اسمه Therac-25 من شركة Atomic Energy of Canada Limited واختصاراً AECL، وتوفي بعضهم متأثراً بهذه الجرعات. وفي كل مرة كان الرد الأول أن الجهاز لا يمكن أن يعطي جرعة زائدة، إلى أن جلس فيزيائي في مستشفى بمدينة Tyler في تكساس مع المشغلة التي كانت تكتب على لوحة المفاتيح، وكرر ما فعلته بالضبط، فاكتشف أن الخطأ لا يظهر إلا إذا عدلت البيانات بسرعة، أي بالسرعة التي يكتب بها شخص اعتاد على الشاشة.

والقصة تذكر في كل مقرر عن هندسة البرمجيات والأنظمة الحرجة Safety-critical Systems، والمرجع الأساسي لها هو بحث Nancy Leveson وClark Turner بعنوان «An Investigation of the Therac-25 Accidents» المنشور في مجلة IEEE Computer في يوليو 1993، وقد اعتمدنا عليه في كل تفاصيل هذا المقال من نسخته المنشورة على موقع مقرر في جامعة Stanford. ولاحظ أن الأخطاء البرمجية في هذه القصة ليست غريبة علينا، فهي حالة سباق Race Condition بين مهمتين تشتركان في متغير، وعداد من بايت واحد يتجاوز حده، ورسالة خطأ لا تقول شيئاً، وهذه أخطاء نكتبها كلنا في أنظمة أقل خطورة.

وسوف نناقش في هذا المقال ما يلي:

  • الجهاز وكيف كان يعمل، ولماذا كان البرنامج فيه مسؤولاً عن الأمان أكثر من الأجهزة التي سبقته.
  • الحوادث الست بالترتيب، وكيف تعاملت معها الشركة والجهات الرقابية.
  • الخطأ الأول في حادثتي Tyler: سباق بين مهمة إدخال البيانات ومهمة ضبط المغناطيسات.
  • الخطأ الثاني في حادثة Yakima الثانية: المتغير Class3 من بايت واحد الذي يعود إلى الصفر فيتخطى فحص الأمان.
  • رسائل الخطأ وواجهة المشغل، والحماية في العتاد Hardware Interlocks التي أزيلت.
  • محاكاة صغيرة بلغة C تعيد إنتاج آلية الخطأين، مع التنبيه أنها محاكاة تعليمية لا علاقة لها بأي جهاز طبي.
  • الدروس للأنظمة الحرجة ولكل مطور.

الصورة التالية تبين الحوادث والقرارات الرئيسية بالترتيب:

خط زمني من اليسار إلى اليمين: 3 يونيو 1985 Kennestone في جورجيا، 26 يوليو 1985 Hamilton في كندا ونسبت إلى مفتاح Microswitch، ديسمبر 1985 Yakima الأولى وسببها غير معروف، 21 مارس 1986 Tyler الأولى مع رسالة Malfunction 54، 11 أبريل 1986 Tyler الثانية والفيزيائي يعيد إنتاج الخطأ، 2 مايو 1986 FDA تعلن أن الجهاز معيب، 17 يناير 1987 Yakima الثانية بسبب العداد Class3، 10 فبراير 1987 إشعار FDA، 21 يوليو 1987 الخطة النهائية مع حماية في العتاد
ست حوادث بين يونيو 1985 ويناير 1987، والسبب البرمجي لم يعرف إلا في الحادثتين الأخيرتين من كل نوع

ما هو Therac-25، ولماذا كان البرنامج مسؤولاً عن الأمان؟

جهاز Therac-25 مسرع خطي طبي Medical Linear Accelerator يعالج الأورام بحزمة من الإلكترونات، أو بالأشعة السينية X-ray التي تنتج عندما تصطدم حزمة إلكترونات عالية الطاقة بهدف Target معدني. وفي وضع الأشعة السينية يجب أن يكون في مسار الحزمة الهدف ومرشح التسطيح Flattening Filter وغرفة قياس الأشعة Ion Chamber، وفي وضع الإلكترونات تنشر مغناطيسات المسح Scanning Magnets الحزمة لتصبح بتركيز علاجي آمن، لذلك فإن عمل الجهاز بشكل صحيح يعتمد كلياً على أن يكون كل جزء في مكانه المناسب للوضع المختار. وكل هذه الأجزاء مركبة على قرص دوار Turntable يحركه الجهاز إلى أحد ثلاثة مواضع: الإلكترونات، أو الأشعة السينية، أو موضع ضوء التوجيه Field Light الذي يستخدمه المشغل لتوجيه الجهاز على جسم المريض.

وبحسب بحث Leveson وTurner فإن الجهازين السابقين Therac-6 وTherac-20 كانا نسخاً من أجهزة قديمة لشركة CGR الفرنسية تعمل وحدها، ثم أضيف لها حاسب صغير من نوع DEC PDP-11 لتسهيل الاستخدام، وبقيت فيها كل وسائل الحماية في العتاد كما هي، أي أن الحاسب كان راحة إضافية فوق جهاز آمن بدونه. أما Therac-25 فقد صممته AECL حول الحاسب من البداية، وقررت ألا تكرر كل وسائل الحماية في العتاد، واعتمدت بدلاً منها على فحوص في البرنامج.

حاسب صغير بلون بيج مكتوب عليه digital PDP 11/23 PLUS وعلى واجهته مفاتيح RESTART وHALT ومصباحان أحمران
حاسب من عائلة DEC PDP-11/23، وهي العائلة التي كان برنامج Therac-25 يعمل عليها بذاكرة 32K، والصورة لطراز 23 PLUS وليست من جهاز Therac. الصورة: Dave Ruske، بترخيص CC BY 2.0، المصدر: Wikimedia Commons (تم قصها وتصغيرها وتحويلها إلى WebP).

والبرنامج نفسه كما يصفه البحث كتبه شخص واحد بلغة Assembly للحاسب PDP-11 على مدى عدة سنوات، وهو نظام تشغيل خاص للوقت الحقيقي Real-time كتب لهذا الجهاز، فيه مجدول Scheduler يوزع وقت المعالج على مهام Tasks تعمل بالتوازي وتتواصل عبر متغيرات مشتركة Shared Variables بدون أي آلية قفل Lock، وهذه هي البيئة التي تولد حالات السباق كما سوف نرى. ثم إن البرنامج بني على كود Therac-6، واستعار أجزاء من كود Therac-20 لوضع الإلكترونات، ولم يكن مدير الجودة في الشركة يعرف أن أجزاء من Therac-20 أعيد استخدامها، إلى أن ظهر خطأ مرتبط بإحدى الحوادث في برنامج Therac-20 نفسه. ولاحظ أيضاً أن تحليل الأمان الذي أجرته الشركة في مارس 1983 على شكل شجرة أعطال Fault Tree افترض أن أخطاء البرمجة قلت بالاختبار الطويل، ولم يدخلها في التحليل، وأعطى أحداثاً مثل «الحاسب يختار الطاقة الخاطئة» احتمالات صغيرة جداً بدون أي تبرير.

والصورة التالية تبين الفرق بين الجهازين في مكان الحماية:

عمودان: على اليمين Therac-20 والبرنامج فيه نفس خطأ Tyler ولكن دوائر حماية مستقلة وأقفال ميكانيكية تمنع الحزمة فتحترق الفيوزات فقط، وعلى اليسار Therac-25 بنفس البرنامج تقريباً ولا توجد حماية مستقلة فتعمل الحزمة بإعدادات خاطئة، وتحتهما ما أضافته خطة التصحيح في يوليو 1987: دائرة توقف الحزمة بعد نبضة عالية، ومقياس Potentiometer وقفل لموضع القرص، وإيقاف كامل بدل الإيقاف المؤقت
نفس الخطأ البرمجي كان مجرد إزعاج في Therac-20، لأن العتاد كان يوقف الحزمة دون أن يسأل البرنامج

وهذا ليس افتراضاً منا، فالبحث يذكر أن فيزيائياً في جامعة Chicago اسمه Frank Borger لاحظ بعد حوادث Tyler أن جهاز Therac-20 عندهم تحترق فيوزاته Fuses كلما بدأت دفعة جديدة من الطلاب تتعلم عليه، لأن الطلاب الجدد يعدلون البيانات بطرق غير مألوفة، واكتشف أن نفس الخطأ البرمجي هو السبب، ولكن الحماية في العتاد كانت تمنع الحزمة من العمل، فلم يصب أحد.

واجهة المشغل: Malfunction 54 وزر P

يعمل المشغل على شاشة طرفية DEC VT100 خارج غرفة العلاج، فيدخل بيانات المريض والوصفة: الوضع Mode (الحرف x للأشعة السينية أو e للإلكترونات)، والطاقة، والجرعة، والزمن، ثم يقارن البرنامج القيم التي ضبطت يدوياً في الغرفة مع القيم المدخلة، فإذا تطابقت ظهرت كلمة VERIFIED. وعندما اشتكى المشغلون في البداية أن إدخال البيانات يأخذ وقتاً طويلاً، عدلت الشركة البرنامج قبل تركيب أول جهاز بحيث يستطيع المشغل أن ينسخ القيم بالضغط على زر Return عدة مرات بدل كتابتها، وهذا التعديل كما يقول البحث كان له دور في عدة حوادث.

شاشة طرفية DEC VT100 بلون بيج مع لوحة مفاتيح سوداء، وعلى الشاشة قائمة ملفات بخط أخضر فاتح
شاشة طرفية DEC VT100 من نفس النوع الذي كان المشغل يدخل عليه بيانات العلاج، والصورة من متحف Living Computer Museum وليست من جهاز Therac. الصورة: Jason Scott، بترخيص CC BY 2.0، المصدر: Wikimedia Commons (تم قصها وتصغيرها وتحويلها إلى WebP).

وعند حدوث خطأ كان الجهاز يتوقف بإحدى طريقتين: الإيقاف الكامل Treatment Suspend الذي يحتاج إعادة ضبط الجهاز كله، أو الإيقاف المؤقت Treatment Pause الذي يكفي فيه أن يضغط المشغل الحرف P (Proceed) ليكمل العلاج بنفس الإعدادات، ويسمح بذلك حتى خمس مرات. والرسائل نفسها كانت كلمة MALFUNCTION ورقماً بين 1 و64، وبحسب البحث كان الجهاز يتوقف أو يؤخر العلاج كثيراً، ولم تكن لذلك عواقب سابقة غير الإزعاج، فاعتاد المشغلون على الضغط على P. ولم يكن في دليل الجهاز ما يشرح معنى Malfunction 54، فالورقة الملصقة على الجهاز تقول إنها «dose input 2»، وقد شهد فني من الشركة لاحقاً أن معناها أن الجرعة المعطاة كانت إما أعلى أو أقل من المطلوب.

وقد يتساءل البعض: لماذا لم يتوقف المشغل عند أول رسالة خطأ؟ والإجابة أن الواجهة علمته العكس، فالرسالة لا تقول ما المشكلة، والجهاز يعرض الجرعة كأنها أقل من المطلوب، والإيقاف «مؤقت» أي غير خطير بحسب تصميم الواجهة نفسها، والضغط على P كان الإجراء المعتاد مئات المرات من قبل دون أي ضرر. والأسوأ أن غرف القياس Ion Chambers كما يذكر البحث لم تكن تتحمل الحزمة المركزة غير الممسوحة، فكانت تتشبع Saturated وتعرض جرعة منخفضة، أي أن الشاشة كانت تقول للمشغل عكس ما حدث تماماً. لذلك لا تلم المستخدم على تجاهل رسالة خطأ صممتها أنت بحيث تتجاهل.

الحوادث الست بالترتيب

كان هناك أحد عشر جهازاً Therac-25 مركبة، خمسة في الولايات المتحدة وستة في كندا، وفيما يلي الحوادث كما يرويها البحث باختصار، ونترك التفاصيل الطبية لمصدرها:

التاريخالمكانما حدثرد الشركة وقتها
3 يونيو 1985Kennestone Regional Oncology Center، جورجيامريضة شعرت بحرارة شديدة أثناء العلاج، وأصيبت بحروق إشعاعية خطيرة، وقدر فيزيائي المركز لاحقاً أنها تلقت جرعة أو جرعتين بين 15,000 و20,000 راد rad، بينما الجرعة العلاجية المعتادة في حدود 200 راد.مهندسو الشركة قالوا إن الخطأ في مسح الحزمة غير ممكن، ولم تبلغ الحادثة لجهة رقابية وقتها.
26 يوليو 1985Ontario Cancer Foundation، Hamilton، كنداالجهاز توقف خمس مرات مع رسالة «لا توجد جرعة»، والمشغلة ضغطت P أربع مرات، وقدر فني من الشركة لاحقاً أن المريضة تلقت بين 13,000 و17,000 راد.لم تستطع الشركة إعادة إنتاج الخطأ، فنسبته إلى عطل محتمل في مفتاح Microswitch يحدد موضع القرص، وعدلته وأعلنت أن الأمان تحسن كثيراً.
ديسمبر 1985Yakima Valley Memorial Hospital، واشنطنمريضة ظهر على جلدها احمرار بنمط خطوط متوازية.الشركة ردت كتابياً بأن الضرر لا يمكن أن يكون من الجهاز، وسجل المستشفى السبب «غير معروف».
21 مارس 1986East Texas Cancer Center، Tyler، تكساسرسالة Malfunction 54 مرتين، والمريض شعر بما يشبه الصدمة الكهربائية، وتوفي بعد خمسة أشهر من مضاعفات الجرعة.الشركة قالت إن الجرعة الزائدة غير ممكنة، وإنها لا تعرف أي حوادث سابقة، واقترحت أن السبب مشكلة كهربائية في المستشفى.
11 أبريل 1986East Texas Cancer Center، Tylerنفس المشغلة ونفس الخطوات ونفس الرسالة، وتوفي المريض في 1 مايو 1986.فيزيائي المستشفى Fritz Hager أعاد إنتاج الخطأ مع المشغلة، ثم أعادته الشركة بعد أن شرح لها أن الإدخال يجب أن يكون سريعاً.
17 يناير 1987Yakima Valley Memorial Hospitalخطأ برمجي مختلف، والمريض توفي في أبريل من مضاعفات مرتبطة بالجرعة، وكان مصاباً بسرطان في مرحلة متأخرة.الشركة وجدت الخطأ في Class3 بعد أسبوع من فحص العتاد.

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

الخطأ الأول: سباق بين إدخال البيانات وضبط المغناطيسات

في حادثتي Tyler كتبت المشغلة الحرف x للأشعة السينية بالخطأ، لأن معظم العلاجات كانت بالأشعة السينية فاعتادت على كتابته، ثم لاحظت الخطأ فرجعت بالمؤشر إلى أعلى وغيرته إلى e للإلكترونات، ثم ضغطت Return عدة مرات لتصل إلى سطر الأوامر، وكل ذلك في ثوان معدودة. وبحسب الوصف الذي قدمته الشركة لإدارة الغذاء والدواء الأمريكية FDA ونقله البحث، فإن الكود كان كما يلي:

  • مهمة العلاج Treat تنفذ عدة إجراءات فرعية Subroutines، منها Datent لإدخال البيانات، وتتواصل مع مهمة لوحة المفاتيح Keyboard Handler التي تعمل بالتوازي عبر متغير مشترك يقول إن الإدخال اكتمل.
  • مهمة لوحة المفاتيح تكتب الوضع والطاقة في متغير مشترك من بايتين اسمه MEOS، البايت العالي تستخدمه Datent لاختيار معاملات الحزمة من جداول المعايرة، والبايت المنخفض تستخدمه مهمة أخرى اسمها Hand لتحريك القرص الدوار.
  • بعد أن تضبط Datent المعاملات تستدعي Magnet لضبط المغناطيسات التي توجه الحزمة، وهذا يأخذ حوالي 8 ثوان، وفي أثنائه تستدعي Ptime عدة مرات للانتظار.
  • Ptime تفحص هل هناك تعديل من المشغل، ولكن فقط إذا كان علم Flag ضبط المغناطيسات موجوداً، والمشكلة أن Ptime نفسها تمسح هذا العلم في نهاية أول تنفيذ لها، فكل تعديل يحدث بعد ذلك لا يراه أحد.
  • بعد انتهاء Magnet تفحص Datent متغير اكتمال الإدخال، وهو يقول فقط إن المؤشر وصل إلى سطر الأوامر مرة واحدة، وليس أنه ما زال هناك، فتنتقل إلى مرحلة الفحص Set-Up Test ولا تعود.

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

ثلاثة مسارات زمنية: المشغل يكتب X ثم يرجع ويغيرها إلى E قبل الثانية الثامنة، والمتغير MEOS يتغير من X إلى E، ومهمة Treat فيها Datent يقرأ X ثم Ptime الأول يرى التعديلات حتى الثانية الثانية ثم Ptime الباقية لا ترى شيئاً لأن العلم ممسوح ثم Set-Up Test، ومهمة Hand تحرك القرص إلى موضع E، والنتيجة معاملات من X والقرص في موضع E
ثلاث مهام تقرأ وتكتب نفس المتغيرات بدون تنسيق، وكل واحدة منها صحيحة لو كانت وحدها

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

الخطأ الثاني: عداد من بايت واحد يتخطى الفحص

في 17 يناير 1987 في Yakima كان المريض سيتلقى تعريضين صغيرين للتحقق بفيلم Film Verification ثم علاجاً بالأشعة السينية، والمشغل بعد التعريض الأول دخل الغرفة واستخدم ضوء التوجيه Field Light لضبط المريض، أي أن القرص كان في موضع ضوء التوجيه، ثم ضغط زر set ليرجع القرص إلى موضع العلاج. وهنا كان الخطأ مختلفاً تماماً عن Tyler، وقد وصفه البحث كما يلي:

  • بعد إدخال البيانات تنتقل المهمة إلى Set-Up Test، وهي تعيد جدولة نفسها مئات المرات وهي تنتظر جاهزية الجهاز، وفي كل مرور تزيد متغيراً مشتركاً اسمه Class3 بواحد.
  • مهمة أخرى اسمها Housekeeper تفحص Class3، فإذا كان غير صفر تستدعي Chkcol التي تتحقق من موضع الجزء العلوي Upper Collimator، وإذا كان الموضع خاطئاً تضع البت رقم 9 في متغير الأعطال F$mal فلا يتقدم العلاج.
  • إذا كان Class3 صفراً فلا يستدعى Chkcol أصلاً، لأن الصفر يعني بحسب التصميم أنه لا يوجد ما يحتاج فحصاً.
  • والمتغير Class3 بايت واحد، أي أن أكبر قيمة له 255، فعند المرور رقم 256 يعود إلى الصفر، فيتخطى البرنامج فحص الموضع.

وحدثت الجرعة الزائدة عندما ضغط المشغل زر set في اللحظة التي عاد فيها Class3 إلى الصفر، فلم يفحص البرنامج موضع القرص، وكان ما زال في موضع ضوء التوجيه، فشغل البرنامج الحزمة بطاقة 25 MeV دون الهدف ودون مسح الحزمة، وقدرت الشركة الجرعة في هذه الظروف بين 4,000 و5,000 راد في كل محاولة، بينما الجرعة الموصوفة كلها 86 راد. والصورة التالية تبين الآلية:

أربع بطاقات للمرور 254 و255 و256 و257 مع بتات البايت: 254 و255 يفحص الموضع والبت 9 في F$mal يساوي 1، والمرور 256 القيمة صفر فلا يستدعي Chkcol وF$mal يساوي صفراً، و257 القيمة 1 فيفحص، وتحتها المنطق الأصلي Class3 = Class3 + 1 والإصلاح Class3 = NONZERO
المتغير كان يستخدم كقيمة منطقية «هل يجب الفحص؟»، ولكنه كتب كعداد يزيد، فصار يعود إلى «لا» مرة كل 256 مرور

والإصلاح الذي أعلنته الشركة بسيط إلى درجة مؤلمة: أن يضع البرنامج في Class3 قيمة ثابتة غير صفرية في كل مرور بدل أن يزيده. والدرس هنا أعمق من حجم المتغير، فلو كان Class3 من 16 بتاً لعاد إلى الصفر بعد 65,536 مرور بدل 256، أي أن الخطأ يصبح أندر وأصعب في الاكتشاف وليس أقل خطورة. وقد شرحنا الفيضان Overflow وكيف يعود العدد إلى الصفر في مقال الأنظمة العددية، ومعه قصة عداد Boeing 787 الذي يفيض بعد 248 يوماً، وتحويل الأرقام الذي أسقط Ariane 5 في قسم الحوادث في مقال Floating Point.

كيف استجابت الشركة والجهات الرقابية؟

لم يكن على إدارة الغذاء والدواء الأمريكية FDA وقتها أن توافق على كل جهاز طبي قبل بيعه، وإنما كان Therac-25 يحتاج فقط إشعاراً قبل التسويق Pre-market Notification يثبت أنه يشبه أجهزة موجودة في الأمان والفعالية، وكانت قواعد الإبلاغ عن الحوادث تلزم المصنعين فقط وليس المستشفيات، وهذا يفسر لماذا لم يعرف المستخدمون بحوادث بعضهم إلا من أحاديث شخصية بينهم. وبعد حوادث Tyler أعلنت FDA في 2 مايو 1986 أن الجهاز معيب، وطلبت خطة تصحيح Corrective Action Plan واختصاراً CAP، ومرت الخطة بعدة نسخ، وبعد حادثة Yakima الثانية أرسلت FDA في 10 فبراير 1987 إشعاراً بأن الجهاز معيب بموجب القانون الأمريكي، وقد نقل البحث من مذكرة داخلية في FDA جملة تلخص القصة كلها: أن الهيئة غير مقتنعة بعدم وجود أخطاء برمجية أخرى قد تسبب إصابات خطيرة، بعد كل ما حدث من قبل.

وفي 21 يوليو 1987 أرسلت الشركة النسخة الخامسة والنهائية من الخطة، وكان أهم ما فيها كما في البحث:

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

التجربة العملية: نعيد إنتاج الآليتين في محاكاة

⚠️
البرنامجان التاليان محاكاة تعليمية لآلية الخطأين كما وصفها البحث، ولا علاقة لهما بأي جهاز طبي، والأسماء فيهما مثل class3 وmeos وturntable مأخوذة من الوصف لتسهيل المقارنة فقط، والأرقام والأزمنة فيهما توضيحية.

سوف نترجم البرنامجين ونشغلهما في Container من ال Image gcc:14.2.0 باسم lab-disaster-therac، وكل ما تحتاجه هو ملفا الكود في مجلد واحد.

محاكاة العداد Class3

الملف class3.c يحاكي 1000 مرور على Set-Up Test والجزء العلوي في الموضع الخاطئ طوال الوقت، أي أن الفحص الصحيح يجب أن يمنع التقدم في كل مرور، ويقارن بين الزيادة والإسناد:

/* محاكاة تعليمية فقط، لا علاقة لها بأي جهاز طبي حقيقي.
   تحاكي المتغير Class3 كما وصفه تقرير Leveson وTurner: بايت واحد يزيد بواحد في كل مرور على Set-Up Test. */
#include <stdio.h>
#include <stdint.h>

#define PASSES 1000
static int collimator_in_place = 0;   /* الجزء العلوي ما زال في وضع Field Light طوال المحاكاة */

/* Chkcol: يضع البت 9 في F$mal إذا كان الموضع غير صحيح */
static uint16_t chkcol(void) { return collimator_in_place ? 0 : (1u << 9); }

static void run(const char *name, int fixed) {
    uint8_t class3 = 0;                    /* بايت واحد: من 0 إلى 255 */
    int skipped = 0, first[4], n = 0;
    for (int pass = 1; pass <= PASSES; pass++) {
        if (fixed) class3 = 1;             /* الإصلاح: قيمة ثابتة غير صفرية */
        else       class3++;               /* الخطأ: زيادة بدلاً من إسناد */
        uint16_t fmal = 0;                 /* تبسيط: تحسب F$mal من جديد في كل مرور */
        if (class3 != 0) fmal |= chkcol(); /* Lmtchk لا يستدعي Chkcol إذا كان Class3 صفراً */
        if (!fixed && pass >= 254 && pass <= 258)
            printf("  pass %d: class3=%3u chkcol=%s fmal=0x%03x\n", pass, class3, class3 ? "run " : "SKIP", fmal);
        if (fmal == 0) {                   /* Set-Up Test: لا يوجد عطل، يسمح بالمتابعة */
            skipped++;
            if (n < 4) first[n++] = pass;
        }
    }
    printf("%-8s passes=%d  allowed_with_collimator_out=%d", name, PASSES, skipped);
    for (int i = 0; i < n; i++) printf("%s%d", i ? "," : "  at pass ", first[i]);
    printf("\n");
}

int main(void) {
    uint8_t b = 255; b++;
    printf("uint8_t 255 + 1 = %u\n", b);
    run("buggy", 0);
    run("fixed", 1);
    return 0;
}
docker run --rm --name lab-disaster-therac -v "$PWD:/lab" -w /lab gcc:14.2.0 sh -c "gcc -O0 -Wall -o class3 class3.c && ./class3"

والمخرج سوف يكون كما يلي:

uint8_t 255 + 1 = 0
  pass 254: class3=254 chkcol=run  fmal=0x200
  pass 255: class3=255 chkcol=run  fmal=0x200
  pass 256: class3=  0 chkcol=SKIP fmal=0x000
  pass 257: class3=  1 chkcol=run  fmal=0x200
  pass 258: class3=  2 chkcol=run  fmal=0x200
buggy    passes=1000  allowed_with_collimator_out=3  at pass 256,512,768
fixed    passes=1000  allowed_with_collimator_out=0

في المخرج أعلاه لاحظ التالي:

  • السطر الأول يثبت السلوك الأساسي: uint8_t قيمته 255 إذا زدته واحداً صار صفراً بدون أي خطأ أو تحذير، وهذا سلوك معرف في C للأنواع بدون إشارة Unsigned.
  • في المرور 256 صارت القيمة صفراً، فلم تستدع chkcol، وصار fmal=0x000، أي أن البرنامج يرى أنه لا يوجد عطل رغم أن الموضع خاطئ.
  • النسخة الخاطئة سمحت بالتقدم 3 مرات في 1000 مرور، في المرور 256 و512 و768 بالضبط، والنسخة المصلحة لم تسمح ولا مرة.
  • لاحظ أن 997 مروراً من 1000 كانت صحيحة، ولو كان اختبارك يضغط الزر مرة أو مرتين في كل تجربة فاحتمال أن تصادف اللحظة الخاطئة صغير جداً، وهذا ما جعل الخطأ يختفي في اختبارات الشركة.

محاكاة السباق في إدخال البيانات

الملف race.c يشغل خيطين Threads: خيط المشغل الذي يغير الوضع من X إلى E بعد عدد من الثواني، والخيط الرئيسي الذي يلعب دور Datent وMagnet وPtime، والثانية في المحاكاة تساوي 100 ميلي ثانية حتى لا تنتظر طويلاً، ونشغل كل نسخة مرتين بتوقيتين مختلفين للتعديل:

/* محاكاة تعليمية فقط، لا علاقة لها بأي جهاز طبي حقيقي.
   تحاكي سباق إدخال البيانات في حوادث Tyler كما وصفه تقرير Leveson وTurner:
   Datent يقرأ النمط مرة واحدة، ثم Magnet يضبط المغناطيسات 8 ثوان، وPtime لا يرى التعديل إلا في مروره الأول. */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

#define SEC 100000                         /* ثانية محاكاة = 100 ميلي ثانية */
enum { XRAY = 'X', ELECTRON = 'E' };

static volatile int meos = XRAY;           /* المتغير المشترك: النمط الذي كتبه المشغل */
static volatile int editing = 0;           /* يضعه معالج لوحة المفاتيح عند وجود تعديل */
static volatile int entry_complete = 0;    /* وصل المؤشر إلى سطر الأوامر مرة واحدة على الأقل */
static volatile int turntable = XRAY;      /* المهمة Hand تحرك القرص حسب meos مباشرة */
static int edit_at;                        /* متى يعدل المشغل النمط (بثواني المحاكاة) */
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static double now0;
static double t(void) { struct timespec s; clock_gettime(CLOCK_MONOTONIC, &s); return (s.tv_sec + s.tv_nsec / 1e9 - now0) * 10; }

static void *operator_thread(void *arg) {
    (void)arg;
    entry_complete = 1;                    /* كتب X ونزل إلى سطر الأوامر */
    usleep(edit_at * SEC);
    editing = 1;                           /* رجع بالمؤشر وغير النمط إلى E */
    pthread_mutex_lock(&lock); meos = ELECTRON; pthread_mutex_unlock(&lock);
    turntable = ELECTRON;                  /* Hand يحرك القرص فوراً */
    usleep(SEC / 2);
    editing = 0;                           /* رجع إلى سطر الأوامر */
    printf("  t=%4.1fs operator: mode edited X -> E\n", t());
    return NULL;
}

/* Magnet + Ptime: أربعة مغناطيسات، كل واحد ثانيتان. يرجع 1 إذا رأى تعديلاً */
static int magnet(int fixed) {
    int magnet_flag = 1;                   /* يوضع عند الدخول إلى Magnet */
    for (int m = 0; m < 4; m++) {
        for (int i = 0; i < 20; i++) {     /* Ptime: انتظار ثانيتين */
            if (magnet_flag && editing) return 1;
            usleep(SEC / 10);
        }
        if (!fixed) magnet_flag = 0;       /* الخطأ: يمسح في نهاية Ptime الأول */
    }
    return 0;                              /* الإصلاح: يمسح في نهاية Magnet فقط */
}

static void run(const char *name, int fixed, int edit_time) {
    meos = XRAY; editing = 0; entry_complete = 0; turntable = XRAY; edit_at = edit_time;
    struct timespec s; clock_gettime(CLOCK_MONOTONIC, &s); now0 = s.tv_sec + s.tv_nsec / 1e9;
    printf("%s, edit after %ds:\n", name, edit_time);
    pthread_t op; pthread_create(&op, NULL, operator_thread, NULL);
    int params;
    for (;;) {                             /* Datent */
        params = meos;                     /* يقرأ النمط ويضبط المعاملات */
        printf("  t=%4.1fs datent: beam parameters set for %c, setting magnets\n", t(), params);
        if (magnet(fixed)) { while (editing) usleep(SEC / 10); continue; }
        if (!fixed) break;                 /* الخطأ: entry_complete يكفي للمتابعة */
        pthread_mutex_lock(&lock);         /* الإصلاح: تحقق أخير من أن ما ضبطناه هو ما على الشاشة */
        int same = (params == meos) && !editing;
        pthread_mutex_unlock(&lock);
        if (same) break;
        printf("  t=%4.1fs datent: mode changed under us, recalculating\n", t());
    }
    pthread_join(op, NULL);
    printf("  t=%4.1fs set-up test: parameters=%c turntable=%c -> %s\n\n", t(), params, turntable,
           params == turntable ? "consistent" : "INCONSISTENT (beam for X with turntable at E)");
}

int main(void) {
    run("buggy", 0, 1);
    run("buggy", 0, 3);
    run("fixed", 1, 3);
    run("fixed", 1, 7);
    return 0;
}
docker run --rm --name lab-disaster-therac -v "$PWD:/lab" -w /lab gcc:14.2.0 sh -c "gcc -O0 -Wall -pthread -o race race.c && ./race"

والمخرج سوف يكون كما يلي:

buggy, edit after 1s:
  t= 0.0s datent: beam parameters set for X, setting magnets
  t= 1.5s operator: mode edited X -> E
  t= 1.5s datent: beam parameters set for E, setting magnets
  t= 9.6s set-up test: parameters=E turntable=E -> consistent

buggy, edit after 3s:
  t= 0.0s datent: beam parameters set for X, setting magnets
  t= 3.5s operator: mode edited X -> E
  t= 8.2s set-up test: parameters=X turntable=E -> INCONSISTENT (beam for X with turntable at E)

fixed, edit after 3s:
  t= 0.0s datent: beam parameters set for X, setting magnets
  t= 3.5s operator: mode edited X -> E
  t= 3.5s datent: beam parameters set for E, setting magnets
  t=11.8s set-up test: parameters=E turntable=E -> consistent

fixed, edit after 7s:
  t= 0.0s datent: beam parameters set for X, setting magnets
  t= 7.5s operator: mode edited X -> E
  t= 7.5s datent: beam parameters set for E, setting magnets
  t=16.1s set-up test: parameters=E turntable=E -> consistent

في المخرج أعلاه لاحظ التالي:

  • في النسخة الخاطئة مع تعديل بعد ثانية واحدة رأى البرنامج التعديل، لأنه حدث أثناء أول Ptime والعلم ما زال موجوداً، فأعاد ضبط المعاملات على E، والنتيجة متسقة. وهذا يعني أن المشغل البطيء أو السريع جداً لا يرى الخطأ.
  • مع تعديل بعد 3 ثوان، أي بعد أول Ptime وقبل نهاية الثواني الثماني، لم ير البرنامج التعديل، فانتقل إلى set-up test بمعاملات X والقرص في موضع E، وهو نفس التناقض الذي وصفه البحث.
  • النسخة المصلحة رأت التعديل بعد 3 ثوان وبعد 7 ثوان، لأنها لا تمسح العلم إلا في نهاية magnet، ثم تتحقق في النهاية تحت القفل lock من أن ما ضبطته هو ما على الشاشة.
  • لاحظ أن الخطأ يعتمد على نافذة زمنية بين الثانية الثانية والثامنة، وهذا ما جعل فيزيائي Tyler يحتاج تدريباً ليكرر الإدخال بالسرعة الكافية، والاختبار اليدوي الهادئ لا يدخل هذه النافذة أبداً.

وفي النهاية نحذف ال Image إذا لم تكن تحتاجها، علماً أن ال Container حذف تلقائياً بسبب الخيار --rm:

docker image rm gcc:14.2.0

ماذا نتعلم كمهندسين؟

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

  • لا تجعل البرنامج نقطة الفشل الوحيدة Single Point of Failure: إذا كان الخطأ يمكن أن يؤذي إنساناً أو يمسح بيانات أو يرسل أموالاً، فاجعل هناك فحصاً مستقلاً لا يعتمد على نفس الكود، كما كانت دوائر الحماية في Therac-20 تمنع الحزمة دون أن تسأل البرنامج. وفي عملنا اليومي هذا يعني قيوداً في قاعدة البيانات Constraints بجانب التحقق في التطبيق، وحدوداً في حساب السحابة بجانب الحدود في الكود.
  • المتغيرات المشتركة بين المهام تحتاج تنسيقاً صريحاً: كل مهمة في Therac-25 كانت صحيحة وحدها، والخطأ كان في التوقيت بينها، لذلك استخدم الأقفال أو الرسائل بين المهام، وتحقق من الحالة قبل الفعل الخطير مباشرة وليس قبله بثوان، كما شرحنا في قسم الأقفال في مقال الخيوط.
  • القيمة المنطقية لا تكون عداداً: إذا كان المتغير يعني «نعم أو لا» فاجعله bool وأسند له القيمة، ولا تزده، وإذا كان عداداً فاعرف حده الأعلى وماذا يحدث بعده.
  • رسالة الخطأ جزء من الأمان: رسالة مثل Malfunction 54 بدون شرح، مع زر يكمل بنفس الإعدادات، تدرب المستخدم على التجاهل، فاكتب رسالة تقول ماذا حدث وماذا يجب أن يفعل، ولا تجعل الاستمرار بعد خطأ خطير بضغطة زر.
  • «لم أستطع إعادة إنتاجه» ليس دليلاً: الشركة لم تستطع إعادة إنتاج الخطأ عدة مرات فاستنتجت أنه مستحيل، والحل كان الجلوس مع المستخدم الذي رآه وتكرار خطواته بنفس السرعة، كما في دليل استكشاف الأخطاء.
  • الكود المعاد استخدامه يحمل افتراضات بيئته القديمة: الكود الذي جاء من Therac-6 وTherac-20 كان يعمل فوق حماية في العتاد، وعندما أزيلت ظهرت أخطاؤه، وهذا نفس ما حدث مع برنامج Ariane 4 في Ariane 5.
  • اختبر ما يفعله المستخدم الحقيقي: المشغل المتمرس يكتب بسرعة ويستخدم الاختصارات، والطالب الجديد يعدل بطرق غير متوقعة، وكلاهما كشف الخطأ، لذلك اجعل في اختباراتك سيناريوهات سريعة وعشوائية، وقد شرحنا أنواع الاختبارات وأين يفيد كل نوع في مقال أنواع الاختبارات.
  • البلاغ عن حادثة يجب أن يصل للجميع: المستخدمون لم يعرفوا بحوادث بعضهم، والشركة قالت لكل مستشفى إنها لا تعرف أي حوادث سابقة، فاجعل عندك سجلاً للحوادث Incident Log يراه كل من يعمل على النظام، واكتب مراجعة بعد كل حادثة Postmortem.

الخلاصة

  • بين يونيو 1985 ويناير 1987 وقعت ست حوادث جرعات إشعاع زائدة كبيرة على جهاز Therac-25 من شركة AECL، في جورجيا وكندا وواشنطن وتكساس، وتوفي عدد من المرضى من مضاعفاتها.
  • الجهاز اعتمد على البرنامج في الأمان بدل الحماية في العتاد التي كانت في Therac-20، والبرنامج مكتوب بلغة Assembly لحاسب PDP-11 بمهام متوازية تتشارك متغيرات بدون أقفال.
  • حادثتا Tyler سببهما سباق Race Condition: تعديل الوضع خلال حوالي 8 ثوان من ضبط المغناطيسات لا يراه البرنامج، فتبقى معاملات الحزمة القديمة بينما يتحرك القرص حسب القيمة الجديدة.
  • حادثة Yakima الثانية سببها المتغير Class3 من بايت واحد الذي يزيد في كل مرور، فيعود إلى الصفر كل 256 مرور ويتخطى فحص موضع القرص.
  • رسائل مثل Malfunction 54 بدون شرح، والاستمرار بالضغط على P، وردود الشركة بأن الخطأ مستحيل، أخرت اكتشاف السبب.
  • الخطة النهائية في يوليو 1987 أعادت الحماية المستقلة في العتاد، وجعلت أعطال الجرعة توقف العلاج كاملاً، وحسنت الرسائل.
  • الدرس لكل مطور: لا تجعل الكود نقطة الفشل الوحيدة، ونسق الوصول للمتغيرات المشتركة، واكتب القيمة المنطقية كقيمة منطقية، واكتب رسائل خطأ تفيد، ولا تعتبر عدم إعادة إنتاج الخطأ دليلاً على غيابه.

وإذا أردت أن تكمل من هنا، فهذه المقالات تشرح السباق والفيضان والاختبارات، وبقية كوارث هذه المرحلة:

دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.دليلالأنظمة العددية للمبرمجين: من Binary إلى Floating Pointلماذا يظهر القرص 465 بدل 500، ويصبح العداد سالباً فجأة، ولا يساوي 0.1 + 0.2 القيمة 0.3؟ الإجابة في طريقة تخزين الأرقام.دليلFloating Point من الداخل: كيف يخزن الحاسب الكسوركيف يحفظ معيار IEEE 754 الكسور في البتات، ولماذا تكبر الفجوة بين الأرقام وتضيع خانات المعرفات، ومتى تحتاج النوع العشري.دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلاستكشاف الأخطاء وتحليل الأداء للمبتدئين: من العرض إلى السببعندما يبطئ الموقع أو يعطي 502، هذه طريقة منظمة توصلك من العرض إلى السبب قبل أن تخمن أو تشتري سيرفراً أكبر.دليلMars Climate Orbiter: مسبار ضاع بسبب وحدة قياسكيف أضاع رقم بالرطل قرأه برنامج آخر بالنيوتن مسباراً كاملاً على المريخ، وكيف تمنع خطأ الوحدات في واجهات أنظمتك.دليلانهيار شبكة AT&T سنة 1990: تحديث برمجي أسقط 114 مقسماًكيف حول خلل في تحديث برمجي عطلاً عادياً في مقسم واحد إلى انهيار شبكة AT&T تسع ساعات، وماذا تتعلم عن اختبار مسار الاستعادة.دليلحريق OVHcloud في ستراسبورغ: نسخ احتياطية احترقت مع البياناتماذا حدث في حريق مركز بيانات OVHcloud سنة 2021، ولماذا فقد عملاء نسخهم الاحتياطية، وكيف تتأكد أن نسختك خارج الموقع فعلاً.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال اعتماداً على بحث Nancy Leveson وClark Turner «An Investigation of the Therac-25 Accidents» في مجلة IEEE Computer (يوليو 1993)، مع محاكاة تعليمية بلغة C على gcc 14.2.0، ورسوم توضيحية من عملنا.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.