Concurrency Patterns
สาม Pattern พื้นฐาน
หัวข้อที่มีชื่อว่า “สาม Pattern พื้นฐาน”Goroutines และ channels ของ Go ประกอบกันตาม pattern ที่เข้าใจดีจำนวนน้อย เมื่อคุณจำแนกได้ คุณสามารถรวมกันสร้างระบบ concurrent ที่ซับซ้อนได้
Pipeline
หัวข้อที่มีชื่อว่า “Pipeline”Pipeline คือชุด stages ที่เชื่อมต่อด้วย channels แต่ละ stage รับค่าจาก upstream channel แปลงค่า แล้วส่ง result ลง downstream channel Stages รัน concurrent — ขณะที่ stage 2 กำลัง process item N, stage 1 กำลังทำงานกับ item N+1 แล้ว
generate → square → printPipelines เหมาะสำหรับ stream processing ที่แต่ละ transformation เป็นอิสระ และ stages สามารถประกอบกันได้แบบ Unix pipes
Fan-out / Fan-in
หัวข้อที่มีชื่อว่า “Fan-out / Fan-in”Fan-out เริ่ม goroutines หลายตัวที่อ่านจาก input channel เดียวกัน เพื่อทำงานแบบขนาน Fan-in รวม output channels หลายตัวเป็นหนึ่งเพื่อ consumer อ่าน รวมกันทำให้ parallel processing stage ใน pipeline
merge: fan-in implementation จริง
หัวข้อที่มีชื่อว่า “merge: fan-in implementation จริง”merge ด้านล่างคือ fan-in แบบมาตรฐาน: รับ input channel กี่ตัวก็ได้ แล้ว return channel เดียวที่มีค่าจากทุกตัวรวมกัน
func merge(cs ...<-chan int) <-chan int { out := make(chan int) var wg sync.WaitGroup wg.Add(len(cs)) for _, c := range cs { go func(c <-chan int) { defer wg.Done() for v := range c { out <- v } }(c) } // A single dedicated goroutine waits for every input goroutine to finish, // then closes out exactly once. go func() { wg.Wait() close(out) }() return out}สองรายละเอียดที่ทำให้อันนี้ถูกต้อง ในจุดที่เวอร์ชัน naive พัง: wg.Add(len(cs)) เกิดขึ้นครั้งเดียว ก่อนที่ goroutine ไหนจะเริ่ม ทำให้ wg.Wait() ไม่มีวันเห็น counter เป็นศูนย์ก่อนเวลา และมีแค่ goroutine ปิดที่เฉพาะทางเท่านั้นที่เรียก close(out) แค่ครั้งเดียว หลังจากยืนยันว่า input goroutine ทุกตัว return แล้ว section อ่านโค้ดที่ AI เขียนของบท Channels แสดงเวอร์ชันที่พังแบบคลาสสิก — input goroutine ทุกตัวเรียก close(out) เอง — พร้อม output จริงคือ panic: send on closed channel และ WARNING: DATA RACE
flowchart LR C1["chan 1"] --> M["merge"] C2["chan 2"] --> M C3["chan 3"] --> M M --> Out["out (closed once, by a dedicated goroutine, after wg.Wait())"]
Worker Pool
หัวข้อที่มีชื่อว่า “Worker Pool”Worker pool คือ goroutines จำนวนคงที่ที่ consume จาก shared jobs channel ขนาด pool จำกัดการใช้ทรัพยากร (goroutines, connections, file handles) ขณะที่ยังประมวลผล concurrent
main → jobs channel → [worker 1, worker 2, worker 3] → resultsมันคือสิ่งที่เทียบเท่ากับ thread pool ใน Go
ตัวอย่างที่รันได้ด้านล่างรวมทั้งสาม patterns: pipeline ที่มี generator และ squaring stage ตามด้วย worker pool ที่ประมวลผล batch of jobs แบบ deterministic
package main
import ( "fmt" "sort" "sync")
// --- Pipeline ---
func generate(nums ...int) <-chan int { out := make(chan int, len(nums)) for _, n := range nums { out <- n } close(out) return out}
func square(in <-chan int) <-chan int { out := make(chan int, cap(in)) go func() { for n := range in { out <- n * n } close(out) }() return out}
// --- Worker pool ---
func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) { defer wg.Done() // one Done per worker, not per job for j := range jobs { results <- j * 2 }}
func main() { // Pipeline: 1,2,3,4,5 -> squares nums := generate(1, 2, 3, 4, 5) squares := square(nums) for v := range squares { fmt.Println(v) }
// Worker pool: 3 workers, 5 jobs, each job doubles its value. // wg counts *workers*: Add(numWorkers) once, one Done() per worker // when its "for j := range jobs" loop exits (jobs is closed). const numJobs = 5 const numWorkers = 3 jobs := make(chan int, numJobs) results := make(chan int, numJobs) var wg sync.WaitGroup
wg.Add(numWorkers) for w := 0; w < numWorkers; w++ { go worker(jobs, results, &wg) } for j := 1; j <= numJobs; j++ { jobs <- j } close(jobs)
// A dedicated goroutine closes results once every worker has // returned — closing from main directly would race with the // workers that are still sending. go func() { wg.Wait() close(results) }()
// Workers finish in a nondeterministic order, so sort before // printing to get a stable, comparable output. var out []int for r := range results { out = append(out, r) } sort.Ints(out) for _, r := range out { fmt.Println(r) }} wg ในที่นี้นับ worker ไม่ใช่ job: Add(numWorkers) เรียกครั้งเดียวก่อน worker ทุกตัวเริ่ม และแต่ละ worker เรียก Done() แค่ครั้งเดียว — ตอนที่ loop for j := range jobs จบลงเพราะ jobs ถูก close นี่คือเหตุผลที่ต้องมี goroutine แยกทำ go func() { wg.Wait(); close(results) }() เพราะต้องมีใครสักคนรอให้ worker ทุกตัวเสร็จแล้วค่อย close results และนั่นเป็น main เองไม่ได้ เพราะ main กำลังยุ่งอยู่กับการ drain results ด้วย for r := range results พร้อมกันอยู่ บน Go 1.25+ สามบรรทัด Add/go/defer wg.Done() ของแต่ละ worker เขียนเป็น wg.Go(func() { ... }) แทนได้ playground ยังใช้รูปแบบคลาสสิกเพราะ runner ในเบราว์เซอร์เกิดก่อน method นี้ เนื่องจาก worker สามตัวแข่งกันเขียนลง results ค่าจึงมาถึงตามลำดับที่ scheduler สั่งงานเสร็จแบบสุ่ม — โค้ดจึง sort ก่อน print เพื่อให้ output deterministic
Pipeline ที่ Cancel ได้
หัวข้อที่มีชื่อว่า “Pipeline ที่ Cancel ได้”pipeline generate → square ธรรมดาด้านบนไม่มีทางหยุดก่อนเวลาเลย — ถ้า consumer เดินหนีไป ทั้งสอง stage ก็ยังรันต่อไป (และ leak ตามบท Goroutines) ตลอดไป การให้ทุก stage มี context.Context และ select บน ctx.Done() แก้ปัญหานี้: แต่ละ stage หยุดผลิตทันทีที่ context ถูกยกเลิก แทนที่จะหยุดแค่ตอน input ของมัน close เท่านั้น
func generate(ctx context.Context, nums ...int) <-chan int { out := make(chan int) go func() { defer close(out) for _, n := range nums { select { case out <- n: case <-ctx.Done(): return // stop producing once the consumer gives up } } }() return out}
func square(ctx context.Context, in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for n := range in { select { case out <- n * n: case <-ctx.Done(): return } } }() return out}ctx, cancel := context.WithCancel(context.Background())defer cancel()
squares := square(ctx, generate(ctx, 1, 2, 3, 4, 5))
var got []intfor v := range squares { got = append(got, v) if len(got) == 2 { cancel() // stop the pipeline after exactly 2 values, deterministically break }}fmt.Println(got) // [1 4] -- always exactly these two, cancel fires at a fixed pointselect ของแต่ละ stage เองคือสิ่งที่ทำให้ cancellation propagate ขึ้นไปได้: square หยุดรับจาก output ของ generate ทันทีที่ ctx เสร็จสิ้น ซึ่งทำให้ out <- n ที่ block อยู่ของ generate ก็ unblock ผ่าน case ctx.Done() ของตัวเองเช่นกัน แทนที่จะ block ตลอดไปบน channel ที่ไม่มีใครอ่านอีกแล้ว
Bounded Parallelism
หัวข้อที่มีชื่อว่า “Bounded Parallelism”Buffered channel ที่ใช้เป็น counting semaphore ล้วน ๆ — แนะนำไว้ในบท Channels — จำกัดว่า goroutine กี่ตัวรัน critical section ของมันพร้อมกันได้ ไม่ขึ้นกับว่าเปิด goroutine ไว้ทั้งหมดกี่ตัว:
const n = 6const limit = 2 // at most 2 tasks run at once
sem := make(chan struct{}, limit)results := make([]int, n) // pre-sized: every goroutine owns a distinct indexvar wg sync.WaitGroup
for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() sem <- struct{}{} // acquire a permit defer func() { <-sem }() // release it on the way out
results[i] = i * i // each goroutine writes a different index -- no overlap, no race }(i)}wg.Wait()fmt.Println(results) // [0 1 4 9 16 25], always in this order despite concurrent writesทำไม Indexed Writes ถึงไม่ใช่ Race
หัวข้อที่มีชื่อว่า “ทำไม Indexed Writes ถึงไม่ใช่ Race”ทั้ง 6 goroutine เขียนลง slice results เดียวกัน พร้อมกัน โดยไม่มี mutex ล้อมรอบ results[i] = i * i เลย — และนี่ถูกต้องที่ปลอด race จริง ไม่ใช่ปลอดโดยบังเอิญ data race ต้องการให้ goroutine สองตัวเข้าถึง memory location เดียวกัน โดยอย่างน้อยหนึ่งเป็นการเขียน ที่นี่ results ถูก allocate ครั้งเดียวด้วย make([]int, n) ก่อนที่ goroutine ไหนจะเริ่ม ดังนั้น backing array และความยาวของมันไม่เปลี่ยนเลย และแต่ละ goroutine เขียนลง index ที่ต่างกัน — results[i] สำหรับ i ที่แตกต่างกันทุกครั้ง index ที่ต่างกันคือ memory location ที่ต่างกัน ไม่มี overlapping access ให้ race detector เจอเลย นี่คือเหตุผลเดียวกับ pattern results[id-1] = ... ของ worker pool ที่ใช้ที่อื่นในคอร์สนี้ และเป็นเหตุผลที่ go run -race ไม่รายงานอะไรเลยสำหรับ snippet นี้
method errgroup.SetLimit จากบท Sync Primitives แสดงขอบเขตแบบเดียวกันนี้ในรูปแบบ declarative — g.SetLimit(limit) แทนที่จะ size channel เอง — และมักเป็นตัวเลือกที่ดีกว่าในโค้ดจริง semaphore channel แบบดิบคุ้มค่าที่จะรู้จักไว้เพราะมันคือสิ่งที่ SetLimit ทำข้างในจริง ๆ และเพราะมันไม่ต้องการ external module เลย
Graceful Shutdown
หัวข้อที่มีชื่อว่า “Graceful Shutdown”HTTP server ใน production ต้องหยุดรับ connection ใหม่และทำ request ที่ค้างอยู่ให้เสร็จก่อนที่ process จะ exit — ไม่ใช่ตัด connection กลางคันทันทีที่ deploy ส่ง SIGTERM มา signal.NotifyContext เปลี่ยน OS signal ให้เป็น context.Context และ http.Server.Shutdown ทำการหยุดแบบ graceful จริง ๆ:
func main() { srv := &http.Server{Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop()
go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } }()
<-ctx.Done() // blocks until SIGINT/SIGTERM, then stop() lets the next signal kill it stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Println("forced shutdown:", err) }}srv.Shutdown หยุด listener ทันที แล้วรอ (จนถึง deadline ของ context) ให้ handler ที่ค้างอยู่ทุกตัว return เองก่อนที่มันจะ return; ListenAndServe unblock ด้วย http.ErrServerClosed พอดี ซึ่งเป็นผลลัพธ์ที่คาดไว้ ไม่ใช่ error จาก shutdown ที่ตั้งใจ ไม่ต้อง log เป็น failure เวอร์ชัน deterministic แยกต่างหากของสิ่งนี้ — listen บน port ที่ OS กำหนดให้ แล้ว shutdown ผ่าน cancel() ธรรมดาแทนที่จะรอ OS signal จริง — ยืนยันแล้วในเครื่องด้วย go run -race:
server shut down cleanlyServe returned: true(true ตรงนี้คือ errors.Is(err, http.ErrServerClosed) ยืนยันว่า Serve return เพราะการ shutdown ไม่ใช่ error จริง) เวอร์ชันนี้ต้องการ TCP listener จริง จึงรันใน Playground แบบเบราว์เซอร์ของหน้านี้ไม่ได้
สามอันที่ไม่ต้องใช้ network connection จริง — fan-in, cancellable pipeline, และ bounded parallelism — รวมกันอยู่ใน snippet ที่รันได้ด้านล่าง
package main
import ( "context" "fmt" "sort" "sync")
// --- Fan-in: merge ---
func merge(cs ...<-chan int) <-chan int { out := make(chan int) var wg sync.WaitGroup wg.Add(len(cs)) for _, c := range cs { go func(c <-chan int) { defer wg.Done() for v := range c { out <- v } }(c) } go func() { wg.Wait() close(out) }() return out}
func gen(vals ...int) <-chan int { out := make(chan int, len(vals)) for _, v := range vals { out <- v } close(out) return out}
// --- Cancellable pipeline ---
func generate(ctx context.Context, nums ...int) <-chan int { out := make(chan int) go func() { defer close(out) for _, n := range nums { select { case out <- n: case <-ctx.Done(): return } } }() return out}
func square(ctx context.Context, in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for n := range in { select { case out <- n * n: case <-ctx.Done(): return } } }() return out}
func main() { // Fan-in merged := merge(gen(1, 2, 3), gen(4, 5), gen(6)) var got []int for v := range merged { got = append(got, v) } sort.Ints(got) // merge order is nondeterministic; sort for a stable print fmt.Println("merged:", got)
// Cancellable pipeline ctx, cancel := context.WithCancel(context.Background()) defer cancel() squares := square(ctx, generate(ctx, 1, 2, 3, 4, 5)) var squared []int for v := range squares { squared = append(squared, v) if len(squared) == 2 { cancel() // stop after exactly 2 values, deterministically break } } fmt.Println("squared before cancel:", squared)
// Bounded parallelism: indexed writes to a pre-sized slice are not a race const n = 6 const limit = 2 sem := make(chan struct{}, limit) results := make([]int, n) var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() results[i] = i * i }(i) } wg.Wait() fmt.Println("bounded results:", results)} ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| worker pool | จำกัดจำนวน goroutine, reuse workers | ต้อง tune pool size, ซับซ้อนกว่า goroutine ต่อ task |
| fan-out / fan-in | parallel processing, aggregate results | ต้องจัดการ error collection และ cancellation |
| pipeline | modular stages, backpressure ง่าย | เพิ่ม latency ต่อ item (buffering overhead) |
| semaphore (buffered channel) | rate limit ง่าย | ต้อง drain channel เมื่อ cancel |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- worker pool เสมอเร็วกว่า goroutine-per-request — ขึ้นกับ workload — I/O-bound อาจเร็วกว่าด้วย goroutine-per-request
- pipeline ต้องมี fixed จำนวน stage — pipeline ใน Go flexible — stage เพิ่มลดได้แบบ dynamic
- fan-out โดยไม่ limit = ดีที่สุด — ไม่มี limit ทำให้ resource exhaustion เมื่อ load สูง
💡 ตัวอย่างจากของจริง
k8s.io/client-go ใช้ worker-queue pattern เดียวกันนี้เป็นแกนของ controller framework ใน
util/workqueue/queue.go— worker หลายตัวดึงงานจาก queue เดียวกันเพื่อจำกัด concurrent reconciliation
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างมารวม pattern เหล่านี้เข้าด้วยกัน ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- ใน fan-in
mergeinput goroutine ทุกตัวเรียกclose(out)เองไหม หรือมีแค่ goroutine เฉพาะทางตัวเดียวเรียกมันครั้งเดียว หลังจากwg.Wait()? - ทุก stage ของ pipeline ที่ควร cancel ได้
selectบนctx.Done()รอบ ๆ channel send ของมันจริงไหม — หรือมันเช็กctx.Err()แค่ครั้งเดียวตอนต้น พลาด cancellation ที่มาถึงกลาง loop? - จำนวน goroutine ที่รันพร้อมกันถูกจำกัดด้วย semaphore, worker pool, หรือ
errgroup.SetLimitจริงไหม — หรือ “bounded parallelism” ใน comment ไม่ตรงกับgostatement ที่ไม่มีขอบเขตในโค้ด? - graceful-shutdown handler เรียก
srv.Shutdown(ctx)พร้อม timeout จริงไหม หรือมันเรียกos.Exitทันทีที่ได้รับ signal ทิ้ง request ที่ค้างอยู่? - ถ้า goroutine หลายตัวเขียนลง shared slice ด้วย index index เหล่านั้นการันตีว่าแยกกันจริงไหม (ไม่มี goroutine สองตัวคำนวณ index เดียวกันได้เลย) หรือทั้งสองอาจชนกันแล้วเปลี่ยน indexed write ที่ “ปลอดภัย” ให้กลายเป็น race จริง?
🐛 หา bug ในโค้ดนี้
func worker(jobs <-chan int, out chan<- int, wg *sync.WaitGroup) { defer wg.Done() for j := range jobs { out <- j * 2 } close(out) // called once per worker, but out is shared by all of them}
func main() { jobs := make(chan int, 3) out := make(chan int, 3) var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go worker(jobs, out, &wg) } jobs <- 1 jobs <- 2 jobs <- 3 close(jobs) wg.Wait() for v := range out { fmt.Println(v) }}เฉลย
นี่คือบัค shared-channel-close ตัวเดียวกับ fan-in merge ของบท Channels แค่ใส่ชุดของ worker pool: out ถูก 3 worker ใช้ร่วมกัน แต่แต่ละตัวเรียก close(out) ตอนที่ loop for j := range jobs ของตัวเองจบ worker ตัวแรกที่เสร็จจะ close out สำเร็จ; worker ตัวอื่นที่เสร็จทีหลังและเรียก close(out) อีกครั้งจะ panic — output จริงที่จับได้บน Go 1.26.5 (out ตรงนี้เป็น buffered ดังนั้น worker มักส่งเสร็จก่อนที่จะแข่งกันตอน close แต่บัคจริงที่เกิดขึ้นต่างกันไปตามการรัน):
panic: close of closed channel
goroutine 5 [running]:main.worker(0xc000020120, 0xc0000201b0, 0xc000012180) /tmp/workerclosebug/main.go:13 +0xb0created by main.main in goroutine 1 /tmp/workerclosebug/main.go:22 +0x80go run -race ในอีกรอบหนึ่งของโปรแกรมเดียวกันเป๊ะ จับ WARNING: DATA RACE ระหว่าง send ของ worker ตัวหนึ่งกับ close ของอีกตัวแทน — สาเหตุเดียวกัน อาการต่างกันไปตาม scheduling วิธีแก้คือแบบเดียวกับที่ใช้ตลอดทั้งบทนี้: ไม่มีใครข้างใน worker close out เลย — go func() { wg.Wait(); close(out) }() ตัวเดียวหลัง launch worker ทำหน้าที่นี้แค่ครั้งเดียว เหมือนตัวอย่าง worker pool ที่รันได้ด้านบน