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

capstone — เปิด HTTP และ​ทำงาน​พร้อม​กัน

เจ็ด​บท​ที่​ผ่าน​มา orderctl ทำงาน​ที​ละ​อย่าง อ่า​นอ​อเด​อร์​หนึ่ง​ใบ คิด​เงิน แล้ว​จบ

บท​นี้​เปลี่ยน​สอง​ข้อ​พร้อม​กัน — มัน​ประมวล​ผล​หลาย​ใบ​พร้อม​กัน และ​มัน​เปิด​พอร์ต​ให้​คน​อื่น​ยิง​เข้า​มา

📦 สิ่ง​ที่​บท​นี้​เพิ่ม​ให้ orderctl
  • internal/batch — worker pool ที่​ยกเลิก​กลาง​ทาง​ได้​โดย​ไม่​ค้าง
  • internal/store.Ledger — ยอด​สะสม​ที่​หลาย​คน​เขียน​พร้อม​กัน​ได้
  • internal/httpapi — ตัว​รับคำ​ขอ HTTP และ​การ​ปิด​เซิร์ฟเวอร์​อย่าง​เรียบร้อย
  • cmd/orderd — โปรแกรม​ตัว​ที่​สอง​ของ module เดียวกัน

ทุก​อย่าง​ยัง standard library ล้วน ไม่มี router ภายนอก ไม่มี library ของ​เทสต์​เพิ่ม

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 เอง

`wg.Add(1)` กับ `defer wg.Done()` ไม่​ได้​เลิก​ใช้

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()
}

การ​ส่ง​บล็อก​จนกว่า​จะ​มี​ผู้รับ​พร้อม​พอดี และ​ผู้​ส่ง​เป็น​คน​ปิด

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` เลือก​สุ่ม​เมื่อ​มี​หลาย case พร้อม​กัน

select รอ​หลาย channel พร้อม​กัน แล้ว​เดิน​เข้า case ที่​พร้อม

ถ้า​พร้อม​มากกว่า​หนึ่ง case มัน​เลือก แบบ​สุ่ม ไม่ใช่​เลือก​ตัว​บน ลำดับ​ที่​คุณ​เขียน case จึง​ไม่​ได้​แปล​ว่า​ลำดับ​ความ​สำคัญ

การ​ใส่ default เปลี่ยน​ความ​หมาย​ทั้งหมด — select จะ​ไม่​รอ​อะไร​เลย ตกลง default ทันที​เมื่อ​ไม่มี case ไหน​พร้อม

ไม่มี​ใคร​หยุด 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 ตัว​นั้น​รั่ว​อยู่​อย่าง​นั้น​จน​โปร​เซ​ส​จบ

หลาย 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 error
send:
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)
}
ถอด `select` ที่​ฝั่ง​ผู้​ส่ง​ออก แล้ว​การ​ยกเลิก​จะ​ไม่มี​ผล​อะไร​เลย

เขียน 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 ตัวจริง · ผู้เรียกจึงวนมันได้โดยไม่ถือ lock
func (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 ตรง​นั้น​เลย

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")] · @PostMappingmux.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 ไว้​เหมือน​เดิม

`recover` ทำงาน​เฉพาะ​ใน goroutine ของ​ตัว​เอง

สำนวน​ที่​คน​มา​จาก Python หรือ C# เขียน​บ่อย​คือ defer recover() ที่​ระดับ​บน​สุด แล้ว​คิด​ว่า​ครอบ​ทุก​อย่าง​ที่​แตก​ข้าง​ใต้

Go ไม่​ทำงาน​แบบ​นั้น recover เห็น​เฉพาะ panic ที่​เกิด​ใน goroutine เดียว​กับ​มัน

panic ใน goroutine ลูก​ที่​ไม่มี​ใคร​รับ ฆ่า​โปร​เซ​ส​ทั้ง​ตัว ไม่​ว่า​แม่​จะ​มี​ตาข่าย​กี่​ชั้น ตาข่าย​จึง​ต้อง​อยู่​ที่​ขอบ​ของ​ทุก goroutine ที่​คุณ​สั่ง go ขึ้น​มา​เอง

ตัว​หนึ่ง​จับ​การ​แย่ง​กัน​เขียน​ที่​เกิด​ขึ้น​จริง​ตอน​รัน อีก​ตัว​ยิง​คำขอ​ผ่าน 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` ไม่ใช่​ตัว​พิสูจน์​ว่า​ไม่มี race

เอกสาร​ทางการ​เขียน​ไว้​ตรงๆ ว่า​มัน​จับ​ได้​เฉพาะ 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

แปดบท​ต่อ​กัน​เป็น​เครื่อง​มือ​ตัว​เดียว​ที่​รัน​ได้​จริง และ​ทุก​ชิ้น​มา​จาก standard library

บทสิ่ง​ที่​เพิ่ม​เข้า​มา
1module example.com/orderctl และ​คำ​สั่ง​ชุด​แรก​ของ toolchain
2ชนิด​โดเมน Order Line Baht Status พร้อม method และ​เทสต์​ตัว​แรก
3อ่า​นอ​อเด​อร์​จาก JSON ห่อ​ความ​ผิด​พลาด​ด้วย %w และ​อธิบาย​ให้​คน​อ่าน​รู้​เรื่อง
4สรุป​ยอด​ตาม​หมวด เรียง​รายการ และ​แสดง​ผล​ที่​คงที่​ทุก​ครั้ง
5กฎ​ส่วนลด​ที่​ต่อ​กัน​ได้​ผ่าน interface เล็ก​ตัว​เดียว
6ตรวจ​ความ​ถูก​ต้อง​ที่​รายงาน​ทุก​ข้อ​พร้อม​กัน และ exit code ที่​สคริปต์​อ่าน​ได้
7subcommand flag slog และ​การ​เขียน​สถานะ​ลง disk แบบ​ไม่​ทำ​ของ​เดิม​พัง
8worker 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/order
ok example.com/orderctl/internal/pricing
ok example.com/orderctl/internal/batch
ok 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 ที่​เจาะจง​กว่า​ชนะ · HandleFunc panic เมื่อ 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 ที่ถูกยกเลิกไปแล้ว จะเกิดอะไรขึ้น