ข้ามไปยังเนื้อหา

Concurrency Patterns

Goroutines และ channels ของ Go ประกอบกันตาม pattern ที่เข้าใจดีจำนวนน้อย เมื่อคุณจำแนกได้ คุณสามารถรวมกันสร้างระบบ concurrent ที่ซับซ้อนได้

Pipeline คือชุด stages ที่เชื่อมต่อด้วย channels แต่ละ stage รับค่าจาก upstream channel แปลงค่า แล้วส่ง result ลง downstream channel Stages รัน concurrent — ขณะที่ stage 2 กำลัง process item N, stage 1 กำลังทำงานกับ item N+1 แล้ว

generate → square → print

Pipelines เหมาะสำหรับ stream processing ที่แต่ละ transformation เป็นอิสระ และ stages สามารถประกอบกันได้แบบ Unix pipes

Fan-out เริ่ม goroutines หลายตัวที่อ่านจาก input channel เดียวกัน เพื่อทำงานแบบขนาน Fan-in รวม output channels หลายตัวเป็นหนึ่งเพื่อ consumer อ่าน รวมกันทำให้ parallel processing stage ใน pipeline

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())"]
Fan-in: input channel แต่ละตัวมี goroutine ส่งต่อของตัวเอง แต่มีแค่ goroutine เดียวเท่านั้นที่ close output ที่รวมแล้ว

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 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 []int
for 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 point

select ของแต่ละ stage เองคือสิ่งที่ทำให้ cancellation propagate ขึ้นไปได้: square หยุดรับจาก output ของ generate ทันทีที่ ctx เสร็จสิ้น ซึ่งทำให้ out <- n ที่ block อยู่ของ generate ก็ unblock ผ่าน case ctx.Done() ของตัวเองเช่นกัน แทนที่จะ block ตลอดไปบน channel ที่ไม่มีใครอ่านอีกแล้ว

Buffered channel ที่ใช้เป็น counting semaphore ล้วน ๆ — แนะนำไว้ในบท Channels — จำกัดว่า goroutine กี่ตัวรัน critical section ของมันพร้อมกันได้ ไม่ขึ้นกับว่าเปิด goroutine ไว้ทั้งหมดกี่ตัว:

const n = 6
const 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 index
var 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

ทั้ง 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 เลย

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 cleanly
Serve 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-inparallel processing, aggregate resultsต้องจัดการ error collection และ cancellation
pipelinemodular 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 สร้างมารวม pattern เหล่านี้เข้าด้วยกัน ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • ใน fan-in merge input 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 ไม่ตรงกับ go statement ที่ไม่มีขอบเขตในโค้ด?
  • 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 +0xb0
created by main.main in goroutine 1
/tmp/workerclosebug/main.go:22 +0x80

go run -race ในอีกรอบหนึ่งของโปรแกรมเดียวกันเป๊ะ จับ WARNING: DATA RACE ระหว่าง send ของ worker ตัวหนึ่งกับ close ของอีกตัวแทน — สาเหตุเดียวกัน อาการต่างกันไปตาม scheduling วิธีแก้คือแบบเดียวกับที่ใช้ตลอดทั้งบทนี้: ไม่มีใครข้างใน worker close out เลย — go func() { wg.Wait(); close(out) }() ตัวเดียวหลัง launch worker ทำหน้าที่นี้แค่ครั้งเดียว เหมือนตัวอย่าง worker pool ที่รันได้ด้านบน

ใน worker pool อะไรจำกัดจำนวน goroutines ที่ process jobs พร้อมกัน?
ใน pipeline pattern อะไรทำให้ stages รัน concurrent?
Fan-out ในบริบท Go concurrency หมายความว่าอะไร?
goroutine บล็อกบน out <- result และ consumer return แล้ว สิ่งนี้เรียกว่าอะไรและป้องกันอย่างไร?
worker สามตัวใช้ out channel เดียวกัน เกิดอะไรผิดพลาด?
func worker(jobs <-chan int, out chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        out <- j * 2
    }
    close(out)
}
ทำไม go run -race ไม่รายงาน race เลย ทั้งที่มี 6 goroutine เขียนลง slice เดียวกันพร้อมกัน?
results := make([]int, 6)
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        results[i] = i * i
    }(i)
}
wg.Wait()