Skip to main content

Concurrency in Go

Concurrency คือจุดขายหลักของ Go และเป็นหัวข้อที่ interviewer ถามบ่อยที่สุด

Goroutines​

Goroutine คือ lightweight thread ที่ Go runtime จัดการ scheduling เอง (M:N scheduler) — เริ่มต้นใช้ stack เพียง ~2KB และขยายได้ตามต้องการ ทำให้สร้างได้เป็นหลักแสนตัวโดยไม่กระทบ performance มากนัก

go doSomething() // รันแบบ async ทันที ไม่ block
ข้อผิดพลาดคลาสสิก: Loop Variable Capture
// ผิด (ก่อน Go 1.22): ทุก goroutine อาจเห็นค่า i ตัวสุดท้ายเหมือนกัน
for i := 0; i < 5; i++ {
go func() { fmt.Println(i) }()
}

// ถูก (ทุกเวอร์ชัน): ส่งค่าเข้าไปเป็น parameter
for i := 0; i < 5; i++ {
go func(n int) { fmt.Println(n) }(i)
}

Go 1.22+ แก้ปัญหานี้แล้วโดยทำให้ loop variable เป็น instance ใหม่ทุก iteration แต่ ควรรู้และ อธิบายปัญหานี้ได้ตอนสัมภาษณ์ เพราะโค้ด legacy จำนวนมากยังเขียนแบบเก่า

🧠 จำง่ายๆ

"M:N = คนงานน้อยกว่างาน" — คิดว่า M = OS thread (คนงานจริง มีจำกัด) และ N = goroutine (งานที่ต้องทำ เยอะกว่ามาก) scheduler ของ Go สลับงานให้คนงานทำทีละงานแบบไม่ต้องรอ OS context switch ที่แพง ต่างจาก thread ของภาษาอื่นที่มักเป็น 1:1 (1 thread ต่อ 1 OS thread) ซึ่งแพงกว่า มากถ้าสร้างเยอะๆ

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Go scheduler ใช้โมเดลที่เรียกว่า GMP (Goroutine, Machine/OS thread, Processor) — P คือ "ใบอนุญาต" จำนวนจำกัด (เท่ากับ GOMAXPROCS) ที่ M ต้องถือไว้ถึงจะรัน goroutine ได้ ถ้า goroutine หนึ่งบล็อกที่ system call (เช่น file I/O) runtime จะ "ปลด" M นั้นออกจาก P แล้วหา M ตัวใหม่มาถือ P แทนทันที เพื่อไม่ให้ P ว่างงาน — นี่คือเหตุผลที่ Go รองรับ I/O-heavy workload ได้ดีมากโดยไม่ต้องเขียน async/await เหมือนภาษาอื่น เพราะ runtime จัดการ "สลับคนงาน" ให้เองเบื้องหลังทั้งหมด

Channels​

Channel คือท่อสำหรับส่งข้อมูลระหว่าง goroutine อย่างปลอดภัย (ไม่ต้องใช้ lock เอง)

ch := make(chan int) // unbuffered: ส่ง-รับต้อง "เจอกัน" พร้อมกัน (synchronous)
ch := make(chan int, 10) // buffered: ส่งได้โดยไม่ block จนกว่า buffer เต็ม

ch <- 42 // ส่ง
v := <-ch // รับ
v, ok := <-ch // ok == false ถ้า channel ถูกปิดและไม่มีข้อมูลเหลือ
close(ch) // ปิด channel — ส่งซ้ำจะ panic, รับได้จนกว่าข้อมูลหมด

กฎทอง: "Don't communicate by sharing memory; share memory by communicating" — ใช้ channel ส่งข้อมูล/ownership แทนการแชร์ pointer แล้วล็อกกันเอง

🧠 จำง่ายๆ

"Unbuffered = จับมือ, Buffered = ตู้ไปรษณีย์" — unbuffered channel เหมือนส่งของมือต่อมือ ต้องมีคนรับยืนรออยู่พอดี (synchronous handshake) ส่วน buffered channel เหมือนตู้ไปรษณีย์ที่ วางของทิ้งไว้ได้โดยไม่ต้องรอคนมารับทันที (จนกว่าตู้จะเต็ม)

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

กฎการปิด channel ที่คนมักงง: "send ไปยัง closed channel = panic, receive จาก closed channel = ได้ zero value ทันทีไม่ block" ส่วนกฎที่สำคัญกว่าคือ "อย่าปิด channel จากฝั่ง receiver และอย่าปิดถ้ายังมีหลาย sender" เพราะถ้ามี sender หลายตัว sender ตัวใดตัวหนึ่งอาจส่งเข้า channel ที่ถูกปิดไปแล้วโดย sender อีกตัว ทำให้ panic แบบสุ่ม (race) วิธีแก้ที่ถูกต้องสำหรับ multi-sender คือใช้ sync.WaitGroup นับ sender ทั้งหมดแล้วให้ goroutine แยกต่างหากเป็นคนปิด channel หลังจาก wg.Wait() เสร็จ

select​

select ใช้รอหลาย channel พร้อมกัน เลือก case ที่พร้อมก่อน (random ถ้าพร้อมหลาย case)

select {
case msg := <-ch1:
fmt.Println("from ch1:", msg)
case msg := <-ch2:
fmt.Println("from ch2:", msg)
case <-time.After(2 * time.Second):
fmt.Println("timeout")
case <-ctx.Done():
return ctx.Err()
default:
// non-blocking: รันถ้าไม่มี case ไหนพร้อมเลย
}
🧠 จำง่ายๆ

"select = สุ่มหยิบจากกองที่พร้อม" — ถ้ามีหลาย case พร้อมพร้อมกัน Go สุ่ม เลือก ไม่ใช่เรียง ตามลำดับที่เขียนจากบนลงล่างเหมือน switch ธรรมดา ต่างกับความเข้าใจผิดที่พบบ่อยว่า "case แรก ที่เขียนจะถูกเลือกก่อน"

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

time.After(d) ที่ใช้ใน select loop ซ้ำๆ (เช่นใน for loop ที่มี select ข้างใน) จะสร้าง time.Timer ใหม่ทุกครั้งที่ loop วนรอบ และ timer เก่าที่ยังไม่ fire จะไม่ถูก garbage collect จนกว่าจะครบเวลา ทำให้เกิด memory ใช้งานเพิ่มขึ้นเรื่อยๆ ใน hot loop ที่ loop ถี่ๆ วิธีแก้ที่ถูกต้องคือสร้าง timer := time.NewTimer(d) ไว้นอก loop แล้ว timer.Reset(d) ซ้ำแทน — เป็นจุดที่ production incident เกิดขึ้นจริงมาหลายครั้งในวงการ Go

sync Package​

เครื่องมือใช้เมื่อ
sync.Mutex / RWMutexป้องกัน race condition บน shared state แบบธรรมดา
sync.WaitGroupรอให้ goroutine หลายตัวทำงานเสร็จทั้งหมด
sync.Onceรันโค้ดบางส่วนครั้งเดียว (เช่น init singleton) แบบ thread-safe
sync.Mapmap ที่ concurrent-safe โดยไม่ต้องล็อกเอง (ใช้เฉพาะเคส read-heavy)
atomic packageเพิ่ม/อ่านค่าตัวเลขแบบ lock-free สำหรับ counter ง่ายๆ
var wg sync.WaitGroup
var mu sync.Mutex
counter := 0

for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
🧠 จำง่ายๆ

"Add ก่อนเกิด, Done ตอนตาย, Wait ตอนรอดูศพครบ" — wg.Add(n) ต้องเรียกก่อนเริ่ม goroutine เสมอ (ไม่ใช่เรียกข้างในฟังก์ชัน goroutine เอง) ไม่งั้น Wait() อาจทำงานเสร็จไปก่อนที่ goroutine จะทัน Add เพราะ timing ไม่แน่นอน — กฎทอง: Add อยู่ฝั่งเดียวกับ go keyword เสมอ

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

sync.Map ไม่ได้เร็วกว่า map + Mutex เสมอไป ทั้งที่ชื่อฟังดูเหมือนเป็น "upgrade" — มันถูกออกแบบมาเฉพาะ 2 pattern เท่านั้น: (1) key-set ที่เขียนครั้งเดียวแล้วอ่านซ้ำๆ บ่อยมาก (write-once, read-many) หรือ (2) มีหลาย goroutine เขียน/อ่าน key ที่ไม่ overlap กันเลย ถ้าเป็น เคสทั่วไปที่ read/write ปนกันบน key เดียวกันบ่อยๆ map ธรรมดาคู่กับ sync.RWMutex จะเร็วกว่า sync.Map จริง (เอกสารทางการของ Go เตือนเรื่องนี้ไว้ตรงๆ แต่คนมักข้ามไปอ่าน)

context — Cancellation & Deadlines​

context.Context ใช้ส่งต่อ cancellation signal, deadline, และ request-scoped value ผ่าน ทุกชั้นของ call chain — เป็น parameter แรกของทุกฟังก์ชันที่ทำงาน async/IO ตามธรรมเนียม Go

func fetch(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
// ...
}

กฎ: อย่าเก็บ context ไว้ใน struct — pass ผ่าน parameter เสมอ (ctx context.Context เป็น argument แรก) และอย่าใช้ context.Background() แทน context.TODO()/context ที่ควรรับมาจาก caller

🧠 จำง่ายๆ

"context คือใบสั่งงานที่ไหลตามสายการบังคับบัญชา" — ต้อง "ส่งต่อ" ลงไปเรื่อยๆ เป็น parameter (เหมือนส่งใบสั่งงานต่อ) ไม่ใช่ "เก็บไว้ในลิ้นชัก" (struct field) เพราะ context ของแต่ละ request ต้องไม่ปนกัน — ถ้าเก็บใน struct ที่ share ข้าม request จะเอา deadline/cancellation ของ request เก่ามาปนกับ request ใหม่โดยไม่ตั้งใจ

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

defer cancel() ใน context.WithTimeout/WithCancel ต้องเรียกเสมอแม้ operation จะสำเร็จ ก่อนครบเวลา ไม่ใช่แค่กรณี timeout — เหตุผลคือ internal goroutine ที่ Go runtime สร้างขึ้นมา คอยเช็ค parent context ผูกกับ timer ไว้ ถ้าไม่เรียก cancel() goroutine/timer นั้นจะค้างรอจน ครบ timeout เองในที่สุด (ไม่ leak ถาวร) แต่ใน hot path ที่เรียกฟังก์ชันนี้ถี่มากๆ จะสร้าง goroutine ค้างสะสมจำนวนมากในช่วงเวลาสั้นๆ จนกระทบ memory/scheduler ได้จริง — go vet มี check (lostcancel) ที่จับ pattern "สร้าง cancel แล้วไม่เรียก" ได้อัตโนมัติ

Deadlock & Race Detection​

go run -race main.go # ตรวจ data race ตอน runtime

Deadlock ที่พบบ่อย: ส่งเข้า unbuffered channel ที่ไม่มีใครรับ, ล็อก mutex ซ้อนกันผิดลำดับ (lock ordering), หรือ WaitGroup ที่ Add() ไม่ตรงกับ Done()

🧠 จำง่ายๆ

"-race ไม่ใช่ linter มันคือกล้องจับโจร" — go vet/golangci-lint วิเคราะห์โค้ดแบบไม่รัน (static) แต่ -race ต้องรันโค้ดจริงถึงจะจับ race ได้ (dynamic) ดังนั้นถ้า code path ที่มี race ไม่ถูก execute ตอนเทส -race ก็จะไม่จับอะไรเลย — ต้องมี test coverage ที่ดีคู่กับ -race ถึงจะมีประโยชน์เต็มที่

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Go memory model รับประกัน "happens-before" ผ่าน channel operation และ mutex เท่านั้น — ตัวแปรธรรมดาที่เข้าถึงพร้อมกันจากหลาย goroutine โดยไม่ผ่าน channel/mutex/atomic ไม่มี การรับประกันอะไรเลยแม้จะดู "แค่ read" เฉยๆ เพราะ compiler มีสิทธิ์ reorder คำสั่งหรือ cache ค่าไว้ใน register ได้ตามกฎหมาย แม้ในทางปฏิบัติบนบาง CPU architecture อาจ "ดูเหมือนทำงานถูก" ก็ตาม — นี่คือเหตุผลที่ "มันก็รันผ่านมาตลอดนี่" ไม่ใช่หลักฐานว่าโค้ดไม่มี race แค่ -race ไม่เจอในรอบที่ทดสอบเท่านั้น

English Summary

Goroutines are cheap (M:N scheduled, ~2KB starting stack), so Go encourages spawning many of them. Prefer channels over shared-memory + locks ("share memory by communicating"). select multiplexes channel operations. sync.Mutex/WaitGroup handle classic shared-state coordination; context.Context propagates cancellation/deadlines down a call chain and should always be the first parameter, never stored in a struct. Always run go run -race to catch data races — a common interview follow-up is explaining the pre-1.22 loop-variable-capture bug. Deeper cuts worth knowing: the GMP scheduler model and how blocking syscalls detach M from P; why sync.Map is only a win for two narrow access patterns (not a general "faster map"); why time.After inside a hot loop leaks timers until they fire; and that Go's memory model only guarantees happens-before ordering through channels, mutexes, and atomics — never through plain shared variables, no matter how reliable it "looks" in testing.