ในฝั่ง Java / Spring Boot คุณคุ้นเคยกับคำว่า: "Map ก็แค่กล่องเก็บ Key-Value คู่กัน อยากได้อะไรก็ map.get(key) จบ"
แต่ในระดับวิศวกรรมระบบของ Go ทีมออกแบบต้องเผชิญปัญหาฮาร์ดแวร์ 2 ข้อใหญ่:
HashMap ทุกครั้งที่ค้นหา CPU ต้องกระโดดข้ามตำแหน่ง RAM ไปมา (Pointer Chasing) ทำให้ระบบช้าลงมหาศาลGo จึงแก้ปัญหานี้ด้วยการแบ่งหน้าที่สถาปัตยกรรมออกเป็น 4 คำศัพท์สำคัญ:
count), จำนวนถังทั้งหมด (คำนวณผ่าน B), และถือ Pointer ชี้ไปยังอาร์เรย์ของถังข้อมูลจริงใน Go runtime ตัวแปร m := make(map[K]V) คือ Pointer ขนาด 8 Bytes ที่ชี้ไปยัง Struct hmap บน Heap:
type hmap struct {
count int // จำนวน Element ทั้งหมดใน Map
flags uint8 // บิตตรวจจับ Concurrent Read/Write (ถ้าชนกันจะ Fatal Crash)
B uint8 // ค่า log2 ของจำนวน Buckets (มีทั้งหมด 2^B buckets)
noverflow uint16 // จำนวน overflow buckets โดยประมาณ
hash0 uint32 // Hash seed สุ่มเพื่อป้องกัน Hash Flooding DoS
buckets unsafe.Pointer // Pointer ชี้ไปยังอาร์เรย์ของ bmap ตัวจริง
oldbuckets unsafe.Pointer // Pointer ชี้ไปยังอาร์เรย์เดิมระหว่างทำ Evacuation
nevacuate uintptr // ความคืบหน้าของการย้ายข้อมูลไปยัง Bucket ใหม่
}
ถ้าคุณไปเปิดดูซอร์สโค้ด Go ที่ไฟล์ src/runtime/map.go คุณจะพบเพียง:
type bmap struct {
tophash [8]uint8
}
ทำไมถึงมีแค่นี้? เพราะ Go ไม่มี Generics ในระดับ Runtime และไม่ต้องการทำ Boxing ให้เป็น interface{}/Object บน Heap เหมือน Java HashMap<K,V> ตัว Go Compiler จึงทำการ Synthesized โครงสร้าง Struct จริงขึ้นมาในหน่วยความจำตามชนิดของ Key และ Value ดังนี้:
type bmap struct {
tophash [8]uint8 // 8 บิตบนของ Hash Code สำหรับเปรียบเทียบสล็อตอย่างรวดเร็ว (8 Bytes)
keys [8]KeyType // อาร์เรย์เก็บ Key เรียงติดกัน 8 ตัว (ไม่สลับกับ Value!)
elems [8]ValueType // อาร์เรย์เก็บ Value เรียงติดกัน 8 ตัว
overflow *bmap // Pointer 8 Bytes ชี้ไปยัง Overflow Bucket ถัดไปเมื่อเกิด Collision เกิน 8 ตัว
}
Key/Value, Key/Value ในกรณีที่ Key และ Value มีขนาดไบต์ไม่เท่ากัน (เช่น Key 1 Byte, Value 8 Bytes) จะเกิด Alignment Padding แทรก 7 Bytes ทุกๆ คู่ รวมเสียพื้นที่ทิ้งเปล่า 56 Bytes ต่อ Bucket!keys [8]Key แล้วตามด้วย elems [8]Value ทำให้ข้อมูลขนาดเท่ากันเรียงติดกัน กำจัดรูโหว่ Padding ทิ้งได้ 100%
หากชนิดข้อมูลของ Key หรือ Value มีขนาด มากกว่า 128 Bytes ตัว Go Compiler จะปรับสถาปัตยกรรมอัตโนมัติ โดยไม่เก็บก้อน Struct ก้อนยักษ์นั้นลงใน bmap ตรงๆ แต่จะเปลี่ยนไปเก็บ Pointer ขนาด 8 ไบต์ชี้ไปยัง Heap แทน เพื่อป้องกันไม่ให้ขนาดของ bmap บวมจนหลุดออกจากขนาด CPU L1/L2 Cache Line
map.get(key).setCheckpoint(2) ได้เพราะมันคือ Reference แต่ใน Go: ค่า Value ที่เก็บใน Map ไม่สามารถขอ Memory Address ได้ (Unaddressable) ห้ามสั่ง &m[k] หรือแก้ไขฟิลด์ของ Struct ใน Value โดยตรง เช่น m[bib].Checkpoint = 2 จะคอมไพล์ไม่ผ่านทันที เพราะตำแหน่ง RAM ของ Struct นั้นอาจถูกย้ายตลอดเวลาเมื่อเกิด Evacuation!
เมื่อมีคำสั่งค้นหานักวิ่งด้วยคำสั่ง s := m[key] ขั้นตอนในระดับ Assembly และ RAM มีดังนี้:
hmap.hash0 ได้รหัสแฮชขนาด 64-bit1011 = 11 $\to$ ตกใน Bucket เบอร์ 11) จากนั้นกระโดดไปยัง Address ของ Bucket นั้นทันทีtophash กวาดเทียบกับอาร์เรย์ tophash [8]uint8 ใน Bucket ด้วย Vectorized Instructiontophash ตรงกัน จึงเข้าไปเทียบค่า Key ใน keys[i] ถ้าตรงกันจึงดึงข้อมูลจาก elems[i] ส่งกลับoverflow != nil ตัว Runtime จะวิ่งตาม Pointer ไปค้นหาต่อใน Overflow Bucket จนกว่าจะเจอหรือสิ้นสุด linked-listGo Map จะเริ่มเตรียมขยายขนาดเมื่อเข้าเงื่อนไขข้อใดข้อหนึ่ง:
oldbuckets ไว้ และจอง buckets ใหม่ที่มีขนาด 2 เท่าoldbucketsrecover() ดักจับได้! ในงานระบบ Concurrent ต้องป้องกันด้วย sync.RWMutex หรือใช้ sync.Map เสมอ
InitRunnerIndex(expectedRunners int) map[int64]stringmake(map[int64]string, cap) จอง Bucket ล่วงหน้าเพื่อไม่ให้เกิด Incremental Evacuation ระหว่าง IngestionexpectedRunners <= 0 ให้คืนค่า empty map ที่จองขนาด 0RunnerSplit มีฟิลด์ Bib int64 และ Checkpoint int64UpdateCheckpoint(m map[int64]RunnerSplit, bib int64, newCP int64) ต้องอัปเดต Checkpoint ให้สำเร็จcannot assign to struct field in map ด้วยการดึง Value ออกมาแก้แล้ว Re-assign กลับลง MapRecordUniqueCheckpoints(chips []int64) map[int64]struct{}map[int64]struct{} เพื่อทำ Set จัดเก็บคีย์ที่ไม่ซ้ำกัน โดย Value ต้องใช้ struct{}{} ซึ่งมีขนาด 0 Bytes เพื่อประหยัด RAM สูงสุด