2 คะแนน โดย GN⁺ 2025-10-09 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Cloudflare ค้นพบบั๊กแบบ race condition ที่พบได้ยาก ใน คอมไพเลอร์ Go ที่ทำงานบนแพลตฟอร์ม arm64 ระหว่างการเฝ้าระวังทราฟฟิกขนาดใหญ่
  • บั๊กนี้แสดงอาการเป็นการที่บริการเกิด panic โดยไม่คาดคิดระหว่างกระบวนการ stack unwinding หรือเกิด ข้อผิดพลาดในการเข้าถึงหน่วยความจำ
  • ระหว่างการไล่หาสาเหตุ พบว่าปัญหาเกิดขึ้นระหว่าง asynchronous preemption (การแย่งการทำงานแบบบังคับ) ของ Go runtime กับ คำสั่งปรับ stack pointer สองคำสั่งที่คอมไพเลอร์สร้างขึ้น
  • ด้วยโค้ดสำหรับทำซ้ำปัญหาแบบย่อที่สุด ได้พิสูจน์ว่าบั๊กนี้เป็น ปัญหาใน Go runtime เอง และเผยให้เห็น race state ระดับหนึ่งคำสั่ง ที่ทำให้ stack pointer ถูกเปลี่ยนไม่สมบูรณ์
  • ปัญหานี้ถูกแพตช์แล้วในเวอร์ชัน go1.23.12, go1.24.6, go1.25.0 โดยวิธีใหม่จะหลีกเลี่ยงการจัดการ stack pointer ที่ไม่สามารถเปลี่ยนได้ทันที ทำให้ปิด race condition นี้ได้จากต้นตอ

การวิเคราะห์บั๊กในคอมไพเลอร์ Go ARM64 ที่ Cloudflare พบ

ดาต้าเซ็นเตอร์ของ Cloudflare ประมวลผลคำขอ HTTP ราว 84 ล้านรายการต่อวินาทีในกว่า 330 เมืองทั่วโลก และสภาพแวดล้อมทราฟฟิกขนาดใหญ่นี้มีลักษณะเฉพาะคือ แม้แต่บั๊กที่พบได้ยากก็ยังโผล่ขึ้นมาบ่อย บทความนี้วิเคราะห์อย่างละเอียดผ่านกรณีจริงเกี่ยวกับ ปัญหา race condition ที่เกิดจากโค้ดซึ่งคอมไพเลอร์ Go สร้างบนแพลตฟอร์ม arm64

การสืบสวนอาการ panic แปลกประหลาด

  • ภายในเครือข่ายของ Cloudflare มีบริการที่ตั้งค่าการประมวลผลทราฟฟิกของผลิตภัณฑ์อย่าง Magic Transit และ Magic WAN ลงใน kernel อยู่
  • บนเครื่อง arm64 ระบบมอนิเตอร์ตรวจพบข้อความ fatal panic เป็นครั้งคราวแต่เกิดซ้ำได้
  • จากการวิเคราะห์เบื้องต้น พบการละเมิด integrity ระหว่างกระบวนการ stack unwinding (ในโค้ดเก่าที่ใช้รูปแบบ panic/recover มีการเกิด panic บ่อยครั้ง)
  • มีการเอาโครงสร้าง panic/recover ออกชั่วคราวเพื่อลดความถี่ของ panic แต่ภายหลังกลับพบว่า fatal panic ที่น่าสงสัยเกิดบ่อยขึ้นกว่าเดิม
  • จึงสรุปได้ว่าจำเป็นต้องวิเคราะห์สาเหตุเชิงลึก มากกว่าการไล่ตามรูปแบบอาการแบบผิวเผิน

ภาพรวมโครงสร้างข้อมูลของ Go runtime และ scheduler

  • Go ใช้ โครงสร้างการจัดตารางแบบ M:N ด้วย user-space scheduler แบบเบา (แมป goroutine หลายตัวลงบน kernel thread จำนวนน้อย)
  • โครงสร้างหลักของ scheduler ประกอบด้วย g(goroutine), m(machine/kernel thread), p(processor)
  • ความล้มเหลวของ stack unwinding หรือข้อผิดพลาดในการเข้าถึงหน่วยความจำ มักเกิดขึ้นเมื่อ stack pointer หรือ return address เปลี่ยนไปอย่างผิดปกติ

สาเหตุเชิงโครงสร้างของข้อผิดพลาดระหว่าง stack unwinding

  • จากการวิเคราะห์ backtrace หลายชุด พบว่าทั้งหมดเกิดขึ้นระหว่างกระบวนการ stack unwinding ในฟังก์ชัน (*unwinder).next
  • กรณีหนึ่งเกิดจาก return address เป็น null จึงถูกมองว่าเป็นสแตกผิดปกติและจบด้วยข้อผิดพลาดร้ายแรง ส่วนอีกกรณีเกิด segmentation fault ขณะพยายามเข้าถึงฟิลด์ของโครงสร้าง m ของ Go scheduler (incgo) ภายใน stack frame
  • จุดที่เกิด crash อยู่ห่างจากจุดที่บั๊กเกิดจริงพอสมควร ทำให้การไล่หาต้นตอยุ่งยาก

รูปแบบที่สังเกตได้และความเกี่ยวข้องกับไลบรารี Go Netlink

  • เมื่อตรวจสอบ stack trace พบว่าการ crash ทั้งหมดกระจุกตัวอยู่ในช่วงที่เกิด preemption ภายในฟังก์ชัน NetlinkSocket.Receive ของ ไลบรารี Go Netlink
  • จากนั้นจึงตั้งสมมติฐานไว้สองข้อ
    • อาจเป็นบั๊กที่มีต้นตอจากการใช้ unsafe.Pointer ใน Go Netlink
    • อาจเป็นบั๊กที่เกิดใน asynchronous preemption และ stack unwinding ของ Go runtime เอง
  • แม้จะมีการ audit โค้ด แต่ไม่พบรูปแบบความเสียหายของหน่วยความจำโดยตรง จึงคาดว่าหัวใจของปัญหาอยู่ที่ runtime และกลยุทธ์การจัดการสแตก

Asynchronous preemption และ race condition

  • ฟีเจอร์ asynchronous preemption ที่ถูกเพิ่มเข้ามาตั้งแต่ Go 1.14 จะส่งสัญญาณ (SIGURG) ไปยัง OS thread เพื่อบังคับสร้าง scheduling point ให้กับ goroutine ที่ทำงานยาวนาน
  • หาก preemption นี้เกิดขึ้น ระหว่างคำสั่งแอสเซมบลีสองคำสั่งที่กำลังปรับ stack frame pointer stack pointer จะค้างอยู่ในสถานะกึ่งกลาง
  • เมื่อต้อง unwind สแตกเพื่อทำ garbage collection, จัดการ panic หรือสร้าง stack trace ก็อาจ อ่านตำแหน่งผิดและตีความที่อยู่ฟังก์ชันหรือข้อมูลผิดพลาด

การสร้างโค้ดสำหรับทำซ้ำปัญหาแบบย่อที่สุด

  • มีการปรับขนาดการจัดสรร stack frame และเขียนฟังก์ชันที่ปรับสแตกอย่างชัดเจน (big_stack) ร่วมกับโค้ดที่ เรียก garbage collection ตลอดเวลา จนสามารถทำซ้ำ race condition นี้ได้
  • ในแอสเซมบลีจริง stack pointer ถูกปรับด้วยคำสั่ง ADD สองคำสั่ง และถ้า asynchronous preemption เกิดขึ้นตรงกลาง กระบวนการ stack unwinding จะ crash
  • ข้อบกพร่องนี้สามารถทำซ้ำได้แม้ใช้เพียงโค้ดจาก standard library ล้วน ๆ ซึ่งพิสูจน์ว่าเป็นช่องโหว่ระดับจำนวนครั้งของคำสั่ง (ขนาด 1 instruction) ที่ฝังอยู่ในโค้ดที่คอมไพเลอร์ Go สร้าง

สาเหตุของ race window ระดับคอมไพเลอร์บน ARM64

  • เนื่องจากสถาปัตยกรรม ARM64 ใช้คำสั่งความยาวคงที่และมีข้อจำกัดด้าน immediate value ทำให้ การปรับ stack pointer อาจต้องใช้มากกว่าหนึ่งคำสั่ง
  • ใน internal IR ของ Go ไม่มีการรับรู้ความยาวของ immediate value นี้ และจะมีการแทรกคำสั่งที่ถูกแยกออกเฉพาะตอนแปลงเป็น machine code จริง
  • ด้วยเหตุนี้ การคืน stack frame (ADD RSP, RSP) จึงใช้สองคำสั่ง และก่อให้เกิดหน้าต่างความเปราะบางต่อ preemption ขนาดหนึ่ง instruction
  • unwinder จำเป็นต้องพึ่งความถูกต้องของ stack pointer แบบสมบูรณ์ ดังนั้นถ้าการทำงานหยุดกลางคำสั่ง จะนำไปสู่การตีความค่าผิดและความล้มเหลวร้ายแรง
  • ลำดับการ crash จริงมีดังนี้:
    1. เกิด asynchronous preemption ระหว่างคำสั่ง ADD สองคำสั่ง
    2. GC หรือสาเหตุอื่นทำให้ routine สำหรับ stack unwinding เริ่มทำงาน
    3. สำรวจตำแหน่ง stack pointer ที่ผิดปกติและตีความที่อยู่ฟังก์ชันผิด
    4. runtime crash

การแก้บั๊กและการปรับปรุงจากต้นตอ

  • ทีม Cloudflare รายงานเรื่องนี้ไปยัง คลังทางการของ Go พร้อมโค้ดทำซ้ำปัญหาแบบย่อที่สุดและรายละเอียดการวิเคราะห์ และปัญหาก็ถูกแพตช์และออกรีลีสอย่างรวดเร็ว
  • ตั้งแต่เวอร์ชัน go1.23.12, go1.24.6, go1.25.0 เป็นต้นไป จะ คำนวณ offset ทั้งหมดในรีจิสเตอร์ชั่วคราวก่อน แล้วจึงเปลี่ยน stack pointer ด้วยคำสั่งเดียว เพื่อลบจุดอ่อนต่อ preemption
  • ตอนนี้รับประกันได้ว่า stack pointer จะอยู่ในสถานะที่ถูกต้องเสมอ ทำให้ race condition นี้ถูกปิดกั้นเชิงโครงสร้าง
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

บทสรุปและนัยสำคัญ

  • บั๊กนี้เป็นกรณีตัวอย่างที่ การสร้างโค้ดของคอมไพเลอร์บนสถาปัตยกรรมเฉพาะ กับ การจัดการ concurrency (asynchronous preemption) ชนกันในรูปแบบที่คาดไม่ถึง
  • เป็นกรณีที่น่าสนใจเพราะสามารถไล่ตาม race condition ระดับ instruction ที่พบได้ยากมาก ซึ่งมักปรากฏเฉพาะในสภาพแวดล้อมขนาดใหญ่ ด้วยข้อมูลจากระบบจริงและการอนุมานอย่างเป็นวิทยาศาสตร์
  • หากคุณดูแลบริการที่ทำงานบน Go รุ่นใหม่และสถาปัตยกรรม ARM64 การอัปเกรดไปยังเวอร์ชัน Go ที่เกี่ยวข้องถือว่าสำคัญ

1 ความคิดเห็น

 
GN⁺ 2025-10-09
ความคิดเห็นจาก Hacker News
  • รู้สึกว่านี่เป็นการค้นพบที่ยอดเยี่ยมมาก และพอเห็นโค้ดแอสเซมบลีปุ๊บก็ตามเส้นทางการดีบักได้ทันที จริง ๆ วิธีนี้ไม่ได้ทำได้เฉพาะในแอสเซมบลีเท่านั้น ในขั้น IR ก็น่าจะทำได้เหมือนกัน แต่ด้วยเหตุผลหลายอย่างจึงไม่ได้ทำแบบนั้น การอ่าน ARM assembly ได้ถือเป็นข้อได้เปรียบมาก เคยคิดเหมือนกันว่าจะลอง push หรือ pop ขนาดสแตกเพื่อลดจำนวนคำสั่ง แต่ไม่แน่ใจว่า GC ตรวจอะไรอย่างแม่นยำบ้าง เลยยังไม่กล้าฟันธง อยากฟังความเห็นอื่น
    • โดยทั่วไปจะใช้ pseudoinstruction ของ ARM ที่ชื่อว่า LDR Rd, =expr สำหรับค่าคงที่ที่สร้างตรง ๆ ไม่ได้ จะวางค่าคงที่ไว้ในตำแหน่งแบบ PC-relative แล้วโหลดเข้ารีจิสเตอร์โดยอิงจาก PC วิธีนี้ทำให้ขั้นตอน “บวกค่าคงที่เข้ากับ SP” กลายเป็นคำสั่งที่รันจริง 2 คำสั่ง และต้องใช้โค้ด 8 ไบต์กับพื้นที่ข้อมูล 4 ไบต์ (สำหรับค่าคงที่ 17 บิต) รวมเป็น 12 ไบต์ เอกสารที่เกี่ยวข้อง: คำอธิบาย LDR pseudo-instruction
    • น่าแปลกที่กรณีพิเศษของการบวก immediate เข้ากับ RSP นี้ไม่ได้ถูกจัดการเป็นกรณีพิเศษใน assembler ถ้าแพตช์ถูกใช้เฉพาะฝั่งคอมไพเลอร์ ปัญหาเดียวกันก็อาจยังเหลืออยู่ในส่วนอื่นของ aarch64 assembly
    • ไวยากรณ์ ARM assembly ที่มีเครื่องหมายดอลลาร์อยู่ในนิพจน์แปลก ๆ นั้นไม่ใช่ AArch64 assembly มาตรฐาน และน่าจะดีถ้าบทความกล่าวถึงกฎที่ว่า “สแตกต้องถูกขยับเพียงครั้งเดียว” ไว้ด้วย
    • ในรันไทม์อย่าง Java หรือ .NET จะกำหนด safepoint ไว้อย่างชัดเจนเพื่อป้องกันไม่ให้คอนเท็กซ์เปลี่ยนกลางชุดคำสั่ง
    • ดูเหมือนว่าทางแก้ที่ถูกต้องคือให้คอมไพเลอร์ใส่ค่าคงที่เข้ารีจิสเตอร์เป็นสองรอบแล้วค่อย add ครั้งเดียวเพื่อปรับ SP แบบอะตอมมิก แน่นอนว่าคำสั่งจะเพิ่มขึ้นอีกหนึ่งคำสั่ง แต่ก็ได้ atomicity หรือไม่ก็อาจคำนวณผ่านรีจิสเตอร์ชั่วคราวแล้วค่อยย้ายกลับ
  • สำหรับคนที่รีบ ขอแชร์ลิงก์คอมมิตที่แก้ไขไว้: ลิงก์คอมมิต golang/go
    • ตอนดูใน issue ก็เกิดคำถามว่า ทีม Go ใช้บอตภาษาธรรมชาติอยู่หรือเปล่า หรือแค่เช็กคีย์เวิร์ดอย่าง backport ในคอมเมนต์เฉย ๆ คอมเมนต์ที่เกี่ยวข้อง: github issue comment
  • เป็นบล็อกเชิงเทคนิคที่ยอดเยี่ยมมาก คำอธิบายชัดเจนจนเข้าใจง่ายมากจนรู้สึกเหมือนตัวเองฉลาดขึ้น ทั้งที่ไม่ได้แตะ assembly มานานตั้งแต่ x86 ก็ยังตามได้ไม่ยาก และยังทำให้รู้สึกเชื่อมั่นด้วยว่าทีมแบบนี้มีทั้งความสามารถในการแก้ปัญหาแนวนี้และการควบคุมคุณภาพที่ดี เคยพิจารณา Ampere Altra เหมือนกันสำหรับการขยายเซิร์ฟเวอร์ แต่เพราะมีพื้นที่เหลือพอเลยใช้ Epyc แทน
  • คิดว่าถ้าใน Go มีโหมดที่ single-step ทุกคำสั่งและทำให้เกิด GC interrupt ทุกคำสั่ง ก็น่าจะหาบั๊กแบบนี้ได้ง่ายขึ้น
  • สงสัยว่า ARM64 server ถูกใช้ทำอะไรบ้าง ปีที่แล้วเห็นพูดถึงการออกเซิร์ฟเวอร์ Gen 12 ที่ใช้ AMD EPYC แต่ไม่เห็นพูดถึง ARM64 ตอนนี้ ARM64 ถูกใช้ใน production แล้วหรือ
    • ผมไม่ใช่พนักงาน Cloudflare แต่จากที่อ่านบล็อกมาหลายชิ้น เท่าที่รู้คือเมื่อคำนึงถึง secure boot และเรื่องอื่น ๆ พวกเขาก็วาง Ampere ควบคู่กับ AMD มาตั้งแต่หลายปีก่อนแล้ว ดูเหมือนเป้าหมายหลักคือประสิทธิภาพฝั่ง edge แต่อาจมีการใช้งานอื่นด้วย ดูข้อมูลเพิ่มได้จาก บทความการออกแบบ edge server, Ampere Altra vs AWS Graviton2 และ การประเมิน ARM ของ Qualcomm
    • จำได้ว่า Cloudflare โฮสต์งานคอมพิวต์ที่ไม่ใช่ edge บางส่วนไว้บน public cloud เช่น control plane ก็อาจเป็นไปได้เหมือนกัน
  • เคยนึกว่าเดี๋ยวนี้ Cloudflare ใช้แต่ Rust 100% กับ x86 (EPYC) เท่านั้น น่าสนใจที่ยังใช้ Go และ ARM อยู่ด้วย
  • ทุกครั้งที่อ่านบทความในบล็อก Cloudflare ก็รู้สึกว่าเป็นคอนเทนต์ที่ยอดเยี่ยม เพราะถ่ายทอดแก่นแท้ของวิศวกรรมโดยไม่ต้องพึ่งเรื่องอินฟราหรือเวทมนตร์ ML สักวันหนึ่งอยากสมัครงานที่นี่เหมือนกัน บั๊กคอมไพเลอร์จริง ๆ แล้วพบได้บ่อยกว่าที่คิด (สมัยก่อนเคยเจอใน gcc ปีละหลายตัว) แต่หลายกรณีก็หายากแบบที่จะแสดงอาการก็ต่อเมื่อสเกลใหญ่แบบในบทความเท่านั้น คนส่วนใหญ่ไม่ค่อยได้เจอสเกลระดับนั้น
    • สงสัยว่าทำไมวันนี้ยังไม่สมัคร
  • ขอย้ำว่าสแตกพอยน์เตอร์ต้องถูกปรับแบบอะตอมมิกเสมอ
    • คนที่เขียน preemption น่าจะเขียนโค้ดโดยยึดจาก x86 เป็นหลัก (ซึ่งในกรณีนี้คำสั่งสามารถบรรจุค่าคงที่ได้เลยจึงทำได้แบบอะตอมมิก) แล้วพอตอนพอร์ตไป ARM มันถูกแยกโดยอัตโนมัติในระดับที่สูงกว่า เลยเกิดบั๊กนี้ขึ้น ไม่ใช่ความผิดของใคร แต่ผลลัพธ์ก็ไม่ดีนัก
    • นี่แหละคือสิ่งที่ผมนึกขึ้นมาได้ทันที
  • ยังไม่ค่อยเข้าใจว่า machine thread ไปหยุดอยู่กลางระหว่างสองคำสั่งได้อย่างไร สงสัยว่าบน bare metal จะเกิดเรื่องแบบนี้ได้ไหม
    • go ใช้ interrupt สำหรับ GC notification
    • signals
  • สำหรับประโยคที่ว่า “เป็นปัญหาที่สนุกมาก” ผมคิดว่าแม้ตอนแก้ปัญหารากฐานแบบนี้สำเร็จจะต้องโล่งใจมากแน่ ๆ แต่ตอนที่มันยังแก้ไม่ได้คงไม่สนุกเลย บั๊กแบบนี้เป็นประสบการณ์ที่ดูดพลังประสาทไปทั้งหมด มีวัฒนธรรมอย่างหนึ่งคือไม่มีใครคิดว่า standard library หรือ compiler จะเป็นต้นตอ ทำให้นักพัฒนามักสงสัยโค้ดตัวเองอยู่ตลอด ผมเองก็เคยเจอบั๊กใน standard library ครั้งหนึ่ง และสิ่งสุดท้ายที่คิดว่าจะผิดก็คือฝั่ง SDK ผลคือเสียเวลาไปกับที่ผิด ๆ แถมถ้าเป็น race condition แบบกรณีนี้ก็ยิ่งทำซ้ำยาก มันจะชอบหายไปจนคิดว่าหมดแล้ว แล้วก็โผล่กลับมาอีก
    • คอมเมนต์นี้แม้จะเล่าประสบการณ์คล้ายกันของตัวเองเพิ่มเติม แต่การยกมาค้านเรื่องที่ผู้เขียนรู้สึกสนุกกลับทำให้ความประทับใจลดลงไปนิดหน่อย คนเราสนุกกับคนละเรื่องได้
    • บางคนกลับรู้สึกยินดีกับการดีบักที่แปลกและทรหดแบบที่คนอื่นอาจทุกข์มาก สิ่งที่เป็นความหงุดหงิดของคนหนึ่งอาจเป็นความสนุกของอีกคน
    • คิดว่าสิ่งที่ผู้เขียนน่าจะอยากสื่อคือไม่ใช่ “สนุก (funny)” แต่เป็น “น่าพอใจ (satisfying)” มากกว่า ผมเองก็เคยไล่จับบั๊ก sscanf ใน Ubuntu GCC ARM toolchain ตอนโดนเดดไลน์ไล่หลัง ตอนนั้นไม่สนุกเลย แต่หลังจากระบุปัญหาได้แม่นและเขียน regression test เสร็จแล้ว มันรู้สึกน่าพอใจมากจริง ๆ
    • การแก้ข้อบกพร่องลึก ๆ แบบนี้ได้ พอคลี่คลายแล้วจะรู้สึกปลดปล่อยมาก ผมเองก็เคยรู้สึกสนุกที่สุดเวลาแก้บั๊กฝั่ง compiler หรือ CPU
    • เวลาเกิด segfault ในภาษาแบบ managed โดยที่ไม่ได้ใช้พวก Unsafe เลย ผมมักถือว่านั่นเป็นสัญญาณว่าปัญหาอาจไม่ได้อยู่ที่โค้ดของผม