☕ 5-Min Tech Story #12

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 ไบต์ พอดีเป๊ะ:

  1. Core 1 สั่งบวก counterA ➔ กลไก Cache Coherency จะสั่งเตะบอก Core 2 ทันทีว่า "เฮ้ย! กล่องใบนี้อัปเดตแล้วนะ ของในแคชแกหมดอายุแล้ว ทิ้งไปซะ!"
  2. Core 2 โดนบังคับทิ้งแคชทิ้ง ต้องเสียเวลาวิ่งลงไปโหลดกล่องใหม่จาก RAM หรือ L3 Cache ขึ้นมาเพื่อจะแก้ counterB
  3. พอ 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):

// สั่งให้ JVM ใส่ Padding 128 ไบต์กั้นไว้รอบตัวแปรอัตโนมัติ
@Contended
var counterA: Long = 0L

@Contended
var counterB: Long = 0L

💡 5-Min Takeaway

"โค้ดระดับ High Performance ไม่ได้ต่อสู้แค่กับอัลกอริทึม แต่สู้กับข้อจำกัดทางกายภาพของชิป CPU" การแยก Thread ให้ทำงานอิสระอาจไร้ความหมาย ถ้าตัวแปรเหล่านั้นอยู่ชิดกันเกินไปในระดับไบต์ การเข้าใจเรื่อง Cache Line ช่วยให้เรารู้ว่าบางครั้ง การยอมเปลือง RAM อีกนิดเพื่อเว้นระยะห่าง จะแลกมาด้วยความเร็วที่พุ่งขึ้นหลายเท่าตัว