ในโลกของการคำนวณระดับสูง แม้แต่บั๊กที่หายากที่สุดก็ย่อมเกิดขึ้นเมื่อคุณประมวลผลคำขอ HTTP หลายร้อยล้านครั้ง วิศวกรของ Cloudflare เพิ่งเผชิญกับปัญหาที่ตามล่าหายากเช่นนี้ นั่นคือระบบล่มอย่างลึกลับที่ปรากฏแบบสุ่มบนโครงสร้างพื้นฐาน ARM64 ของพวกเขา สิ่งที่เริ่มต้นจากการแพนิกเป็นครั้งคราว ในที่สุดก็เปิดเผยตัวว่าเป็นบั๊กพื้นฐานในคอมไพเลอร์ ARM64 ของ Go ซึ่งแสดงให้เห็นว่าความซับซ้อนของซอฟต์แวร์สมัยใหม่สามารถซ่อนปัญหาที่ละเอียดอ่อนแต่สำคัญได้อย่างไร
![]() |
---|
บทความบล็อกนี้กล่าวถึงการค้นพบบั๊กพื้นฐานใน ARM64 compiler ของ Go ซึ่งเป็นปัญหาสำคัญที่วิศวกร Cloudflare พบเจอขณะประมวลผล HTTP requests หลายล้านรายการ |
ผีในเครื่อง
เป็นเวลาหลายสัปดาห์ที่วิศวกรของ Cloudflare สังเกตเห็น segmentation fault และการแพนิกอย่างร้ายแรงที่เกิดขึ้นบนเซิร์ฟเวอร์ ARM64 ของพวกเขา การล่มดังกล่าวน่างงเป็นพิเศษเพราะดูเหมือนจะเกิดขึ้นแบบสุ่มโดยไม่มีรูปแบบที่ชัดเจน และส่งผลต่อปริมาณการรับส่งข้อมูลจำนวนมหาศาลที่ไหลผ่านเครือข่ายระดับโลกของ Cloudflare เพียงเล็กน้อยเท่านั้น การตรวจสอบเบื้องต้นชี้ไปที่ความเสียหายของหน่วยความจำ แต่สาเหตุรากฐานยังคงหายากแม้จะมีความพยายามดีบั๊กอย่างกว้างขวาง
ปัญหานี้แสดงออกมาในรูปแบบ segmentation fault ในระหว่างการดำเนินการ garbage collection และ stack unwinding วิศวกรสังเกตเห็นว่าการล่มเหล่านี้เกิดขึ้นอย่างสม่ำเสมอในระหว่าง async preemption ซึ่งเป็นกลไกของ Go ในการขัดจังหวะ goroutine ที่ทำงานเป็นเวลานานเพื่อรักษาการจัดตารางเวลาที่เป็นธรรม คำใบ้นี้กลายเป็นเส้นด้ายเส้นแรกในการเดินทางดีบั๊กที่ซับซ้อน
สิ่งหนึ่งที่มักถูกมองข้ามไปคือความยากที่จะสงสัยว่าคอมไพเลอร์เป็นสาเหตุรากฐาน วิศวกรส่วนใหญ่ใช้เวลาหลายชั่วโมงในการตามล่าหาบั๊กในโค้ดของตัวเอง เพราะเราถูกฝึกมาให้เชื่อมั่นในเครื่องมือของเรา
การเปิดโป่งภาวะแข่งกัน
จุดเปลี่ยนสำคัญเกิดขึ้นเมื่อวิศวกรตระหนักว่าการล่มเกิดขึ้นในช่วงเวลาที่เฉพาะเจาะจงมาก นั่นคือเมื่อ Go runtime กำลังทำการ preempt goroutine ในระหว่างการปรับค่า stack pointer บนสถาปัตยกรรม ARM64 การปรับค่า stack pointer ขนาดใหญ่บางครั้งถูกแบ่งออกเป็นหลายคำสั่งโดย Go assembler หาก async preemption เกิดขึ้นระหว่างคำสั่งที่แบ่งเหล่านี้ มันจะทิ้งให้ stack pointer อยู่ในสถานะที่ไม่สอดคล้องกัน
สิ่งนี้สร้างภาวะแข่งกันที่ garbage collection จะพยายาม unwind stack ด้วยพอยน์เตอร์ที่ไม่ถูกต้อง นำไปสู่ segmentation fault บั๊กนี้มีความละเอียดอ่อนเป็นพิเศษเพราะมันส่งผลกระทบเฉพาะฟังก์ชันที่มี stack frame ใหญ่กว่า 4KB และเฉพาะบนสถาปัตยกรรม ARM64 เท่านั้น ที่ซึ่งคำสั่งความยาวคงที่บางครั้งต้องการให้การดำเนินการที่ซับซ้อนถูกแบ่งออกเป็นหลายขั้นตอน
การอภิปรายในชุมชนเน้นย้ำว่าบั๊กประเภทนี้เป็นตัวแทนของปัญหา classic ในการเขียนโปรแกรมระบบ ผู้แสดงความคิดเห็นหลายคนระบุถึงประสบการณ์ที่คล้ายกันกับบั๊กคอมไพเลอร์ตลอดอาชีพการงานของพวกเขา โดยเน้นย้ำว่าขนาดงานและความแตกต่างของสถาปัตยกรรมสามารถเปิดเผยปัญหาที่ยังคงซ่อนอยู่ในการสภาพแวดล้อมการพัฒนาส่วนใหญ่ได้อย่างไร
รายละเอียดทางเทคนิคที่สำคัญ:
- สถาปัตยกรรม: ARM64 (ชุดคำสั่งที่มีความยาวคงที่)
- ปัญหา: การปรับค่า stack pointer ถูกแบ่งออกเป็นหลายคำสั่ง
- ผลกระทบ: stack pointer ที่ไม่ถูกต้องในระหว่างการทำ garbage collection
- วิธีแก้ไข: ใช้ temporary register สำหรับการอัปเดต stack pointer แบบ atomic
- วิธีการตรวจจับ: การวิเคราะห์รูปแบบการ crash ในระดับขนาดใหญ่มหาศาล (หลายร้อยล้าน request)
การแก้ไขและผลกระทบ
วิศวกรของ Cloudflare ได้พัฒนา minimal reproducer ที่แสดงให้เห็นบั๊กโดยไม่มี dependencies ภายนอก ซึ่งทำให้พวกเขายืนยันได้ว่าปัญหาอยู่ใน Go runtime จริงๆ ไม่ใช่ในโค้ดแอปพลิเคชันของพวกเขา การแก้ไขเกี่ยวข้องกับการปรับเปลี่ยนวิธีที่คอมไพเลอร์ Go จัดการกับการปรับ stack ขนาดใหญ่บน ARM64 เพื่อให้แน่ใจว่าการปรับเปลี่ยน stack pointer เกิดขึ้นแบบอะตอมมิกในคำสั่งเดียว แทนที่จะถูกแบ่งออกเป็นการดำเนินการหลายครั้ง
บั๊กนี้ได้รับการแก้ไขอย่างรวดเร็วโดยทีม Go และถูกแก้ไขในเวอร์ชัน 1.21.3, 1.20.10 และ 1.19.13 วิธีแก้ไขป้องกันภาวะแข่งกันโดยใช้ register ชั่วคราวเพื่อสร้างค่าออฟเซ็ตขนาดใหญ่ จากนั้นจึงนำไปใช้กับ stack pointer ในการดำเนินการเดียวที่แบ่งแยกไม่ได้ ซึ่งทำให้แน่ใจได้ว่า goroutine สามารถถูก preempt ก่อนหรือหลังการปรับเปลี่ยน stack pointer แต่ไม่เคยเกิดขึ้นในช่วงการปรับเปลี่ยนที่สำคัญ
สมาชิกในชุมชนได้อภิปรายถึงผลกระทบในวงกว้างของบั๊กดังกล่าว โดยบางคนระบุว่านี่เน้นย้ำถึงความสำคัญของการเข้าใจภาษาแอสเซมบลี แม้อยู่ในสภาพแวดล้อมการเขียนโปรแกรมระดับสูง คนอื่นๆ ชี้ให้เห็นว่าปัญหาที่คล้ายกันได้ปรากฏขึ้นตลอดประวัติศาสตร์การคำนวณ มักเกี่ยวข้องกับการปรับเปลี่ยน stack pointer ที่ไม่ใช่แบบอะตอมมิก across สถาปัตยกรรมที่แตกต่างกัน
เวอร์ชัน Go ที่ได้รับผลกระทบและการแก้ไข:
- Go 1.19.x: แก้ไขแล้วในเวอร์ชัน 1.19.13
- Go 1.20.x: แก้ไขแล้วในเวอร์ชัน 1.20.10
- Go 1.21.x: แก้ไขแล้วในเวอร์ชัน 1.21.3
- สาเหตุหลัก: การปรับ stack pointer แบบ non-atomic บน ARM64
- เงื่อนไขที่ทำให้เกิดปัญหา: Async preemption ระหว่างคำสั่ง split สำหรับ stack frames ที่มีขนาดมากกว่า 4KB
บทเรียนสำหรับการพัฒนาซอฟต์แวร์สมัยใหม่
เหตุการณ์นี้เน้นย้ำบทเรียนสำคัญหลายประการสำหรับการดำเนินงานซอฟต์แวร์ขนาดใหญ่ ประการแรก มันแสดงให้เห็นถึงคุณค่าของนโยบายการตรวจสอบระบบล่มอย่างละเอียดถี่ถ้วน - Cloudflare บังคับให้ตรวจสอบทุกครั้งที่ระบบล่ม หลังจากที่เรียนรู้มาก่อนหน้านี้แล้วว่าระบบล่มที่อธิบายไม่ได้อาจเป็นสัญญาณเตือนเบื้องต้นของปัญหาที่ร้ายแรง ประการที่สอง มันแสดงให้เห็นว่าความแตกต่างทางสถาปัตยกรรมมีความสำคัญอย่างไร - บั๊กที่ไม่เคยปรากฏบนระบบ x86 สามารถกลายเป็นปัญหาวิกฤตในการใช้งาน ARM64 ได้
กระบวนการดีบั๊กยังเน้นย้ำถึงความสำคัญของการมีวิศวกรที่สามารถคิด across หลายระดับของ abstraction ตั้งแต่โค้ดแอปพลิเคชันระดับสูง ลงไปจนถึงภายในคอมไพเลอร์และสถาปัตยกรรมโปรเซสเซอร์ ดังที่สมาชิกชุมชนคนหนึ่งระบุ บั๊กคอมไพเลอร์ได้กลายเป็นสิ่งที่หายากมากขึ้นเรื่อยๆ เนื่องจากเครื่องมือที่ดีขึ้น แต่พวกมันยังคงเกิดขึ้นและต้องการเทคนิคการตรวจสอบที่ซับซ้อน
การค้นพบนี้เป็นข้อเตือนใจว่าในระบบกระจายศูนย์ที่ทำงานในระดับขนาดใหญ่ แม้แต่เหตุการณ์ที่เกิดขึ้นหนึ่งในล้านก็เกิดขึ้นเป็นประจำ สิ่งที่อาจถูกพิจารณาว่าเป็น edge case ในสภาพแวดล้อมส่วนใหญ่ กลายเป็นปัญหาใน production เมื่อคุณกำลังจัดการกับการรับส่งข้อมูลระดับอินเทอร์เน็ต นอกจากนี้ยังแสดงให้เห็นถึงคุณค่าของระบบนิเวศ open source ที่บั๊กดังกล่าวสามารถถูกระบุ รายงาน และแก้ไขได้อย่างรวดเร็วผ่านการทำงานร่วมกันระหว่างบริษัทและผู้ดูแลภาษา
ในขณะที่ซอฟต์แวร์ยังคงพัฒนาต่อไปและสถาปัตยกรรมใหม่ๆ ได้รับความนิยมมากขึ้น ปฏิสัมพันธ์ที่ละเอียดอ่อนระหว่างคอมไพเลอร์ รันไทม์ และฮาร์ดแวร์ที่คล้ายกันจะยังคงปรากฏให้เห็น แนวทางการดีบั๊กอย่างเป็นระบบของทีม Cloudflare ให้พิมพ์เขียวสำหรับวิธีการที่องค์กรวิศวกรรมสามารถจัดการกับปัญหาท้าทายดังกล่าวได้