False Sharing: ทำงานคนละตัวแปร แต่ทำไม CPU ถึงแย่งกันตบจนเครื่องอืด?
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที
สมมติว่าคุณกำลังจูนระบบคำนวณตัวเลขความเร็วสูง
คุณสร้างตัวแปรนับจำนวนขึ้นมา 2 ตัว: counterA และ
counterB
คุณส่ง Thread 1 ไปบวกเลขที่ counterA ตัวเดียว และส่ง
Thread 2 ไปบวกเลขที่ counterB ตัวเดียว... ไม่มี Lock,
ไม่มีการแย่งตัวแปรเดียวกัน, แยกกันทำอิสระ 100%
ตามทฤษฎี พอรัน 2 Thread บน CPU 2 คอร์ งานควรจะเสร็จเร็วขึ้น 2 เท่าใช่ไหมครับ? แต่ในโลกความเป็นจริง มันกลับช้ากว่ารัน Thread เดียวบนคอร์เดียวซะงั้น!
📦 กฎเหล็กของฮาร์ดแวร์: CPU ไม่เคยซื้อของชิ้นเดียว
ความลับอยู่ที่ระดับ L1/L2 Cache ของ CPU
เวลา CPU Core 1 จะไปดึงตัวแปร counterA (ขนาดแค่ 8 Bytes)
ขึ้นมาจาก RAM มันจะไม่เดินไปหยิบของชิ้นเดียวเดี่ยวๆ
แต่ฮาร์ดแวร์ถูกออกแบบมาให้
"ยกของมาทั้งกล่อง" ที่เรียกว่า
Cache Line ซึ่งมีขนาดมาตรฐานคือ
64 Bytes เสมอ!
"ลองนึกภาพสมุดโน้ต 1 เล่ม (Cache Line) ที่มีข้อความของนาย A อยู่หน้าซ้าย และของนาย B อยู่หน้าขวา ถ้าทั้งคู่จะเขียนข้อความพร้อมกัน ถึงจะเขียนคนละหน้า แต่ก็ต้องแย่งสมุดเล่มเดียวกันอยู่ดี!"
🥊 สงครามตบตีข้ามคอร์ (Cache Invalidation)
เพราะตัวแปร counterA กับ
counterB ถูกประกาศติดกันในหน่วยความจำ
ทั้งสองตัวจึงบังเอิญ
ตกอยู่ใน Cache Line ก้อนเดียวกัน 64 ไบต์ พอดีเป๊ะ:
-
Core 1 สั่งบวก
counterA➔ กลไก Cache Coherency จะสั่งเตะบอก Core 2 ทันทีว่า "เฮ้ย! กล่องใบนี้อัปเดตแล้วนะ ของในแคชแกหมดอายุแล้ว ทิ้งไปซะ!" -
Core 2 โดนบังคับทิ้งแคชทิ้ง
ต้องเสียเวลาวิ่งลงไปโหลดกล่องใหม่จาก RAM หรือ L3 Cache
ขึ้นมาเพื่อจะแก้
counterB -
พอ Core 2 แก้
counterBเสร็จ ก็ส่งสัญญาณกลับไปตบกล่องของ Core 1 ให้พังต่อ วนลูปไม่รู้จบ!
ปรากฏการณ์นี้เรียกว่า False Sharing (การแชร์เทียม) คือในโค้ดไม่ได้แชร์อะไรกันเลย แต่ในระดับฮาร์ดแวร์ดันยัดลงกล่องเดียวกัน ทำให้ CPU มัวแต่เสียเวลาแย่งสัญญาณแคชไปมา แทนที่จะได้คำนวณงานจริงๆ
🛡️ ท่ายัดฟองน้ำกันกระแทก: Cache Padding & @Contended
วิธีแก้ปัญหาฟังดูตลกแต่ได้ผลชะงัด: "ถ้ากลัวอยู่กล่องเดียวกัน ก็ยัดช่องว่าง (Padding) คั่นกลางซะเลยสิ!"
เราแค่ยัดตัวแปรขยะ (เช่น ตัวแปร long ว่างๆ 7-8 ตัว รวมกัน
56 ไบต์) คั่นระหว่าง counterA และ
counterB เพื่อผลักให้ตัวแปรที่สองกระเด็นไปตกอยู่อีก Cache
Line คนละกล่อง
หรือในโลก Java/Kotlin ยุคใหม่ JVM มี Annotation ระดับเทพมาให้ใช้คือ
@jdk.internal.vm.annotation.Contended
(ซึ่งเป็นความลับเบื้องหลังคลาสความเร็วสูงอย่าง
LongAdder):
@Contended
var counterA: Long = 0L
@Contended
var counterB: Long = 0L
💡 5-Min Takeaway
"โค้ดระดับ High Performance ไม่ได้ต่อสู้แค่กับอัลกอริทึม แต่สู้กับข้อจำกัดทางกายภาพของชิป CPU" การแยก Thread ให้ทำงานอิสระอาจไร้ความหมาย ถ้าตัวแปรเหล่านั้นอยู่ชิดกันเกินไปในระดับไบต์ การเข้าใจเรื่อง Cache Line ช่วยให้เรารู้ว่าบางครั้ง การยอมเปลือง RAM อีกนิดเพื่อเว้นระยะห่าง จะแลกมาด้วยความเร็วที่พุ่งขึ้นหลายเท่าตัว