Go Interview Prep — คำถามที่พบบ่อย
รวมคำถามเชิง conceptual ที่มักถูกถามในรอบ technical interview พร้อมแนวคำตอบสั้นกระชับ (ฝึกพูดออกเสียงเป็นภาษาอังกฤษด้วยกล่อง English Summary ของแต่ละข้อ)
1. Slice vs Array ต่างกันอย่างไร?
Array มีขนาดคงที่ตอน compile time เป็นส่วนหนึ่งของ type ([5]int กับ [10]int คือคนละ type)
Slice คือ struct เล็กๆ (pointer + len + cap) ที่ "ชี้" ไปยัง array ภายใน ทำให้ resize ได้แบบ
dynamic ผ่าน append — เกือบทุกที่ในโค้ด Go จริงใช้ slice ไม่ใช่ array
จำว่า "Array = กล่องขนาดตายตัว, Slice = รีโมทที่ปรับช่องได้" — Beyond: array ที่ assign ให้ตัวแปรอื่นหรือส่งเข้าฟังก์ชันจะ copy ทั้งก้อนเสมอ (value type เต็มรูปแบบ) ต่างจาก slice ที่ copy แค่ header (ptr+len+cap) คนที่ส่ง array ขนาดใหญ่เข้าฟังก์ชันโดยไม่รู้ตัวว่ามัน copy ทั้งก้อน คือสาเหตุ performance bug ที่ profiler จับได้บ่อยแต่คนเขียนไม่ทันสังเกต
2. make กับ new ต่างกันอย่างไร?
new(T) จองหน่วยความจำสำหรับ zero value ของ T แล้ว return *T ใช้ได้กับทุก type
make(T, ...) ใช้ได้เฉพาะ slice, map, channel และ return ค่า ที่ initialize แล้ว
พร้อมใช้งาน (ไม่ใช่ pointer) — เพราะ 3 type นี้ต้องมี internal state (เช่น hashmap bucket)
ที่ zero value เปล่าๆ ใช้งานไม่ได้ทันที
จำว่า "new ให้บ้านเปล่า (pointer), make ให้บ้านพร้อมอยู่ (ready-to-use value)" — Beyond:
ในทางปฏิบัติแทบไม่มีใครใช้ new() เลยในโค้ด Go จริง (นิยม &T{} มากกว่าเพราะเซ็ตค่า field
พร้อมกันได้ในที) new() มักเจอแค่ในโจทย์สัมภาษณ์หรือโค้ดเก่ามากๆ — รู้ไว้ตอบได้ แต่ไม่ต้องใช้จริง
3. อธิบาย Goroutine Leak พร้อมตัวอย่าง
func leaky() {
ch := make(chan int) // unbuffered
go func() {
ch <- compute() // ไม่มีใครรับ → goroutine นี้ค้างตลอดไป
}()
// ฟังก์ชันออกไปโดยไม่รับค่าจาก ch
}
Goroutine ค้างรอส่งเข้า channel ที่ไม่มีใครอ่าน จะไม่ถูก garbage collect (เพราะ runtime ยังมี
reference ถึงมันอยู่) วิธีแก้: ใช้ context ให้ goroutine เช็ค cancellation, หรือทำ channel
เป็น buffered/non-blocking send
จำว่า "Goroutine ที่ค้างรอ = ผีที่ GC มองไม่เห็นว่าควรลบ" (runtime ยังมี pointer ถึง stack
ของมันอยู่ ไม่ว่าง) — Beyond: pprof มี endpoint /debug/pprof/goroutine ที่นับจำนวน
goroutine ปัจจุบันได้แบบ real-time วิธี debug goroutine leak ใน production จริงคือ monitor
ตัวเลขนี้เป็น metric แล้วดู trend ว่ามันไต่ขึ้นเรื่อยๆ ไม่มีวันลดหรือไม่ (ถ้าขึ้นแบบไม่มีเพดาน
= leak ชัวร์) ไม่ใช่แค่เดาจากโค้ดอย่างเดียว
4. defer ทำงานตามลำดับไหน และมี pitfall อะไร
defer ทำงานแบบ LIFO (last-in-first-out) และ argument ของ deferred call ถูก evaluate
ทันทีตอนเจอ defer ไม่ใช่ตอนรัน
func example() {
for i := 0; i < 3; i++ {
defer fmt.Println(i) // พิมพ์ 2, 1, 0 (LIFO)
}
}
Pitfall ที่ถามบ่อย: defer ใน loop ที่เปิด resource (เช่น file.Close()) จะไม่ถูกเรียกจนกว่า
ฟังก์ชันทั้งหมด จะ return ไม่ใช่ตอนจบ loop แต่ละรอบ — ถ้า loop เปิดไฟล์เป็นพันไฟล์จะ leak
file descriptor ชั่วคราวจนฟังก์ชัน return
จำว่า "defer = จานล้างซ้อนกันในอ่าง ล้างจากบนลงล่าง" (LIFO) — Beyond: argument evaluate
ทันทีแต่ การเรียก method ผ่าน receiver ที่เป็น pointer ยัง "เห็น" ค่าล่าสุดได้ เพราะสิ่งที่
evaluate ทันทีคือ "ตัว pointer" ไม่ใช่ "ค่าที่ pointer ชี้ไป": defer obj.Method() ที่ obj
เป็น pointer จะใช้ state ของ obj ณ ตอนที่ defer ทำงานจริง (ไม่ใช่ตอนเขียน defer) ต่างจาก
defer fmt.Println(x) ที่ x เป็น value ธรรมดาซึ่ง snapshot ค่าไว้ทันที เป็นจุดที่ทำให้คน
สับสนว่า "defer evaluate ทันที" นั้นจริงแค่ไหนกันแน่
5. Go จัดการ Memory/Garbage Collection อย่างไร
Go ใช้ concurrent, tri-color mark-and-sweep GC ที่ออกแบบมาให้ pause time ต่ำ (sub-millisecond
ในเวอร์ชันใหม่ๆ) ตัวแปร escape ไป heap เมื่อ compiler escape-analysis พบว่ามันถูกใช้งานเกิน
scope ของฟังก์ชัน (เช่น return pointer ของ local variable) ใช้ go build -gcflags="-m" ดู
escape analysis ได้
จำสี 3 สี: ขาว (ยังไม่ตรวจ) → เทา (กำลังตรวจ) → ดำ (ตรวจแล้ว ไม่ลบ) — object ที่เหลือเป็น
"ขาว" ตอนจบ mark phase คือขยะที่ถูกลบ — Beyond: ตัวแปร GOGC (default 100) ควบคุม "GC จะรอให้
heap โตขึ้นกี่ % ก่อนเริ่มรอบถัดไป" — GOGC=100 แปลว่ารอจน heap โตเป็น 2 เท่าของหลัง GC รอบ
ก่อน ตั้งค่าสูงขึ้น (เช่น GOGC=200) แลก memory usage ที่สูงขึ้นกับความถี่ GC ที่ลดลง (CPU ลด)
— เป็น trade-off ที่ engineer ระดับ production ต้อง tune เองตามลักษณะ workload ไม่ใช่ปล่อย
default เสมอไป
6. ความแตกต่างระหว่าง Buffered vs Unbuffered Channel
Unbuffered: ส่งจะ block จนกว่ามีคนรับ (synchronization point) — ใช้เมื่อต้องการ "handshake" Buffered: ส่งไม่ block จนกว่า buffer เต็ม — ใช้เมื่อต้องการ decouple ความเร็วผู้ส่ง/ผู้รับ (เหมือน bounded queue) แต่ buffer ที่ใหญ่เกินไปอาจซ่อนปัญหา backpressure ไว้
จำว่า "Unbuffered บังคับเจอหน้า, Buffered แค่ฝากของไว้" — Beyond: channel ที่มี cap == 1
ใช้ pattern เป็น mutex ทางเลือก ได้จริง (ch <- struct{}{} แทน mu.Lock(),
<-ch แทน mu.Unlock()) ข้อดีคือ "lock" แบบนี้ cancel ได้ผ่าน select + ctx.Done() ซึ่ง
sync.Mutex ทำไม่ได้โดยตรง — เป็นเทคนิคที่ใช้น้อยแต่ interviewer ระดับ senior ชอบถามว่า
"ใช้ channel แทน mutex ได้ไหม เมื่อไหร่ควรใช้"
7. Struct embedding vs interface embedding ต่างกันอย่างไร
Struct embedding = composition ของ data + behavior (ได้ field และ method มาด้วย)
Interface embedding = รวม method set ของหลาย interface เข้าด้วยกันเป็น interface ใหม่ เช่น
io.ReadWriter = io.Reader + io.Writer — ไม่มี data เกี่ยวข้องเลย เป็นแค่ contract
จำว่า "Struct embedding ยืมของ, Interface embedding รวมสัญญา" — Beyond: ถ้า struct
embed interface (ไม่ใช่ struct) จะได้ pattern ที่ทรงพลังมาก: struct นั้น "satisfy"
interface ตัวใหญ่ได้ทันทีโดยยังไม่ implement ครบทุก method (เพราะ method ที่ขาดถูก delegate
ไปที่ embedded interface ซึ่งอาจเป็น nil ถ้าไม่ได้ set ค่า) — เทคนิคนี้ใช้ทำ partial mock
หรือ decorator pattern ได้ง่ายมาก แต่ก็เสี่ยง nil pointer panic ถ้า embedded interface ไม่ได้
ถูก set ค่าไว้ก่อนเรียก method ที่ delegate ไป เป็นดาบสองคมที่ต้องระวัง
8. ทำไม Go ไม่มี Generics จนถึง 1.18?
ทีม Go ให้ความสำคัญกับ compile speed, simplicity, และ explicit code มากกว่า — Generics (type parameters) เพิ่ม complexity ให้ compiler และอาจทำให้ error message อ่านยากขึ้น เมื่อ เปิดตัวใน 1.18 ก็ออกแบบให้ minimal (ไม่มี operator overloading, ไม่มี specialization ซับซ้อน)
func Map[T, U any](s []T, f func(T) U) []U {
r := make([]U, len(s))
for i, v := range s {
r[i] = f(v)
}
return r
}
จำว่า "Go รอ 10 ปีเพื่อทำ generics ให้ถูก ไม่ใช่ทำให้เร็ว" — Beyond: generic function ของ
Go ใช้กลยุทธ์ compile แบบ GC shape stenciling (ไม่ใช่ full monomorphization แบบ C++
template หรือ full type erasure แบบ Java) คือ type ที่มี memory layout เหมือนกัน (เช่น
*T ทุกตัว) จะแชร์ compiled code ชุดเดียวกัน ลด binary size เมื่อเทียบกับ C++ ที่ generate
code แยกทุก concrete type แต่ก็แลกกับ runtime overhead เล็กน้อยเมื่อเทียบกับ C++ — เป็นจุดที่
แสดงว่าเข้าใจ "ทำไมถึงออกแบบแบบนี้" ลึกกว่าแค่ "มี generics ใน 1.18"
Common Go interview themes: slices (dynamic view over an array, copied by header only) vs
fixed-size arrays (copied in full); make (initializes slice/map/channel internal state) vs
new (zero-valued pointer, rarely used in practice); goroutine leaks from unbuffered sends with
no receiver (monitor via pprof's goroutine count in production); defer's LIFO order with
arguments evaluated at defer-time — except pointer receivers, which still see later state
changes; the concurrent tri-color mark-and-sweep GC, escape analysis, and the GOGC tuning
knob; buffered vs unbuffered channels as a synchronization vs decoupling tool, including the
cap-1-channel-as-cancellable-mutex trick; struct embedding (composition) vs interface embedding
(contract composition, including the partial-mock-via-embedded-interface pattern); and why
generics arrived late — Go prioritized compile speed and binary size via GC shape stenciling
over C++-style full monomorphization.