Concurrency in Go
Concurrency คือจุดขายหลักของ Go และเป็นหัวข้อที่ interviewer ถามบ่อยที่สุด
Goroutines
Goroutine คือ lightweight thread ที่ Go runtime จัดการ scheduling เอง (M:N scheduler) — เริ่มต้นใช้ stack เพียง ~2KB และขยายได้ตามต้องการ ทำให้สร้างได้เป็นหลักแสนตัวโดยไม่กระทบ performance มากนัก
go doSomething() // รันแบบ async ทันที ไม่ block
// ผิด (ก่อน 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) ซึ่งแพงกว่า มากถ้าสร้างเยอะๆ
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 เหมือนตู้ไปรษณีย์ที่ วางของทิ้งไว้ได้โดยไม่ต้องรอคนมารับทันที (จนกว่าตู้จะเต็ม)
กฎการปิด 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 แรก
ที่เขียนจะถูกเลือกก่อน"
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.Map | map ที่ 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 เสมอ
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 ใหม่โดยไม่ตั้งใจ
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
ถึงจะมีประโยชน์เต็มที่
Go memory model รับประกัน "happens-before" ผ่าน channel operation และ mutex เท่านั้น —
ตัวแปรธรรมดาที่เข้าถึงพร้อมกันจากหลาย goroutine โดยไม่ผ่าน channel/mutex/atomic ไม่มี
การรับประกันอะไรเลยแม้จะดู "แค่ read" เฉยๆ เพราะ compiler มีสิทธิ์ reorder คำสั่งหรือ cache
ค่าไว้ใน register ได้ตามกฎหมาย แม้ในทางปฏิบัติบนบาง CPU architecture อาจ "ดูเหมือนทำงานถูก"
ก็ตาม — นี่คือเหตุผลที่ "มันก็รันผ่านมาตลอดนี่" ไม่ใช่หลักฐานว่าโค้ดไม่มี race แค่ -race
ไม่เจอในรอบที่ทดสอบเท่านั้น
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.