Heisenbug: คืออะไร เกิดขึ้นได้อย่างไร และวิธีการจับ

ผู้แต่ง: IT Sectr เผยแพร่เมื่อ: 2026-07-29 เวลาอ่าน: 10 นาที

Heisenbug — บั๊กที่หายไปเมื่อคุณพยายามดีบักมัน คำศัพท์นี้มาจากหลักการความไม่แน่นอนของไฮเซนเบิร์ก: การสังเกตมีผลต่อพฤติกรรมของระบบ ในการพัฒนามือถือ Heisenbug เป็นหนึ่งในปัญหาที่ยากที่สุด เพราะวิธีการดีบักมาตรฐาน (ล็อก เบรกพอยต์ โค้ดเพิ่มเติม) เปลี่ยนสถานะของโปรแกรมและซ่อนบั๊ก มาทำความเข้าใจสาเหตุและวิธีการจัดการกับข้อผิดพลาดที่จับต้องได้ยากกัน

ประเด็นสำคัญ

  • Race condition — สาเหตุหลักของ Heisenbug: การเปลี่ยนจังหวะเวลาระหว่างดีบักปกปิดปัญหา
  • Bohrbug — บั๊กที่คาดเดาได้ ทำซ้ำได้ง่าย แตกต่างจาก Heisenbug
  • Mandelbug — บั๊กที่มีความสัมพันธ์เชิงเหตุผลซับซ้อน ไวต่อเงื่อนไขเริ่มต้น
  • ThreadSanitizer — เครื่องมือตรวจจับการแข่งข้อมูลโดยไม่กระทบจังหวะเวลา
  • การทดสอบแบบกำหนดได้ — วิธีเดียวที่เชื่อถือได้ในการทำซ้ำ Heisenbug

Heisenbug ในการพัฒนามือถือคืออะไร

Heisenbug — ข้อผิดพลาดประเภทหนึ่งที่เกิดขึ้นในโปรดักชันหรือระหว่างการทำงานปกติ แต่หายไปเมื่อพยายามทำซ้ำในสภาพแวดล้อมการดีบัก คำศัพท์นี้ถูกบัญญัติขึ้นในปี 1980 โดยโปรแกรมเมอร์ Jim Gray ในบริบทของระบบกระจาย แต่ปัจจุบันเกี่ยวข้องมากที่สุดกับแอปพลิเคชันมือถือเนื่องจากลักษณะอะซิงโครนัส

สาเหตุหลัก: เครื่องมือดีบักมาตรฐานเปลี่ยนสภาพแวดล้อมการทำงาน เบรกพอยต์ หยุดเธรดชั่วคราวหลายมิลลิวินาที การบันทึก เพิ่ม I/O แบบซิงโครนัส การตรวจสอบเพิ่มเติม เปลี่ยนลำดับการทำงาน ในสภาพแวดล้อมแบบหลายเธรด แม้แต่ความล่าช้าระดับไมโครวินาทีก็สามารถเปลี่ยนลำดับการทำงานของเธรดและซ่อนการแข่งข้อมูลได้

ตามข้อมูลของ Microsoft Research (2022) ประมาณ 15-25% ของบั๊กทั้งหมดในแอปพลิเคชันมือถือแบบหลายเธรดถูกจัดประเภทเป็น Heisenbug ในขณะเดียวกัน เวลาในการค้นหาและแก้ไข Heisenbug หนึ่งตัวโดยเฉลี่ยมากกว่าบั๊กทั่วไป 5-10 เท่า เนื่องจากไม่สามารถทำซ้ำได้โดยตรง

ตัวอย่าง Heisenbug

แอป crash ในโปรดักชันเมื่อปัดรายการอย่างรวดเร็ว แต่เมื่อเชื่อมต่อกับดีบักเกอร์หรือเพิ่มล็อก — ทำงานได้อย่างสมบูรณ์ สาเหตุ: การแข่งข้อมูลระหว่างเธรด UI (อัปเดต RecyclerView) และเธรดพื้นหลัง (อัปเดตข้อมูลอะแดปเตอร์) ล็อกเพิ่มความล่าช้าที่ซิงโครไนซ์เธรดแบบสุ่ม

Bohrbug, Mandelbug, Heisenbug: การจำแนกบั๊ก

Bohrbug — บั๊กที่คาดเดาได้ ทำซ้ำได้อย่างเสถียร ตั้งชื่อโดยเปรียบเทียบกับแบบจำลองอะตอมของบอร์: เช่นเดียวกับอะตอม บั๊กมีพฤติกรรมเหมือนกันทุกครั้งที่สังเกต ตัวอย่าง: NullPointerException เมื่อคลิกปุ่มก่อนโหลดข้อมูล รักษาด้วยการทดสอบหน่วยมาตรฐาน

Mandelbug — บั๊กที่มีความสัมพันธ์เชิงเหตุผลที่ซับซ้อนและวุ่นวาย (ตั้งชื่อโดยเปรียบเทียบกับเซต Mandelbrot) เกิดขึ้นภายใต้เงื่อนไขบางอย่างรวมกันเท่านั้น: เวอร์ชัน OS, รุ่นอุปกรณ์, สถานะเครือข่าย แตกต่างจาก Heisenbug ตรงที่ไม่หายไประหว่างดีบัก — ปัญหาคือ ความยากในการทำซ้ำ ไม่ใช่การเปลี่ยนแปลงพฤติกรรมจากเครื่องมือ

Heisenbug — บั๊กที่หายไปอย่างแม่นยำ เพราะเครื่องมือดีบัก ถ้าคุณเพิ่มล็อก — บั๊กหายไป ถ้าคุณตั้งเบรกพอยต์ — บั๊กไม่เกิดขึ้น ถ้าคุณลบทุกอย่าง — บั๊กกลับมา สาเหตุหลัก: จังหวะเวลาที่เปลี่ยนแปลงไประหว่างดีบัก

ประเภทความสามารถในการทำซ้ำปฏิกิริยาต่อดีบักตัวอย่าง
Bohrbug100%ไม่เปลี่ยนแปลงNPE เมื่อรายการว่าง
Mandelbugวุ่นวายไม่เปลี่ยนแปลงCrash บน Android 12, Samsung, แบตเตอรี่ต่ำ
Heisenbugเฉพาะเมื่อไม่ดีบักหายไปRace condition ที่หายไปพร้อมล็อก
Schrödinbugไม่แสดงในโค้ดปรากฏเมื่อมองบั๊กเห็นในโค้ดแต่ไม่เคยทำงาน

สาเหตุหลักของ Heisenbug

Race condition — สาเหตุอันดับหนึ่งของ Heisenbug สองเธรดเข้าถึงข้อมูลที่ใช้ร่วมกันโดยไม่มีการซิงโครไนซ์ ดีบักเกอร์ทำให้เกิดความล่าช้า ส่งผลให้เธรดซิงโครไนซ์กันโดยธรรมชาติ หากไม่มีดีบักเกอร์ ลำดับการทำงานไม่สามารถคาดเดาได้

ข้อผิดพลาดที่ขึ้นกับจังหวะเวลา — บั๊กที่เกิดขึ้นเฉพาะที่ความเร็วการทำงานบางอย่างเท่านั้น ตัวอย่าง: แอนิเมชันที่ต้องเสร็จสิ้นก่อนการทำงานถัดไปจะเริ่ม ในดีบักเกอร์ แอนิเมชันทำงานช้าลง และการทำงานมีเวลาเริ่มหลังแอนิเมชันเสร็จ ในโปรดักชัน — ตรงกันข้าม

kotlin
// 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) อาจจัดลำดับคำสั่งใหม่เพื่อการปรับแต่ง ในบิลด์ดีบัก การปรับแต่งถูกปิดใช้งานและโค้ดทำงาน «ตามที่เขียน» ในบิลด์รีลีส คอมไพเลอร์เปลี่ยนลำดับการทำงาน ซึ่งอาจเปิดเผยสมมติฐานที่ซ่อนอยู่ในโค้ด

  • ThreadLocal — การใช้ตัวแปร thread-local ไม่ถูกต้องซึ่งเธรดอื่นไม่เห็น
  • ตัวแปรที่ไม่ได้เริ่มต้น — โค้ดที่พึ่งพาค่าเริ่มต้นของฟิลด์คลาส
  • คิว GCD/dispatch — ใน iOS ลำดับการทำงานของบล็อกในคิวพร้อมกันไม่ถูกกำหนด
  • I/O ที่มีบัฟเฟอร์ — ข้อมูลจะไม่ถูกเขียนลงดิสก์จนกว่าบัฟเฟอร์จะเต็ม

กลยุทธ์การจับบั๊กที่ยากจะจับ

ThreadSanitizer (TSan) — เครื่องมือของ Google สำหรับตรวจจับการแข่งข้อมูลใน C/C++ และ Kotlin/Native ถูกฝังในบิลด์และตรวจจับการเข้าถึงหน่วยความจำที่ใช้ร่วมกันโดยไม่ซิงโครไนซ์ ต่างจากล็อก TSan ไม่กระทบจังหวะเวลาเพราะทำงานผ่านโค้ดที่ถูก instrument ไม่ใช่ผ่าน I/O

การทดสอบแบบกำหนดได้ — แทนที่อะซิงโครนัสจริงด้วยอะซิงโครนัสที่ควบคุมได้ ใช้ TestDispatcher (Kotlin), RxJava Plugins หรือ คิวทดสอบ GCD (iOS) เพื่อควบคุมลำดับการทำงานอย่างสมบูรณ์ กำหนดสถานการณ์เฉพาะ: เธรด A ทำงาน, จากนั้น B, จากนั้น A อีกครั้ง

การบันทึกแบบวนรอบ — การบันทึกไปยังบัฟเฟอร์วงแหวนในหน่วยความจำ (ไม่ใช่ดิสก์) เมื่อบั๊กเกิดขึ้น บัฟเฟอร์จะถูกบันทึกลงไฟล์ เนื่องจากการเขียนในหน่วยความจำใช้เวลาเป็นนาโนวินาที (แทนที่จะเป็นมิลลิวินาทีสำหรับ I/O ดิสก์) การบันทึกดังกล่าวไม่กระทบจังหวะเวลาและไม่ปกปิด Heisenbug

kotlin
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 หรือตัวบันทึกแบบวนรอบที่กำหนดเอง สำคัญ: การบันทึกควรเป็นอะซิงโครนัสและมีผลกระทบต่อประสิทธิภาพน้อยที่สุด

การป้องกัน Heisenbug ในระดับสถาปัตยกรรม

การแยกสถานะ — ลดสถานะที่เปลี่ยนแปลงได้ซึ่งใช้ร่วมกันให้น้อยที่สุด แต่ละคอมโพเนนต์ควรมีสถานะแยกของตัวเอง ไม่สามารถเขียนโดยตรงจากคอมโพเนนต์อื่น ใช้ Unidirectional Data Flow (UDF) — สถานะไหลในทิศทางเดียว: เหตุการณ์ → รีดิวเซอร์ → สถานะ → UI

แนวทางการทำงานเชิงฟังก์ชัน — ฟังก์ชันบริสุทธิ์ที่ไม่มีผลข้างเคียงทดสอบและดีบักได้ง่ายกว่า แยกผลข้างเคียง (เครือข่าย, DB, ไฟล์) ในเลเยอร์ที่กำหนดอย่างเคร่งครัด (repository, data source) ข้อผิดพลาดเกี่ยวกับเธรดในโค้ดเชิงฟังก์ชันแทบเป็นไปไม่ได้

โหมดเข้มงวด — เปิดใช้งาน Android StrictMode ในบิลด์ดีบัก มันตรวจจับการละเมิดนโยบายเธรด (เครือข่ายบนเธรดหลัก, I/O ดิสก์บนเธรดหลัก) และโยนข้อยกเว้น สิ่งนี้เปลี่ยน Heisenbug ที่อาจเกิดขึ้นเป็น Bohrbug ที่กำหนดได้ซึ่งมองเห็นได้ทันที

kotlin
class DebugApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

การตรวจสอบโค้ดที่เน้นอะซิงโครนัส — ส่วนบังคับของกระบวนการ พูลรีเควสต์แต่ละรายการควรตรวจสอบสถานะที่เปลี่ยนแปลงได้ซึ่งใช้ร่วมกัน, คอลเลกชันที่ไม่ปลอดภัยสำหรับเธรด และการขาดการซิงโครไนซ์ ใช้ กฎ lint เพื่อห้ามรูปแบบบางอย่างโดยอัตโนมัติ (เช่น การเข้าถึง MutableList โดยไม่มี synchronized)

คำถามที่พบบ่อย

ทำไม Heisenbug ถึงหายาก?

เพราะวิธีการมาตรฐาน — เบรกพอยต์, ล็อก, print — เปลี่ยนสภาพแวดล้อมการทำงานมากจนบั๊กหยุดเกิดขึ้น ดีบักเกอร์หยุดเธรดทั้งหมดชั่วคราวเป็นสิบมิลลิวินาที ในช่วงเวลานี้ การแข่งข้อมูลที่ทำให้เกิดบั๊กได้รับการแก้ไขโดยธรรมชาติ จำเป็นต้องใช้เครื่องมือที่ไม่กระทบจังหวะเวลาการทำงาน

Heisenbug แตกต่างจาก Mandelbug อย่างไร?

Mandelbug ทำซ้ำได้ยากเนื่องจากความซับซ้อนของเงื่อนไข แต่เครื่องมือดีบักไม่กระทบการแสดงออกของมัน Heisenbug หายไปอย่างแม่นยำเพราะเครื่องมือดีบัก ตัวอย่าง Mandelbug: crash เฉพาะบนอุปกรณ์ Android 11, 3 GB RAM และระดับแบตเตอรี่ต่ำกว่า 15% ตัวอย่าง Heisenbug: race condition ที่หายไปเมื่อเพิ่ม Log.d()

จะทดสอบ Heisenbug ใน CI/CD ได้อย่างไร?

ใช้ การตรวจจับการทดสอบที่ไม่เสถียร — การทดสอบที่บางครั้งล้มเหลว บางครั้งผ่าน ใน Android ใช้ Android Test Orchestrator เพื่อแยกการทดสอบ เพิ่ม StrictMode ในการทดสอบดีบัก instrument บิลด์ด้วย ThreadSanitizer หากการทดสอบไม่เสถียร >5% ของการรัน — ถือว่าเป็น Heisenbug ที่อาจเกิดขึ้นและสอบสวนก่อนรวม

Flow/Coroutines ช่วยหลีกเลี่ยง Heisenbug หรือไม่?

บางส่วน Flow และ การทำงานพร้อมกันแบบมีโครงสร้าง ใน Kotlin ลดปริมาณสถานะที่เปลี่ยนแปลงได้ซึ่งใช้ร่วมกันและทำให้การจัดการเธรดง่ายขึ้น แต่ coroutine ไม่รับประกันความปลอดภัยของเธรด: ถ้า coroutine สองตัวใช้สถานะร่วมกัน การแข่งข้อมูลยังคงเป็นไปได้ ใช้ Mutex เพื่อป้องกันสถานะที่ใช้ร่วมกันหรือ Channel เพื่อส่งข้อมูลระหว่าง coroutine

จะทำอย่างไรถ้า Heisenbug เกิดขึ้นเฉพาะในโปรดักชัน?

ใช้ บัฟเฟอร์ล็อกแบบวนรอบ ในหน่วยความจำที่ล้างข้อมูลโดยอัตโนมัติเมื่อเกิดข้อผิดพลาด เพิ่ม การตรวจสอบโดยละเอียด ผ่าน Crashlytics หรือ Sentry ด้วย breadcrumbs ที่กำหนดเอง สำหรับ Android เปิดใช้งาน การตรวจจับ ANR และตรวจสอบ traces หากบั๊กเป็นการแข่งข้อมูล ThreadSanitizer ในบิลด์ดีบักที่มีโหลดใกล้เคียงโปรดักชันอาจเปิดเผยปัญหา

สรุป

  • Heisenbug — บั๊กที่หายไปเมื่อพยายามดีบัก; สาเหตุหลักคือการเปลี่ยนแปลงจังหวะเวลาจากเครื่องมือของนักพัฒนา
  • Race condition — สาเหตุหลักของ Heisenbug ในแอปพลิเคชันมือถือ โดยเฉพาะในโค้ดอะซิงโครนัส
  • Bohrbug (ทำซ้ำได้ 100%) และ Mandelbug (วุ่นวาย) — บั๊กประเภทอื่น อย่าสับสนกับ Heisenbug
  • ThreadSanitizer — เครื่องมือที่ดีที่สุดสำหรับตรวจจับการแข่งข้อมูล โดยไม่กระทบจังหวะเวลาการทำงาน
  • การบันทึกแบบวนรอบ ในหน่วยความจำแทนดิสก์ — วิธีรวบรวมข้อมูลโดยไม่ปกปิด Heisenbug
  • Unidirectional Data Flow และการลดสถานะที่เปลี่ยนแปลงได้ซึ่งใช้ร่วมกัน — การป้องกันทางสถาปัตยกรรมสำหรับข้อผิดพลาดทั้งคลาส
  • StrictMode ในบิลด์ดีบักเปลี่ยน Heisenbug ที่อาจเกิดขึ้นเป็น Bohrbug ที่กำหนดได้ซึ่งมองเห็นได้ทันที

เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร

IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ

ปรึกษาโครงการ

อ่านเพิ่มเติม