capstone — เปิด HTTP และทำงานพร้อมกัน
เจ็ดบทที่ผ่านมา orderctl ทำงานทีละอย่าง อ่านออเดอร์หนึ่งใบ คิดเงิน แล้วจบ
บทนี้เปลี่ยนสองข้อพร้อมกัน — มันประมวลผลหลายใบพร้อมกัน และมันเปิดพอร์ตให้คนอื่นยิงเข้ามา
internal/batch— worker pool ที่ยกเลิกกลางทางได้โดยไม่ค้างinternal/store.Ledger— ยอดสะสมที่หลายคนเขียนพร้อมกันได้internal/httpapi— ตัวรับคำขอ HTTP และการปิดเซิร์ฟเวอร์อย่างเรียบร้อยcmd/orderd— โปรแกรมตัวที่สองของ module เดียวกัน
ทุกอย่างยัง standard library ล้วน ไม่มี router ภายนอก ไม่มี library ของเทสต์เพิ่ม
goroutine ไม่ใช่ Task ที่ await ได้
หัวข้อที่มีชื่อว่า “goroutine ไม่ใช่ Task ที่ await ได้”
go f()ไม่คืนอะไรกลับมาเลย จึงต้องมีเครื่องมือแยกไว้รอให้มันจบ
goroutinegoroutineฟังก์ชันที่ทำงานพร้อมกับตัวอื่นภายใต้ตัวจัดคิวของ Go เริ่มด้วย `go f()` และไม่คืน handle ให้ผู้เรียก จึงไม่มีอะไรให้ `join` หรือ `cancel` โดยตรง คือฟังก์ชันที่ทำงานพร้อมกับตัวอื่นภายใต้ตัวจัดคิวของ Go
เริ่มมันด้วยคำเดียวคือ go
ตรงนี้คือจุดที่คนมาจาก C#, TypeScript หรือ Python สะดุดก่อนเสมอ ในภาษาเหล่านั้น
การเรียกงานพร้อมกันคืนของกลับมาให้ถือไว้ — Task, Promise หรือ Future
go f() ไม่คืนอะไรเลย ไม่มีตัวแปรให้รับ ไม่มี await ให้เรียก
และไม่มีทางถามมันว่าเสร็จหรือยัง
package main
import ( "fmt" "sync")
func work(id int) { fmt.Println("งานที่", id, "เสร็จแล้ว")}
func main() { go work(0) // ยิงแล้วลืม · main อาจจบก่อนมันได้พิมพ์ ผลจึงไม่รับประกัน
var wg sync.WaitGroup for id := range 3 { // range บนจำนวนเต็ม · Go 1.22 ขึ้นไป wg.Go(func() { work(id + 1) }) // sync.WaitGroup.Go · Go 1.25 ขึ้นไป } wg.Wait() // ทางเดียวที่รู้ว่าทั้งสามตัวจบแล้ว}บรรทัด go work(0) กับ wg.Go(...) ต่างกันตรงเดียว — ตัวหลังมีคนนับให้ว่าเหลืออีกกี่ตัว
wg.Wait() จึงเป็นจุดที่ orderctl รู้ว่าประมวลผลครบแล้ว ไม่ใช่ตัว go เอง
sync.WaitGroup.Go มาใน Go 1.25 ผู้อ่านจำนวนมากยังอยู่บน 1.24 หรือต่ำกว่า
เอกสารของ sync เรียกรูปเก่าว่าเป็นสำนวนที่พบบ่อยในโปรแกรมที่เขียนก่อนจะมี WaitGroup.Go
แปลว่ามันยังถูกต้อง แค่ไม่ใช่ค่าตั้งต้นของโปรแกรมใหม่ตั้งแต่ 1.25 เป็นต้นมา
package main
import ( "fmt" "sync")
func main() { var wg sync.WaitGroup for id := range 3 { wg.Add(1) // ต้อง Add ก่อนสั่ง go เสมอ ไม่ใช่ข้างใน goroutine go func() { defer wg.Done() // ต้อง defer · ถ้า panic กลางทางแล้วไม่ Done จะค้างตลอดกาล fmt.Println("งานที่", id) }() } wg.Wait()}channel ที่ไม่มีบัฟเฟอร์คือจุดนัดพบ
หัวข้อที่มีชื่อว่า “channel ที่ไม่มีบัฟเฟอร์คือจุดนัดพบ”การส่งบล็อกจนกว่าจะมีผู้รับพร้อมพอดี และผู้ส่งเป็นคนปิด
unbuffered channelunbuffered channelchannel ที่สร้างโดยไม่ระบุความจุ การส่งจะบล็อกจนกว่าจะมีผู้รับพร้อมรับพอดี เป็นจุดนัดพบไม่ใช่คิว คือ channel ที่สร้างด้วย make(chan T) โดยไม่ระบุความจุ
ถ้าคุณมาจาก Java หรือ C# คุณจะเผลอมองมันเป็น BlockingQueue ที่มีที่ว่างอยู่ข้างใน
ผู้ส่งใส่ของแล้วเดินต่อได้ทันที
มันไม่ใช่แบบนั้น การส่งค้างอยู่ตรงนั้นจนกว่าจะมีผู้รับมารับ การส่งกับการรับเกิดพร้อมกันเป๊ะ
package main
import "fmt"
func main() { jobs := make(chan int) // ไม่มีบัฟเฟอร์ · การส่งบล็อกจนกว่าจะมีผู้รับ done := make(chan struct{}) // ใช้เป็นสัญญาณล้วน ไม่ได้ส่งข้อมูล
go func() { for j := range jobs { // วนไปจนกว่า channel จะถูกปิด fmt.Println("รับงาน", j) } close(done) }()
for j := range 3 { jobs <- j } close(jobs) // ผู้ส่งเป็นคนปิด ไม่ใช่ผู้รับ <-done}กฎการปิดมีข้อเดียวและมันเข้มงวด — คนที่ส่งเป็นคนปิด
เหตุผลอยู่ที่สองอย่างที่ทำให้โปรแกรม panic ทันที คือปิดซ้ำสองครั้ง และส่งเข้า channel ที่ปิดไปแล้ว
ผู้รับที่ปิดเองจึงเสี่ยงพา send on closed channel มาให้ผู้ส่งที่ยังไม่รู้เรื่อง
select รอหลาย channel พร้อมกัน แล้วเดินเข้า case ที่พร้อม
ถ้าพร้อมมากกว่าหนึ่ง case มันเลือก แบบสุ่ม ไม่ใช่เลือกตัวบน ลำดับที่คุณเขียน case จึงไม่ได้แปลว่าลำดับความสำคัญ
การใส่ default เปลี่ยนความหมายทั้งหมด — select จะไม่รออะไรเลย
ตกลง default ทันทีเมื่อไม่มี case ไหนพร้อม
context คือการขอให้จบ ไม่ใช่การฆ่า
หัวข้อที่มีชื่อว่า “context คือการขอให้จบ ไม่ใช่การฆ่า”ไม่มีใครหยุด goroutine จากข้างนอกได้ ตัวมันเองต้องเลือกอ่านสัญญาณ
contextcontext.Contextตัวส่งสัญญาณยกเลิกและกำหนดเส้นตายที่ส่งต่อไปตามสายการเรียก — โปรแกรมต้องเลือกอ่าน `ctx.Done()` เอง ไม่มีการฆ่า goroutine จากภายนอก คือตัวส่งสัญญาณยกเลิกที่ส่งต่อไปตามสายการเรียก
นี่เป็นข้อที่ต่างจาก CancellationToken ของ .NET มากกว่าที่ชื่อทำให้คิด
Go ไม่มีกลไกไหนโยนอะไรเข้าไปกลางทางของ goroutine ตัวอื่น
cancel() แค่ปิด channel ที่ ctx.Done() คืนมา ถ้าโปรแกรมข้างในไม่เคยอ่านมัน
มันก็ทำงานต่อไปเรื่อยๆ เหมือนไม่มีอะไรเกิดขึ้น
package main
import ( "context" "fmt")
// นับไปเรื่อยๆ จนกว่าจะมีคนขอให้หยุด · ไม่มีใครฆ่ามันจากข้างนอกได้func count(ctx context.Context, out chan<- int) { for i := 0; ; i++ { select { case out <- i: case <-ctx.Done(): // ตัวมันเองเป็นคนตัดสินใจว่าจะจบ close(out) return } }}
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel()
out := make(chan int) go count(ctx, out)
for v := range out { fmt.Println("ได้ค่า", v) if v == 2 { cancel() // ขอให้จบ · ไม่ใช่สั่งให้ตาย } } fmt.Println("เหตุผลที่จบ:", context.Cause(ctx))}สังเกตว่า count มี select อยู่ที่ฝั่ง ผู้ส่ง ด้วย ไม่ใช่แค่ฝั่งผู้รับ
ตัดบรรทัด case <-ctx.Done() ทิ้ง แล้วไม่มีใครเหลือปิด out โปรแกรมนี้จะนับต่อไปไม่มีวันจบ
cancel() ที่เรียกไปกลายเป็นคำสั่งที่ไม่มีใครฟัง
และในโปรแกรมที่ผู้รับเลิกรับจริง เช่นออกจากลูปด้วย break ผู้ส่งจะค้างที่ out <- i
ตลอดกาล — goroutine ตัวนั้นรั่วอยู่อย่างนั้นจนโปรเซสจบ
worker pool ของ orderctl
หัวข้อที่มีชื่อว่า “worker pool ของ orderctl”หลาย goroutine เขียนคนละช่องของ slice จึงไม่ต้องมีตัวกั้น และผู้ส่งเป็นคนดู
ctx
batch.ProcessAll รับออเดอร์ทั้งกอง แจกให้ worker หลายตัว แล้วคืนผลที่เรียงตามลำดับเดิม
กลไกที่ทำให้มันไม่ต้องใช้ mutex อยู่ที่บรรทัดเดียว — จอง slice ให้ครบขนาดก่อน
แล้วให้แต่ละ goroutine เขียนลง out[i] ของตัวเอง
สองตัวไม่มีวันแตะช่องเดียวกัน เพราะ i แต่ละค่าถูกส่งออกจาก channel เพียงครั้งเดียว
flowchart LR P["ผู้ส่ง<br/>select ระหว่าง jobs กับ ctx.Done"] -->|"ส่ง index"| J(["jobs<br/>chan int ไม่มีบัฟเฟอร์"]) J --> W1["worker 1"] J --> W2["worker 2"] J --> W3["worker 3"] W1 -->|"เขียน out[i]"| O["out slice<br/>จองครบตั้งแต่ต้น"] W2 -->|"เขียน out[i]"| O W3 -->|"เขียน out[i]"| O C["ctx ถูกยกเลิก"] -.->|"หยุดส่ง แล้ว close(jobs)"| P P -.->|"close(jobs)"| J
คำบรรยายภาพ: ผู้ส่งดูสองทางพร้อมกันคือช่องส่งงานกับสัญญาณยกเลิก · worker แต่ละตัวรับ index แล้วเขียนลงช่องของตัวเองใน out slice · เมื่อ ctx ถูกยกเลิก ผู้ส่งหยุดส่งแล้วปิด jobs ทำให้ worker ทุกตัวออกจากลูป
package main
import ( "context" "errors" "fmt" "sync")
type Order struct { ID string Total int64}
func (o Order) Validate() error { if o.Total <= 0 { return errors.New("ยอดต้องมากกว่าศูนย์") } return nil}
type Result struct { ID string Total int64 Err error}
func ProcessAll(ctx context.Context, orders []Order, workers int) ([]Result, error) { if workers < 1 { workers = 1 } out := make([]Result, len(orders)) // จองครบก่อน · แต่ละตัวเขียนคนละช่อง jobs := make(chan int)
var wg sync.WaitGroup for range workers { wg.Go(func() { for i := range jobs { o := orders[i] r := Result{ID: o.ID} if err := o.Validate(); err != nil { r.Err = fmt.Errorf("order %s: %w", o.ID, err) } else { r.Total = o.Total } out[i] = r } }) }
var sendErr errorsend: for i := range orders { select { case jobs <- i: case <-ctx.Done(): sendErr = ctx.Err() // จุดเดียวใน ProcessAll ที่อ่าน ctx · ถอดออกแล้วการยกเลิกไม่มีผล break send } } close(jobs) // ผู้ส่งเป็นคนปิด · worker ทุกตัวจึงออกจากลูป wg.Wait() return out, sendErr}
func main() { orders := []Order{{ID: "A1", Total: 250}, {ID: "A2", Total: 0}} res, err := ProcessAll(context.Background(), orders, 4) fmt.Println(res, err)}เขียน jobs <- i เปล่าๆ ในลูปของผู้ส่งดูสะอาดกว่ามาก และมันทำให้ ctx กลายเป็นของประดับ
select ตรงนี้คือจุด เดียว ใน ProcessAll ที่อ่าน ctx — worker วน for i := range jobs
ล้วน ไม่มีตัวไหนอ่านสัญญาณยกเลิกเลย
ถอด select ออกแล้วผู้ส่งจะแจกงานจนครบทุกใบ แล้วคืน error เป็น nil
ทั้งที่ผู้เรียกสั่งยกเลิกไปตั้งแต่ก่อนเริ่ม
หลายคนคาดว่ามันจะค้าง อาการนั้นต้องมีเงื่อนไขเพิ่มคือ worker ต้องเลิกรับ jobs เองด้วย
รูปนี้ไม่ทำ worker จึงรับจนกว่า channel จะถูกปิดเสมอ
รูปที่มี select ให้ทางออกที่สองแก่ผู้ส่ง — เลิกส่ง ปิด channel รอ worker จบ
แล้วบอกผู้เรียกด้วย ctx.Err() ว่าทำไมงานถึงไม่ครบ
สถานะที่แชร์กันจริงต้องมีตัวกั้น
หัวข้อที่มีชื่อว่า “สถานะที่แชร์กันจริงต้องมีตัวกั้น”map ของ Go ไม่ปลอดภัยเลย และการใช้ผิดพาโปรแกรมล้มทั้งตัว ไม่ใช่แค่ throw
Ledger เก็บยอดสะสมต่อออเดอร์ หลาย handler เขียนลงมันพร้อมกันได้จริง
ตรงนี้ไม่มีเทคนิคแบ่งช่องแบบ worker pool มาช่วย
Go ไม่มี ConcurrentDictionary ให้หยิบ คำตอบมาตรฐานคือ map ธรรมดาห่อด้วย sync.Mutex
package main
import ( "fmt" "maps" "sync")
// Ledger เก็บยอดสะสมต่อออเดอร์ · หลาย goroutine เขียนพร้อมกันได้type Ledger struct { mu sync.Mutex totals map[string]int64}
func NewLedger() *Ledger { return &Ledger{totals: make(map[string]int64)}}
func (l *Ledger) Record(id string, total int64) { l.mu.Lock() defer l.mu.Unlock() l.totals[id] += total}
// Snapshot คืนสำเนา ไม่ใช่ map ตัวจริง · ผู้เรียกจึงวนมันได้โดยไม่ถือ lockfunc (l *Ledger) Snapshot() map[string]int64 { l.mu.Lock() defer l.mu.Unlock() return maps.Clone(l.totals)}
func main() { l := NewLedger() var wg sync.WaitGroup for range 100 { wg.Go(func() { l.Record("A1", 10) }) } wg.Wait() fmt.Println(l.Snapshot()["A1"])}Snapshot คืนสำเนาด้วยเหตุผลเดียว — คืน map ตัวจริงออกไปเท่ากับยกกุญแจให้คนข้างนอก
ที่ไม่รู้ว่าต้องถือ lock
สอง method นี้ใช้ defer ปลด lock เพื่อให้ปลดแน่นอนแม้มี panic ระหว่างทาง
อ่าน map พร้อมที่อีกตัวเขียน Go พิมพ์ fatal error: concurrent map read and map write
สองตัวเขียนพร้อมกัน มันพิมพ์ fatal error: concurrent map writes
คำว่า fatal error ไม่ใช่ panic — recover รับมันไม่ได้ และไม่มี defer ตัวไหนช่วย
โปรเซสจบทันทีพร้อมสถานะของทุก goroutine
sync.Mutex ยังมีข้อที่คนจาก C# หรือ Java พลาดอีกข้อ มันไม่ reentrant
Lock ซ้อน Lock ในสายเรียกเดียวกันคือ deadlock ตรงนั้นเลย
routing อยู่ใน standard library แล้ว
หัวข้อที่มีชื่อว่า “routing อยู่ใน standard library แล้ว”
ServeMuxตั้งแต่ Go 1.22 อ่าน method และตัวแปรในเส้นทางได้ และตัดสินด้วยความเจาะจง
ก่อน Go 1.22 http.ServeMux แยก GET กับ POST ไม่ได้ และดึงค่าออกจากเส้นทางไม่ได้
คนจึงลง chi หรือ gin กันเป็นนิสัย
routing patternServeMux patternรูปแบบเส้นทางของ `net/http` ที่ตั้งแต่ Go 1.22 รับทั้ง method และตัวแปรในเส้นทางได้ เช่น `POST /orders/{id}` โดยไม่ต้องใช้ router ภายนอก แบบใหม่ปิดช่องนั้น รูปแบบคือ method
เว้นวรรค แล้วตามด้วยเส้นทางที่มีตัวแปรใน {...} ได้
package main
import ( "encoding/json" "log/slog" "net/http" "os")
type Order struct { ID string `json:"id"` Total int64 `json:"total"`}
type API struct { Log *slog.Logger}
func (a *API) Routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("POST /orders", a.create) // method + เส้นทาง · Go 1.22 ขึ้นไป mux.HandleFunc("GET /orders/{id}", a.get) // ตัวแปรหนึ่ง segment mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("GET /{$}", a.root) // ตรงกับ "/" เท่านั้น ไม่ใช่ทุกเส้นทาง return mux}
func (a *API) create(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields()
var o Order if err := dec.Decode(&o); err != nil { writeJSON(a.Log, w, http.StatusBadRequest, map[string]any{"error": err.Error()}) return // ลืม return แล้วจะเขียนคำตอบซ้ำสองรอบ } if o.Total <= 0 { writeJSON(a.Log, w, http.StatusUnprocessableEntity, map[string]any{"error": "ยอดต้องมากกว่าศูนย์"}) return } writeJSON(a.Log, w, http.StatusCreated, map[string]any{"id": o.ID, "total": o.Total})}
func (a *API) get(w http.ResponseWriter, r *http.Request) { writeJSON(a.Log, w, http.StatusOK, map[string]any{"id": r.PathValue("id")})}
func (a *API) root(w http.ResponseWriter, _ *http.Request) { writeJSON(a.Log, w, http.StatusOK, map[string]any{"service": "orderd"})}
func writeJSON(log *slog.Logger, w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") // ต้องมาก่อน WriteHeader w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { log.Error("write response", "err", err) }}
func main() { api := &API{Log: slog.New(slog.NewJSONHandler(os.Stderr, nil))} http.ListenAndServe(":8080", api.Routes())}handler ของ Go ไม่คืนค่าและไม่โยน exception ทางเดียวที่ตอบคือเขียนลง w
แล้ว return ทันที
นี่คือที่ที่คนมาจาก ASP.NET หรือ Spring พลาดบ่อยที่สุด — ไม่มี return NotFound()
ให้เขียน และการลืม return ทำให้ handler เขียนคำตอบทับกันเอง
| สิ่งที่คุณคุ้น | รูปของ Go | จุดที่ต่าง |
|---|---|---|
[HttpPost("orders")] · @PostMapping | mux.HandleFunc("POST /orders", h) | เป็นข้อความล้วน ไม่มี annotation และไม่มีการสแกนตอนเริ่ม |
| ลำดับที่ลงทะเบียนคือลำดับที่ตรวจ | ตัดสินด้วยความเจาะจง | เส้นทางที่เจาะจงกว่าชนะเสมอ ลำดับที่เขียนไม่มีผล |
| route ที่ชนกันคือของที่ตัวแรกชนะ | HandleFunc panic ตอนลงทะเบียน | มันแตกตั้งแต่โปรแกรมยังไม่ทันฟังพอร์ต |
return NotFound() | w.WriteHeader(404) แล้ว return | ไม่มีค่าคืน คุณต้องออกจากฟังก์ชันเอง |
เส้นทาง "/" ใน ServeMux ไม่ได้แปลว่าหน้าแรก มันแปลว่า ทุกเส้นทางที่ไม่มีใครรับ
เพราะ pattern ที่ลงท้ายด้วย / ครอบทุกอย่างที่อยู่ใต้มัน
"GET /{$}" จำกัดให้ตรงกับ / เพียงเส้นทางเดียว คำขอที่หลงมาจึงได้ 404 ตามที่ควร
อีกข้อที่มาพร้อมกันคือ 405 — ลงทะเบียนไว้แต่ POST /orders แล้วมีคนยิง GET /orders
เข้ามา ServeMux ตอบ 405 Method Not Allowed ให้เองโดยคุณไม่ต้องเขียน
ปิดเซิร์ฟเวอร์โดยไม่ตัดสายกลางคัน
หัวข้อที่มีชื่อว่า “ปิดเซิร์ฟเวอร์โดยไม่ตัดสายกลางคัน”
signal.NotifyContextแปลสัญญาณของระบบเป็นctxแล้วShutdownรอคำขอที่ค้างอยู่ให้จบ
ListenAndServe บล็อกตลอดกาล การกด Ctrl+C แบบธรรมดาจึงตัดคำขอที่กำลังทำอยู่ทิ้งกลางคัน
รูปที่ถูกคือแปลสัญญาณเป็น ctx แล้วให้ srv.Shutdown หยุดรับคำขอใหม่
พร้อมรอคำขอเดิมจนจบ
package main
import ( "context" "errors" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time")
func Serve(ctx context.Context, log *slog.Logger, addr string, h http.Handler) error { srv := &http.Server{ Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, }
errCh := make(chan error, 1) // มีบัฟเฟอร์ · ผู้ส่งจึงไม่ค้างเมื่อไม่มีใครอ่าน go func() { log.Info("listening", "addr", addr) if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { errCh <- err } close(errCh) }()
select { case err := <-errCh: return err case <-ctx.Done(): log.Info("shutting down") // ctx ถูกยกเลิกไปแล้ว ต้องตัดสายก่อน ไม่งั้นเส้นตายหมดอายุทันที shutCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() return srv.Shutdown(shutCtx) }}
func main() { addr := flag.String("addr", ":8080", "ที่อยู่ที่จะฟัง") flag.Parse()
log := slog.New(slog.NewJSONHandler(os.Stderr, nil)) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop()
if err := Serve(ctx, log, *addr, mux); err != nil { fmt.Fprintln(os.Stderr, "orderd:", err) os.Exit(1) }}บรรทัดที่แปลกที่สุดคือ context.WithoutCancel(ctx) และมันจำเป็นจริง
ตอนที่เราเดินมาถึงบรรทัดนั้น ctx ถูกยกเลิกไปแล้ว การต่อเส้นตาย 10 วินาที
เข้ากับสายที่ยกเลิกแล้ว ได้เส้นตายที่หมดอายุทันที
WithoutCancel ตัดสายการยกเลิกออก แล้วเก็บค่าที่ติดมากับ ctx ไว้เหมือนเดิม
สำนวนที่คนมาจาก Python หรือ C# เขียนบ่อยคือ defer recover() ที่ระดับบนสุด
แล้วคิดว่าครอบทุกอย่างที่แตกข้างใต้
Go ไม่ทำงานแบบนั้น recover เห็นเฉพาะ panic ที่เกิดใน goroutine เดียวกับมัน
panic ใน goroutine ลูกที่ไม่มีใครรับ ฆ่าโปรเซสทั้งตัว ไม่ว่าแม่จะมีตาข่ายกี่ชั้น
ตาข่ายจึงต้องอยู่ที่ขอบของทุก goroutine ที่คุณสั่ง go ขึ้นมาเอง
-race กับ httptest คือเกตปิดคอร์ส
หัวข้อที่มีชื่อว่า “-race กับ httptest คือเกตปิดคอร์ส”ตัวหนึ่งจับการแย่งกันเขียนที่เกิดขึ้นจริงตอนรัน อีกตัวยิงคำขอผ่าน
muxโดยไม่ต้องเปิดพอร์ต
race detectorrace detectorตัวตรวจ data race ที่เปิดด้วยแฟล็ก `-race` มันจับได้เฉพาะ race ที่เกิดขึ้นจริงตอนรัน จึงไม่เห็นเส้นทางที่เทสต์ไม่เคยเรียก เปิดด้วย flag เดียวคือ -race มันเฝ้าดูทุกการอ่านและเขียน
แล้วรายงานเมื่อสองสายแตะที่เดียวกันโดยไม่มีลำดับที่แน่นอน
โปรแกรมข้างล่างนี้เขียน map เดียวกันจากร้อย goroutine โดยไม่มีตัวกั้นอะไรเลย
package main
import ( "fmt" "sync")
// ❌ ตัวอย่างที่ผิด · ห้ามลอกไปใช้ · ไม่มีตัวกั้นระหว่างผู้เขียนร้อยตัวfunc main() { totals := map[string]int{}
var wg sync.WaitGroup for range 100 { wg.Go(func() { totals["A1"]++ // อ่านแล้วเขียน · ผลไม่รับประกัน }) } wg.Wait()
fmt.Println(totals["A1"])}รันธรรมดามันอาจพิมพ์เลขออกมาเฉยๆ อาจพิมพ์เลขที่น้อยกว่าร้อย
หรืออาจล้มด้วย fatal error — ผลไม่รับประกัน นั่นคือนิยามของ data race
go run -race เปลี่ยนสภาพนั้นเป็นรายงานที่ชี้บรรทัดให้ ห่อด้วย Ledger จากหัวข้อก่อนหน้า
แล้วรายงานหายไป
เอกสารทางการเขียนไว้ตรงๆ ว่ามันจับได้เฉพาะ race ที่ เกิดขึ้นจริงตอนรัน จึงไม่เห็นเส้นทางที่เทสต์ไม่เคยเรียก
แปลว่าค่าของมันขึ้นกับความครอบคลุมของเทสต์ ไม่ใช่ขึ้นกับตัวมันเอง
และมันทำให้โปรแกรมช้าลงกับกินหน่วยความจำมากขึ้นชัดเจน ที่ทำงานของมันคือ CI กับตอนพัฒนา ไม่ใช่ในโปรแกรมที่ส่งให้ผู้ใช้
ฝั่ง HTTP net/http/httptest ให้คำขอปลอมกับตัวจดคำตอบ จึงทดสอบได้โดยไม่ต้องเปิดพอร์ตจริง
ข้อสำคัญคือยิงผ่าน mux ไม่ใช่เรียก handler ตรงๆ เพราะการเลือกเส้นทางกับ 405
คือส่วนที่เราอยากตรวจ
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์func TestCreate(t *testing.T) { mux := (&API{Log: slog.New(slog.DiscardHandler)}).Routes()
tests := []struct { name string body string wantCode int }{ {"ปกติ", `{"id":"A1","total":250}`, http.StatusCreated}, {"ยอดเป็นศูนย์", `{"id":"A1","total":0}`, http.StatusUnprocessableEntity}, {"field ที่ไม่รู้จัก", `{"id":"A1","totl":250}`, http.StatusBadRequest}, {"JSON พัง", `{`, http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/orders", strings.NewReader(tt.body)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) // ยิงผ่าน mux ไม่ใช่เรียก handler ตรงๆ
if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d (body %q)", rec.Code, tt.wantCode, rec.Body.String()) } }) }}
func TestProcessAllStopsOnCancel(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() // ยกเลิกก่อนเริ่ม · ถ้าผู้ส่งไม่มี select ค่าที่คืนจะเป็น nil แล้วเทสต์นี้แดง if _, err := ProcessAll(ctx, manyOrders(500), 4); !errors.Is(err, context.Canceled) { t.Errorf("ProcessAll() err = %v, want context.Canceled", err) }}TestProcessAllStopsOnCancel คือเกตของหัวข้อ worker pool โดยตรง
ถ้าใครถอด select ที่ฝั่งผู้ส่งออกในอนาคต ProcessAll จะคืน nil แทน context.Canceled
แล้วเทสต์นี้แดงทันทีด้วย ProcessAll() err = <nil>, want context.Canceled
orderctl ทำอะไรได้แล้วบ้าง
หัวข้อที่มีชื่อว่า “orderctl ทำอะไรได้แล้วบ้าง”แปดบทต่อกันเป็นเครื่องมือตัวเดียวที่รันได้จริง และทุกชิ้นมาจาก standard library
| บท | สิ่งที่เพิ่มเข้ามา |
|---|---|
| 1 | module example.com/orderctl และคำสั่งชุดแรกของ toolchain |
| 2 | ชนิดโดเมน Order Line Baht Status พร้อม method และเทสต์ตัวแรก |
| 3 | อ่านออเดอร์จาก JSON ห่อความผิดพลาดด้วย %w และอธิบายให้คนอ่านรู้เรื่อง |
| 4 | สรุปยอดตามหมวด เรียงรายการ และแสดงผลที่คงที่ทุกครั้ง |
| 5 | กฎส่วนลดที่ต่อกันได้ผ่าน interface เล็กตัวเดียว |
| 6 | ตรวจความถูกต้องที่รายงานทุกข้อพร้อมกัน และ exit code ที่สคริปต์อ่านได้ |
| 7 | subcommand flag slog และการเขียนสถานะลง disk แบบไม่ทำของเดิมพัง |
| 8 | worker pool ที่ยกเลิกได้ ยอดสะสมที่ปลอดภัย และ HTTP ที่ปิดตัวเองเป็น |
สิ่งที่ไม่ได้เพิ่มเข้ามาเลยตลอดแปดบทคือ dependency ภายนอก go.mod ยังมีแค่บรรทัด
module กับบรรทัด go
นี่ไม่ใช่ข้อจำกัดที่เราฝืนทำ มันคือสิ่งที่ standard library ของ Go ให้ได้จริงในปี 2026
รันชุดเดียวกับที่ CI ของโปรแกรมจริงจะรัน
$ gofmt -l .$ go vet ./...$ go test -race -timeout 120s ./...ok example.com/orderctl/internal/orderok example.com/orderctl/internal/pricingok example.com/orderctl/internal/batchok example.com/orderctl/internal/httpapiไปต่อจากตรงนี้
หัวข้อที่มีชื่อว่า “ไปต่อจากตรงนี้”คอร์สนี้จงใจไม่สอนอยู่หลายเรื่อง แต่ละเรื่องมีที่ที่ควรไปอ่านต่อ
สิ่งที่คุณมีตอนนี้พอสำหรับเครื่องมือบรรทัดคำสั่งและ service ขนาดเล็กที่รันได้จริง สามหัวข้อข้างล่างคือก้าวถัดไปที่ตรงกับสิ่งที่เพิ่งเรียนจบมากที่สุด
contextเชิงลึก — การส่งต่อข้ามชั้น ค่าที่ติดไปกับctxและWithCancelCauseสำหรับตอนที่ต้องรู้ว่าใครสั่งยกเลิก- รูปของ channel ที่ซับซ้อนขึ้น — fan-in, fan-out และ pipeline สำหรับงานที่มีหลายขั้นต่อกัน
- generics — คำแนะนำทางการบอกให้รอจนกว่าคุณจะเขียนของซ้ำ แบบเดียวกันหลายรอบก่อน ไม่ใช่ใช้แทน interface
สองเครื่องมือที่คุณจะเจอในโปรแกรมจริงเร็วๆ นี้คือ
errgroup สำหรับกลุ่ม goroutine ที่คืนความผิดพลาด
และ testing/synctest สำหรับเทสต์ที่พึ่งเวลา
ทั้งคู่มีข้อแลกที่ต้องรู้ก่อนหยิบมาใช้ — errgroup คืนความผิดพลาดตัวแรกตัวเดียว
ซึ่งขัดกับ errors.Join ที่บทที่ 6 สอน
ส่วน synctest ไม่นับ mutex กับ I/O ว่าเป็นการบล็อกถาวร เทสต์จึงเงียบๆ ไม่ทำงานได้
ถ้ายังไม่เข้าใจกลไกที่บทนี้เพิ่งวางไว้
คอร์ส Java กับ Kotlin จะสร้าง orderctl ตัวเดียวกันนี้ เมื่อถึงตอนนั้นคุณจะเทียบได้ตรงๆ
ว่าแต่ละภาษาจ่ายอะไรไปกับการตัดสินใจของตัวเอง
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Go 1.22 Release Notes (2024-02-06) — routing pattern ของ
ServeMuxและfor rangeบนจำนวนเต็ม - net/http — ServeMux (เข้าถึง 2026-08-19) — รูปแบบ
[METHOD ][HOST]/[PATH]· pattern ที่เจาะจงกว่าชนะ ·HandleFuncpanic เมื่อ pattern ชนกัน - Routing Enhancements for Go 1.22 (Jonathan Amsterdam, 2024-02-13) — เหตุผลเบื้องหลัง
{$}และการตอบ 405 อัตโนมัติ - Go 1.25 Release Notes (2025-08-12) —
sync.WaitGroup.Goและtesting/synctest - Data Race Detector (เข้าถึง 2026-08-27) — “The race detector only finds races that happen at runtime, so it can’t find races in code paths that are not executed.”
- context package (เข้าถึง 2026-08-27) —
WithoutCancelและCause
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ถอด `select` ที่ฝั่งผู้ส่งของ `ProcessAll` ออก แล้วเขียน `jobs <- i` เปล่าๆ แทน · เรียกมันด้วย ctx ที่ถูกยกเลิกไปแล้ว จะเกิดอะไรขึ้น