Heisenbug — خطأ يختفي عند محاولة تصحيحه. المصطلح مشتق من مبدأ عدم اليقين لهايزنبرغ: الملاحظة تؤثر على سلوك النظام. في تطوير التطبيقات، Heisenbug هو واحد من أصعب المشاكل لأن طرق التصحيح القياسية (السجلات، نقاط التوقف، الكود الإضافي) تغير حالة البرنامج وتخفي الخطأ. سنستعرض الأسباب وطرق مكافحة الأخطاء المراوغة.
الخلاصة
Heisenbug — فئة من الأخطاء التي تظهر في بيئة الإنتاج أو أثناء التشغيل الطبيعي، لكنها تختفي عند محاولة إعادة إنتاجها في بيئة التصحيح. صاغ المصطلح في الثمانينيات المبرمج Jim Gray في سياق الأنظمة الموزعة، لكنه الأكثر صلة اليوم بتطبيقات الجوال بسبب طبيعتها غير المتزامنة.
السبب الرئيسي: أدوات التصحيح القياسية تغير بيئة التنفيذ. نقطة التوقف توقف الخيط لبضع ملي ثوان، التسجيل يضيف إدخال/إخراج متزامن، الفحوصات الإضافية تغير ترتيب العمليات. في بيئة متعددة الخيوط، حتى تأخير ميكروثانية يمكن أن يغير ترتيب تنفيذ الخيوط ويخفي تسابق البيانات.
وفقاً لـ Microsoft Research (2022)، حوالي 15-25% من جميع الأخطاء في تطبيقات الجوال متعددة الخيوط تصنف كـ Heisenbug. في نفس الوقت، وقت البحث وإصلاح Heisenbug واحد أطول بمعدل 5-10 مرات من الخطأ العادي، بسبب استحالة إعادة إنتاجه مباشرة.
تتعطل التطبيقات في الإنتاج عند التمرير السريع عبر القائمة، ولكن عند الاتصال بالمصحح أو إضافة السجلات — تعمل بشكل مثالي. السبب: تسابق البيانات بين خيط الواجهة (تحديث RecyclerView) وخيط الخلفية (تحديث بيانات المحول). السجلات تضيف تأخيراً يزامن الخيوط بشكل عشوائي.
Bohrbug — خطأ يمكن التنبؤ به، يمكن إعادة إنتاجه بثبات. سمي تشبيهاً بنموذج بور الذري: مثل الذرة، يتصرف الخطأ بنفس الطريقة في كل مرة يُلاحظ فيها. مثال: NullPointerException عند النقر على زر قبل تحميل البيانات. يُعالج بالاختبارات الوحدوية القياسية.
Mandelbug — خطأ بعلاقة سببية معقدة وفوضوية (سمي تشبيهاً بمجموعة ماندلبروت). يظهر فقط تحت مجموعة معينة من الظروف: إصدار نظام التشغيل، طراز الجهاز، حالة الشبكة. يختلف عن Heisenbug في أنه لا يختفي أثناء التصحيح — المشكلة هي صعوبة إعادة الإنتاج، وليس تغير السلوك من الأدوات.
Heisenbug — خطأ يختفي تحديداً بسبب أدوات التصحيح. إذا أضفت سجلاً — يختفي الخطأ. إذا وضعت نقطة توقف — لا يظهر الخطأ. إذا أزلت كل شيء — يعود الخطأ. السبب الرئيسي: تغير التوقيت أثناء التصحيح.
| النوع | قابلية إعادة الإنتاج | رد الفعل على التصحيح | مثال |
|---|---|---|---|
| Bohrbug | 100% | لا يتغير | NPE عند قائمة فارغة |
| Mandelbug | فوضوية | لا يتغير | تعطل على Android 12، Samsung، بطارية منخفضة |
| Heisenbug | فقط بدون تصحيح | يختفي | تسابق بيانات يختفي مع السجلات |
| Schrödinbug | لا يظهر في الكود | يظهر عند النظر | خطأ مرئي في الكود لكنه لا يحدث أبداً |
Race condition — السبب الأول لـ Heisenbug. خيطان يصلان إلى بيانات مشتركة دون مزامنة. المصحح يقدم تأخيراً، مما يؤدي إلى مزامنة الخيوط بشكل طبيعي. بدون المصحح، ترتيب التنفيذ غير متوقع.
الأخطاء المعتمدة على التوقيت — أخطاء تظهر فقط عند سرعة تنفيذ معينة. على سبيل المثال، رسم متحرك يجب أن ينتهي قبل بدء العملية التالية. في المصحح، الرسم المتحرك أبطأ، وتكون العملية قد بدأت بعد انتهاء الرسم. في الإنتاج — العكس.
// Example race condition — typical Heisenbug
class ListViewModel : ViewModel() {
private var items = mutableListOf<String>()
fun loadFromNetwork() {
viewModelScope.launch(Dispatchers.IO) {
val result = api.fetchItems()
items.addAll(result) // ❌ Not thread-safe
}
}
fun getItems(): List<String> = items.toList()
// Race condition: getItems read may overlap with loadFromNetwork write
}
تحسين المترجم — يمكن للمترجم (JIT، ART، Kotlin/Native) إعادة ترتيب التعليمات للتحسين. في بناء التصحيح (debug build)، التعطيلات معطلة والكود ينفذ «كما هو مكتوب». في بناء الإصدار (release build)، يغير المترجم ترتيب العمليات، مما قد يكشف افتراضات مخفية في الكود.
ThreadSanitizer (TSan) — أداة جوجل لاكتشاف تسابق البيانات في C/C++ و Kotlin/Native. تُضمن في البناء وتكشف أي وصول للذاكرة المشتركة دون مزامنة. على عكس السجلات، TSan لا يؤثر على التوقيت لأنه يعمل من خلال كود مُجهز، وليس من خلال الإدخال/الإخراج.
الاختبارات الحتمية — استبدل عدم التزامن الحقيقي بعدم تزامن خاضع للتحكم. استخدم TestDispatcher (Kotlin)، RxJava Plugins أو قوائم اختبار GCD (iOS) للتحكم الكامل في ترتيب التنفيذ. حدد سيناريوهات محددة: الخيط A ينفذ، ثم B، ثم A مرة أخرى.
التسجيل الدوري — التسجيل في مخزن مؤقت حلقي في الذاكرة (وليس على القرص). عندما يحدث الخطأ، يُحفظ المخزن المؤقت في ملف. نظراً لأن الكتابة في الذاكرة تستغرق نانو ثانية (بدلاً من ملي ثانية لإدخال/إخراج القرص)، فإن هذا التسجيل لا يؤثر على التوقيت ولا يخفي Heisenbug.
class CyclicBuffer(val capacity: Int = 1000) {
private val buffer = ArrayDeque<String>(capacity)
private val lock = Any()
fun log(message: String) {
synchronized(lock) {
if (buffer.size >= capacity) buffer.removeFirst()
buffer.addLast(message)
}
}
fun flush() {
synchronized(lock) { buffer.forEach { fileWriter.write(it) } }
}
}
التسجيل في الإنتاج — إذا لم يتم إعادة إنتاج الخطأ محلياً، اجمع البيانات في الإنتاج. استخدم سجلات Firebase Crashlytics، Sentry Breadcrumbs أو مسجل دوري مخصص. مهم: يجب أن يكون التسجيل غير متزامن وذو تأثير ضئيل على الأداء.
عزل الحالة — قلل من الحالة القابلة للتغيير المشتركة. يجب أن يكون لكل مكون حالته المعزولة الخاصة، غير القابلة للكتابة المباشرة من المكونات الأخرى. استخدم تدفق البيانات أحادي الاتجاه (UDF) — تتدفق الحالة في اتجاه واحد: حدث → مختزل → حالة → واجهة.
النهج الوظيفي — الدوال الخالصة دون آثار جانبية أسهل في الاختبار والتصحيح. اعزل الآثار الجانبية (الشبكة، قاعدة البيانات، الملفات) في طبقات محددة بدقة (مستودع، مصدر بيانات). أخطاء الخيوط في الكود الوظيفي شبه مستحيلة.
الوضع الصارم — فعّل Android StrictMode في بناء التصحيح. يكتشف انتهاكات سياسة الخيوط (الشبكة على الخيط الرئيسي، إدخال/إخراج القرص على الخيط الرئيسي) ويرمي استثناء. هذا يحول Heisenbug محتمل إلى Bohrbug حتمي مرئي فوراً.
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
مراجعة الكود مع التركيز على عدم التزامن — جزء إلزامي من العملية. يجب فحص كل طلب سحب بحثاً عن حالة قابلة للتغيير مشتركة، مجموعات غير آمنة للخيوط، وغياب المزامنة. استخدم قواعد lint لمنع أنماط معينة تلقائياً (مثل الوصول إلى MutableList بدون synchronized).
الأسئلة الشائعة
لأن الطرق القياسية — نقاط التوقف، السجلات، print — تغير بيئة التنفيذ لدرجة أن الخطأ يتوقف عن الظهور. المصحح يوقف جميع الخيوط لعشرات الملي ثوان. خلال هذا الوقت، يتلاشى تسابق البيانات الذي تسبب في الخطأ بشكل طبيعي. نحتاج أدوات لا تؤثر على توقيت التنفيذ.
Mandelbug يصعب إعادة إنتاجه بسبب تعقيد الظروف، لكن أدوات التصحيح لا تؤثر على ظهوره. Heisenbug يختفي تحديداً بسبب أدوات التصحيح. مثال Mandelbug: تعطل فقط على أجهزة Android 11، 3 GB RAM، ومستوى بطارية أقل من 15%. مثال Heisenbug: تسابق بيانات يختفي عند إضافة Log.d().
استخدم كشف الاختبارات غير المستقرة — اختبارات تارة تفشل وتارة تنجح. في Android، استخدم Android Test Orchestrator لعزل الاختبارات. أضف StrictMode لاختبارات التصحيح. جهز البناء بـ ThreadSanitizer. إذا كان الاختبار غير مستقر >5% من التشغيلات — اعتبره Heisenbug محتملاً وافحصه قبل الدمج.
جزئياً. Flow و التزامن المنظم في Kotlin يقللان من كمية الحالة القابلة للتغيير المشتركة ويبسطان إدارة الخيوط. لكن coroutines لا تضمن أمان الخيوط: إذا شاركت اثنتان من coroutines الحالة، فلا يزال تسابق البيانات ممكناً. استخدم Mutex لحماية الحالة المشتركة أو Channel لنقل البيانات بين coroutines.
استخدم مخزناً دورياً للسجلات في الذاكرة مع تفريغ تلقائي عند الخطأ. أضف مراقبة مفصلة عبر Crashlytics أو Sentry مع breadcrumbs مخصصة. بالنسبة لـ Android، فعّل كشف ANR وراجع التتبعات. إذا كان الخطأ تسابق بيانات، فإن ThreadSanitizer في بناء تصحيح مع حمل مشابه للإنتاج قد يكشف المشكلة.
الملخص
سنقوم بتطوير تطبيق جوال جاهز
تقدم IT Sectr تطبيقات iOS وAndroid للشركات الناشئة والشركات منذ عام 2017. سوف نقدم لك النصح ونقترح أفضل حل.