บทเรียน 25 · Day 6 · Concurrency

Context: บอกให้ทุกคนหยุดพร้อมกัน

~15 นาที · ต้องมี: บทเรียน 23 (select), บทเรียน 12 (errors.Is)

ผู้ใช้กดปุ่ม Cancel บนหน้าเว็บ ตอนนั้น request ของเขาแตกเป็น goroutine ไปแล้ว 10 ชั้น — ยิง DB, เรียก API ข้างนอก, เขียน log
คุณจะบอกทั้งสิบชั้นยังไงว่า "งานนี้ไม่ต้องทำต่อแล้ว"?
Go ไม่มีวิธีฆ่า goroutine จากข้างนอก คำตอบเดียวคือ ส่งสัญญาณให้มันหยุดเอง — นั่นคือ context

1 · Context ส่งอะไรได้บ้าง

ส่งอะไรแปลว่าสร้างด้วย
Cancellation"งานนี้ไม่ต้องทำต่อแล้ว"context.WithCancel
Deadline / Timeout"งานนี้ทำได้ถึงเวลานี้"context.WithTimeout · WithDeadline
Valuesข้อมูลที่เดินทางไปกับ request เช่น request IDcontext.WithValue

⚠️ WithValue ไม่ใช่ทางเลี่ยง parameter
ใส่ได้เฉพาะของที่ "เดินทางไปกับ request ทั้งเส้น" เช่น request ID, trace ID, ข้อมูล user ที่ login อยู่
ห้ามใส่ argument ของฟังก์ชัน เพราะ compiler ตรวจให้ไม่ได้ — ใส่ผิด key หรือลืมใส่ จะไปพังตอนรัน แทนที่จะพังตอน build
ถ้าฟังก์ชันต้องใช้ userID ให้รับเป็น parameter ตรง ๆ

2 · context.WithCancel — กดกริ่งเอง

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go worker(ctx, "A")               // ตัว worker เขียนยังไง ดูหัวข้อ 3

    time.Sleep(2 * time.Second)
    fmt.Println("สั่งยกเลิก...")
    cancel()                              // ← กดกริ่ง

    time.Sleep(1 * time.Second)
}
   context.Background()          ← ต้นทาง ไม่มีวันถูกยกเลิก
          │
          ├── WithCancel  ──► ctx  ────────┬──────────┬──────────┐
          │                   │            │          │          │
       cancel()               ▼            ▼          ▼          ▼
          │              Handler       Service   Repository   Logger
          │                                │
          └──► ปิด ctx.Done() ──► ทุกคนที่ถือ ctx รู้พร้อมกัน

   cancel ที่ต้นทาง = ทุกกิ่งที่แตกออกไปได้สัญญาณหมด
🧰 เทียบให้เห็นภาพ — เบรกเกอร์ไฟในบ้าน

ในตู้ไฟที่บ้านมีเบรกเกอร์ตัวใหญ่ตัวเดียว แล้วแตกเป็นเบรกเกอร์ย่อยของแต่ละห้อง
· ปลดตัวใหญ่ → ดับทั้งบ้าน ทุกห้องที่ต่อจากมันดับหมด
· ปลดตัวของห้องครัว → ดับแค่ห้องครัว ห้องนอนยังสว่างอยู่

context ต่อกันแบบเดียวกันเป๊ะ ๆ · context.Background() = สายเมนที่ไม่มีวันดับ · WithCancel / WithTimeout = แยกเบรกเกอร์ย่อยออกมาอีกตัว · cancel() = ปลดเบรกเกอร์ตัวนั้น
ปลดตัวไหน ตัวนั้นกับทุกกิ่งที่แตกออกไปจากมันได้สัญญาณหมด ส่วนตัวที่อยู่เหนือขึ้นไปไม่กระทบ

ที่ไม่เหมือนกัน: ปลดเบรกเกอร์แล้วไฟดับจริงทันที แต่ cancel() แค่ส่งสัญญาณ — งานที่กำลังทำอยู่ไม่ได้หยุดเอง ใครไม่เปิดดูสัญญาณก็ทำต่อไปเรื่อย ๆ (หัวข้อ 6 จะพิสูจน์ให้ดูด้วยผลรันจริง)

3 · ctx.Done() — ช่องรับสัญญาณ

ctx.Done() คืน channel ที่จะถูกปิด เมื่อถึงเวลายกเลิก — เอามาใช้กับ select ตามที่ฝึกในบทเรียน 23:

func worker(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("[%s] ได้รับสัญญาณ หยุด: %v\n", name, ctx.Err())
            return                      // ← ต้อง return จริง ๆ

        default:
            fmt.Printf("[%s] ทำงาน...\n", name)
            time.Sleep(400 * time.Millisecond)
        }
    }
}

ทำไม ctx.Done() ถึงใช้วิธี "ปิด channel" ไม่ใช่ "ส่งค่า": เพราะ channel ที่ปิดแล้วทำให้ทุกคนที่รออยู่ได้รับพร้อมกันหมด (บทเรียน 22 หัวข้อ 7) — ถ้าส่งค่า จะมีแค่คนเดียวที่ได้
นี่คือสำนวน broadcast ของ Go ใช้ได้กับงานอื่นด้วย: อยากบอกอะไรกับ goroutine หลายตัวพร้อมกัน → ปิด channel

🗣 ศัพท์ที่เพิ่งเรียนไป

คำพวกนี้คือคำที่ dev คนอื่นใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้คำให้ตรง จะได้คำตอบที่ตรง

parent context / child context
ตัวแม่กับตัวลูกในภาพเบรกเกอร์ · ประโยคที่จะได้ยินบ่อยคือ “ctx ตัวนี้ derive (แตก) มาจากตัวไหน”
cancel function
ฟังก์ชันตัวที่สองที่ WithCancel/WithTimeout คืนมา — คือ “ปุ่มปลดเบรกเกอร์” · type จริงชื่อ context.CancelFunc · นิยมตั้งชื่อตัวแปรว่า cancel แต่ถ้ามีหลายตัวในฟังก์ชันเดียว ก็ตั้งให้สื่อได้ เช่น stop, cancelShutdown (เจอทั้งสองแบบในหัวข้อ 9)
propagate
การที่สัญญาณยกเลิก “ไหลต่อ” จากแม่ลงไปหาลูกทุกตัว · พูดว่า “cancellation มัน propagate ลงไปถึงชั้น DB ไหม”
broadcast
บอกทุกคนพร้อมกันทีเดียว (ตรงข้ามกับส่งค่าที่ได้แค่คนเดียว) · ใน Go สำนวนที่ใช้บ่อยที่สุดคือปิด channel (อีกทางคือ sync.Cond.Broadcast) · ถามรีวิวโค้ดได้ว่า “ตรงนี้อยากบอกทุก goroutine พร้อมกัน ใช้ปิด channel broadcast เอาไหม”

4 · context.WithTimeout — ตั้งเวลาไว้ล่วงหน้า

ctx, cancel := context.WithTimeout(
    context.Background(),
    2 * time.Second,
)
defer cancel()      // ← ต้องมีเสมอ แม้จะปล่อยให้หมดเวลาเอง

⚠️ defer cancel() ต้องเขียนทุกครั้ง — ไม่มีข้อยกเว้น
คนมักคิดว่า "ตั้ง timeout ไว้แล้ว เดี๋ยวมันก็ยกเลิกเอง ไม่ต้องเรียก cancel" ผิด — context ที่ยังไม่ถูก cancel จะยึด resource (timer + การเชื่อมกับ context แม่) ไว้จนกว่าจะครบเวลา
ถ้างานเสร็จใน 10ms แต่ timeout ตั้งไว้ 30 วินาที = ค้างไว้เปล่า ๆ 30 วินาที ทุกครั้งที่มี request → memory leak แบบค่อยเป็นค่อยไป (หน่วยความจำที่จองไว้แล้วยังไม่ถูกคืน · ถ้า request ใหม่เข้ามาเร็วกว่า ที่ของเก่าจะครบ timeout ก็ทับถมกันขึ้นเรื่อย ๆ)
ข่าวดี: go vet จับให้ — ผมลองละทิ้ง cancel ด้วย ctx, _ := context.WithTimeout(...) แล้วได้:

the cancel function returned by context.WithTimeout should be called,
not discarded, to avoid a context leak
🧰 เทียบให้เห็นภาพ — จองโต๊ะร้านอาหารไว้ 2 ชั่วโมง

คุณโทรจองโต๊ะไว้ 2 ชั่วโมง ร้านก็กันโต๊ะนั้นไว้ให้ ไม่ให้ใครนั่ง
แต่คุณกินเสร็จใน 15 นาที แล้วเดินออกไปเฉย ๆ ไม่ได้บอกร้าน → โต๊ะถูกกันไว้เปล่า ๆ อีกชั่วโมงสี่สิบห้า ร้านรับลูกค้าใหม่ไม่ได้

defer cancel() คือการบอกร้านว่า “เราไปแล้วนะ ปล่อยโต๊ะได้เลย” — เขียนไว้บรรทัดถัดจาก WithTimeout ทันที เท่ากับบอกร้านอัตโนมัติทุกครั้ง ไม่ว่างานจะจบแบบไหน

ที่ไม่เหมือนกัน: ร้านอาหารเดี๋ยวพนักงานก็เดินมาเห็นเองว่าโต๊ะว่าง แต่ Go ไม่มีใครเดินมาเช็คให้ — ของที่ context จองไว้จะถูกปล่อยก็ต่อเมื่อ (1) เราเรียก cancel() เอง (2) ครบเวลาที่ตั้งไว้ หรือ (3) context แม่ถูกยกเลิก แล้วสัญญาณไหลลงมาปลดให้ — ไม่มีพนักงานเดินมาเก็บโต๊ะให้เองนอกจากสามทางนี้

🗣 ศัพท์ที่เพิ่งเรียนไป
context leak
อาการ “ลืมบอกร้านว่าไปแล้ว” · เวลาแจ้งปัญหาให้พูดว่า “ตรงนี้น่าจะ context leak เพราะไม่ได้เรียก cancel” จะตรงกว่าบอกว่า “โปรแกรมกินแรมขึ้นเรื่อย ๆ”
deadline
“เส้นตายเป็นเวลาจริง” เช่น 10:30:00 · ส่วน timeout คือ “นับจากตอนนี้ไปอีกเท่าไหร่” เช่น 2 วินาที — WithTimeout คือทางลัดของ WithDeadline นั่นเอง
go vet · lostcancel
go vet คือเครื่องมือหาโค้ดที่ compile ผ่านแต่น่าจะผิด · ตัวตรวจที่จับเรื่องนี้ชื่อ lostcancel · พูดกับ senior ได้เลยว่า “vet ฟ้อง lostcancel ตรงไฟล์นี้”

5 · ctx.Err() — ถามว่าหยุดเพราะอะไร

if errors.Is(ctx.Err(), context.Canceled) {
    fmt.Println("มีคนกด cancel!")
}

if errors.Is(ctx.Err(), context.DeadlineExceeded) {
    fmt.Println("หมดเวลาทำงานแล้ว!")
}
ctx.Err() คืนอะไรแปลว่าควรตอบผู้ใช้ยังไง
nilยังไม่ถูกยกเลิก ทำงานต่อได้—
context.Canceledมีคนเรียก cancel()ไม่ต้องตอบ — ผู้ใช้ไปแล้ว (HTTP 499)
context.DeadlineExceededหมดเวลาตาม timeout504 Gateway Timeout

สังเกตว่าใช้ errors.Is ไม่ใช่ == — หลักการเดิมจาก บทเรียน 12 เพราะ error อาจถูกห่อด้วย %w ระหว่างทาง

6 · การยกเลิกเป็นเรื่อง "ขอความร่วมมือ" ไม่ใช่ "บังคับ"

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

go worker(ctx, "A")      // worker วน select + Sleep 400ms
go worker(ctx, "B")

<-ctx.Done()               // รอจนหมดเวลา
time.Sleep(100 * time.Millisecond)
fmt.Println("DeadlineExceeded?", errors.Is(ctx.Err(), context.DeadlineExceeded))

worker ทั้งสองตัวจะได้พิมพ์ "ได้รับสัญญาณ หยุด" ไหม?

ผมรันแล้วได้:

[B] ทำงาน...
[A] ทำงาน...
[A] ทำงาน...
[B] ทำงาน...
[B] ทำงาน...
[A] ทำงาน...
DeadlineExceeded? true

ไม่มีใครพิมพ์ "ได้รับสัญญาณ" เลย

ทำไม: ตอน context หมดเวลา worker ทั้งสองตัว กำลัง Sleep อยู่กลางทาง — ยังไม่ถึงรอบที่จะเช็ค ctx.Done() พอ main รอแค่ 100ms แล้วจบ โปรแกรมก็จบ (บทเรียน 21) worker ตายกลางคันโดยไม่ทันรู้ตัว

บทเรียนสำคัญที่สุดของบทนี้: cancel() ไม่ได้หยุดใครทั้งนั้น มันแค่ปิด channel — goroutine ที่ไม่เคยเช็ค ctx.Done() จะทำงานต่อไปเรื่อย ๆ ไม่มีใครหยุดมันได้
"Context ไม่ใช่ปุ่มหยุด มันคือป้ายบอกทาง — ต้องมีคนอ่านถึงจะได้ผล"

🧰 เทียบให้เห็นภาพ — ส่งไลน์บอกเพื่อนที่เดินอยู่ในห้าง

คุณฝากเพื่อนไปซื้อของ แล้วเปลี่ยนใจ จึงส่งไลน์ไปว่า “ไม่ต้องซื้อแล้วนะ กลับได้เลย”
ข้อความส่งถึงแน่ ๆ แต่คุณบังคับให้เพื่อนเปิดอ่านไม่ได้ — คนที่หยิบมือถือขึ้นมาดูเป็นระยะก็เดินกลับ ส่วนคนที่ยัดมือถือไว้ในกระเป๋าก็ซื้อต่อจนครบลิสต์

cancel() = กดส่งไลน์ · select ที่เช็ค ctx.Done() ทุกรอบ = การหยิบมือถือขึ้นมาดู · worker ที่ไม่เคยเช็คก็คือเพื่อนที่ไม่เปิดดูมือถือ
และถ้าอยากรู้ว่าเพื่อนกลับถึงบ้านจริงหรือยัง ส่งไลน์อย่างเดียวไม่พอ ต้องรอให้เขาตอบกลับด้วย — นั่นคือหน้าที่ของ WaitGroup

และมีอีกแบบที่เจ็บกว่า — เพื่อนที่ตั้งใจหยิบมือถือขึ้นมาดูทุก ๆ 400ms แต่จังหวะที่เราส่งไลน์ เขาเพิ่งเก็บมือถือลงกระเป๋าพอดี กว่าจะถึงรอบดูรอบหน้า ห้างปิดไปแล้ว (= main จบ) เขาเลยไม่ได้อ่านเหมือนกัน
นี่คือสิ่งที่เกิดในผลรันข้างบน — worker A/B เช็ค ctx.Done() ทุกรอบ แต่ก็ยังไม่ทันพิมพ์ “ได้รับสัญญาณ” · เช็คเป็นรอบ ๆ ก็ยังพลาดได้

ที่ไม่เหมือนกัน: ในชีวิตจริงถ้าเพื่อนไม่อ่านไลน์ เรายังโทรตามได้ แต่ Go ไม่มีช่องทางสำรองเลย — ไม่มีคำสั่งฆ่า goroutine จากข้างนอก goroutine ที่ไม่ยอมเช็คสัญญาณจะอยู่ยาวจนโปรแกรมทั้งตัวจบไปพร้อมกัน

รอให้ worker หยุดจริง ๆ ต้องใช้ WaitGroup คู่กับ context — worker จึงต้องรับ wg เพิ่มอีกตัว เพิ่มจากเวอร์ชันหัวข้อ 3 แค่สองบรรทัด ที่เหลือเหมือนเดิมทุกอย่าง:

func worker(ctx context.Context, name string, wg *sync.WaitGroup) {
    defer wg.Done()              // ← เพิ่มบรรทัดนี้
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("[%s] ได้รับสัญญาณ หยุด: %v\n", name, ctx.Err())
            return

        default:
            fmt.Printf("[%s] ทำงาน...\n", name)
            time.Sleep(400 * time.Millisecond)
        }
    }
}
var wg sync.WaitGroup
wg.Add(2)
go worker(ctx, "A", &wg)
go worker(ctx, "B", &wg)

wg.Wait()      // ← รอให้เขาจบ "เอง" จริง ๆ
fmt.Println("ทุก worker หยุดเรียบร้อย")

คราวนี้ผลลัพธ์เปลี่ยนไป — ผมรันแล้วได้:

[A] ทำงาน...
[B] ทำงาน...
...
[A] ได้รับสัญญาณ หยุด: context deadline exceeded
[B] ได้รับสัญญาณ หยุด: context deadline exceeded
ทุก worker หยุดเรียบร้อย
💡 สูตรที่ต้องจำ

context = บอกให้หยุด · WaitGroup = รอจนหยุดจริง
ต้องมีคู่กันเสมอ ถ้าอยากรู้ว่างานปิดเรียบร้อยแล้ว

🗣 ศัพท์ที่เพิ่งเรียนไป
cooperative cancellation
“ยกเลิกแบบขอความร่วมมือ” — ผู้ถูกยกเลิกต้องเช็คเอง ถึงจะหยุด · ประโยคที่ใช้ได้เลยคือ “Go ใช้ cooperative cancellation ไม่มีคำสั่ง kill goroutine” — พูดแบบนี้ senior จะรู้ทันทีว่าคุณเข้าใจกลไก
cancellation point
“จุดที่โค้ดเงยหน้าขึ้นมาดูสัญญาณ” · ไม่ใช่แค่ case <-ctx.Done() เท่านั้น — ทุกฟังก์ชันที่รับ ctx ไปแล้วรอเป็น เช่น db.QueryContext(ctx, ...) หรือ http.NewRequestWithContext ก็เป็น cancellation point ในตัวมันเอง · ที่ยกเลิกไม่ได้จริง ๆ คือ loop คำนวณหนัก ๆ ที่ไม่แตะ ctx เลย
goroutine leak
goroutine ที่ไม่มีวันจบ ค้างกินหน่วยความจำไปเรื่อย ๆ · เวลาถามให้พูดว่า “ตรงนี้มีโอกาส goroutine leak ไหม”
graceful (จบแบบเรียบร้อย)
ปล่อยให้งานที่ค้างอยู่เก็บของจบเองก่อน ไม่ตัดกลางคัน · ตรงข้ามกับ abrupt / force kill ที่ตัดจบทันที

7 · ctx ต้องเป็น parameter ตัวแรกเสมอ

func Handler(ctx context.Context, r *http.Request) { Service(ctx) }

func Service(ctx context.Context)                    { Repository(ctx) }

func Repository(ctx context.Context) {
    db.QueryRowContext(ctx, query, id)   // ส่งต่อจนถึง DB
}

ธรรมเนียมที่ทั้งชุมชน Go ใช้เหมือนกันหมด:
1. ctx context.Context เป็น parameter ตัวแรก เสมอ
2. ชื่อตัวแปรคือ ctx — ไม่ใช่ c หรือ context
3. ห้ามเก็บ context ไว้ใน struct — ส่งผ่าน parameter อย่างเดียว
เพราะ ctx ผูกกับ "หนึ่ง request" แต่ struct อย่าง repository ถูกสร้าง ครั้งเดียว แล้วใช้ร่วมกันทุก request
ถ้าเก็บ ctx ของ request แรก ไว้ใน struct request ที่ 2, 3, 4 จะได้ ctx ของคนอื่นไปใช้ — พอ request แรกถูก cancel ทุกคนที่มาทีหลังจะถูกยกเลิกตามไปด้วย ทั้งที่ไม่เกี่ยวอะไรเลย
4. ห้ามส่ง nil — ถ้ายังไม่มีให้ใช้ context.TODO()

ใน HTTP handler คุณได้ context มาฟรีจาก r.Context() ซึ่งจะถูกยกเลิกอัตโนมัติเมื่อผู้ใช้ปิดหน้าเว็บ — ส่งต่อไปเรื่อย ๆ แล้วทั้ง request จะยกเลิกตัวเองได้ทั้งเส้น

8 · Background() กับ TODO() ต่างกันตรงไหน

context.Background()

ctx := context.Background()

"นี่คือต้นทาง" — ใช้ใน main, ตอนเริ่ม server, ใน test, ในงานที่รันเป็น background job
ไม่มีวันถูกยกเลิก ไม่มี deadline ไม่มี value

context.TODO()

ctx := context.TODO()

"ยังไม่รู้ว่าจะเอา context จากไหน" — ใช้ตอนกำลังแก้โค้ดเก่าให้รองรับ context แต่ยังไม่ได้เดินสายจากข้างบนลงมา
เป็นป้ายบอกตัวเองว่า ยังไม่เสร็จ

ทั้งสองตัวทำงานเหมือนกันเป๊ะ — ต่างกันแค่เจตนา ที่สื่อให้คนอ่านโค้ด (และเครื่องมือวิเคราะห์บางตัวใช้แยกได้)

9 · Graceful Shutdown — ปิดโดยไม่ตัดงานกลางคัน

ตอน deploy หรือ scale down (ลดจำนวนเครื่องที่รันอยู่ลง) ระบบจะส่ง SIGTERM มาบอกให้ปิด
SIGTERM คือสัญญาณมาตรฐานของระบบปฏิบัติการ แปลว่า “ขอให้ปิดตัวเองนะ” ไม่ใช่การฆ่าทิ้งทันที — โปรแกรมมีสิทธิ์เก็บของก่อนจบ แต่มีสิทธิ์ก็ต่อเมื่อเราดักไว้เอง (signal.NotifyContext ข้างล่างนี่แหละ) ถ้าไม่ดัก OS ก็ปิดให้ทันทีอยู่ดี · ตัวที่ดักไม่ได้เลยคือ SIGKILL — โดนแล้วจบทันที ไม่มีโอกาสเก็บของ
ถ้าปิดทันที = request ที่ค้างอยู่ถูกตัดกลางคัน = ลูกค้าจ่ายเงินแล้วแต่ order ไม่ถูกบันทึก

ctx, stop := signal.NotifyContext(
    context.Background(),
    os.Interrupt,        // Ctrl+C
    syscall.SIGTERM,     // docker stop / kubernetes
)
defer stop()

// ... เปิด server แล้วส่ง ctx ต่อไปเรื่อย ๆ ...

<-ctx.Done()                        // ค้างตรงนี้จนกว่าจะมีสัญญาณ
fmt.Println("ได้รับสัญญาณปิด:", ctx.Err())

// เก็บงานให้เรียบร้อยก่อนจบ
// context ตัวที่สอง: ให้เวลาเก็บงานค้าง 30 วินาที ไม่เสร็จก็ตัดจบ
shutdownCtx, cancelShutdown := context.WithTimeout(
    context.Background(), 30*time.Second)
defer cancelShutdown()

srv.Shutdown(shutdownCtx)   // srv คือ *http.Server ที่เปิดไว้ข้างบน — ชั้น HTTP เรียนสัปดาห์ที่ 3

signal.NotifyContext ทำให้สัญญาณจากระบบปฏิบัติการ กลายเป็น context ธรรมดา — ทุกอย่างที่คุณเรียนมาในบทนี้ใช้ได้ทันที

ผมทดลองส่ง SIGTERM ให้โปรแกรมจริง ๆ ได้แบบนี้:

server กำลังรับ request...
server กำลังรับ request...

ได้รับสัญญาณปิด: context canceled
กำลังปิดงานค้าง (drain)...
ปิดเรียบร้อย ไม่มี request ไหนถูกตัดกลางคัน

สังเกตว่าได้ context canceled ไม่ใช่ DeadlineExceeded — เพราะสัญญาณจาก OS นับเป็นการ "กด cancel" ไม่ใช่ "หมดเวลา"
คำว่า drain ในผลรันข้างบนคือศัพท์ที่ใช้เรียกช่วงนี้: “ไม่รับงานใหม่แล้ว แต่ปล่อยงานที่ค้างอยู่ให้จบก่อน” เหมือนร้านที่ปิดป้ายแล้วแต่ยังให้คนที่นั่งอยู่กินให้เสร็จ
สำนวนเต็มในระบบจริงมีสอง context: ตัวแรกรับสัญญาณปิด ตัวที่สองเป็น WithTimeout กำหนดว่า "ให้เวลาเก็บของ 30 วินาที ถ้าไม่เสร็จก็ตัดจบ"

ทดสอบความเข้าใจ

1. เรียก cancel() แล้ว goroutine ที่กำลังทำงานอยู่จะเป็นยังไง?

cancel() แค่ปิด channel ที่ ctx.Done() คืนมา Go ไม่มีวิธีฆ่า goroutine จากข้างนอกเลย
ผมรัน worker ที่ Sleep อยู่ตอน timeout หมด — ไม่มีตัวไหนพิมพ์ "ได้รับสัญญาณ" เลยสักตัว
context คือป้ายบอกทาง ไม่ใช่ปุ่มหยุด — ต้องมีคนอ่านถึงได้ผล

2. ctx, cancel := context.WithTimeout(...) แล้วไม่เรียก cancel() เลย?

context เก็บ timer และผูกกับ context แม่ไว้ — cancel() คือตัวปลดของพวกนี้
งานเสร็จใน 10ms แต่ timeout 30 วินาที = ค้างเปล่า ๆ 30 วินาที ต่อหนึ่ง request · ที่ 1000 req/s = สะสมเป็นกอง
defer cancel() ทันทีบรรทัดถัดไปเสมอ · go vet มี check lostcancel ช่วยจับ

3. ทำไม ctx.Done() ถึงใช้วิธี "ปิด channel" แทนการ "ส่งค่า"?

ส่งค่า = คนแรกที่รับได้ไปคนเดียว คนอื่นยังรออยู่
ปิด channel = ทุกคนที่รอได้รับพร้อมกัน และรับซ้ำได้ไม่จำกัด (บทเรียน 22: รับจาก channel ที่ปิดแล้วได้ zero value ทันที ไม่ block)
context หนึ่งตัวถูกส่งต่อไป 10 ชั้น — ต้องแจ้งทุกชั้นพร้อมกันได้
นี่คือสำนวน broadcast มาตรฐานของ Go

4. signal.NotifyContext มีไว้ทำอะไร?

มันเชื่อมโลกของ OS (สัญญาณ) เข้ากับโลกของ Go (context)
ผมส่ง SIGTERM ให้โปรแกรมจริง ๆ แล้วได้ ได้รับสัญญาณปิด: context canceled — เป็น Canceled ไม่ใช่ DeadlineExceeded
พอเป็น context แล้ว ทุกอย่างที่เรียนมาในบทนี้ใช้ต่อได้ทันที

🛠️ ภารกิจ — ระบบที่ปิดตัวเองได้อย่างสุภาพ
  1. เขียน worker(ctx context.Context, id int, wg *sync.WaitGroup) ที่วน select พิมพ์ worker N ทำงาน ทุก 300ms และออกเมื่อ ctx.Done() พร้อมพิมพ์ ctx.Err()
  2. main เปิด 3 worker ด้วย context.WithTimeout(..., 2*time.Second) แล้ว wg.Wait()
    ต้องเห็นทั้ง 3 ตัวพิมพ์ข้อความปิดครบ
  3. เปลี่ยนเป็น WithCancel แล้วสั่ง cancel() หลัง 1 วินาที — ctx.Err() เปลี่ยนไปไหม?
  4. ลบ wg.Wait() ออก แล้วรันใหม่ — ยังเห็นข้อความปิดครบ 3 ตัวไหม? ทำไม?
  5. เปลี่ยนมาใช้ signal.NotifyContext รับ os.Interrupt แล้วกด Ctrl+C เอง — ctx.Err() เป็นอะไร?
  6. ท้าทาย 1 — worker ดื้อ: เขียน worker อีกตัว ที่ไม่เช็ค ctx.Done() เลย แค่ for { Sleep(300ms) } แล้วใส่เข้า wg ด้วย → เกิดอะไรขึ้นกับโปรแกรม?
  7. ท้าทาย 2 — context ซ้อนกัน: สร้าง parent timeout 5 วินาที แล้วสร้าง child จาก parent ด้วย timeout 1 วินาที
    ลองสลับ: parent 1 วิ / child 5 วิ อันไหนหมดก่อนชนะ? child เกินเวลา parent ได้ไหม?
  8. ท้าทาย 3: ลบ defer cancel() ออกทุกที่ แล้วสั่ง go vet ./... — vet ว่าอะไร?

ท้าทาย 1: wg.Wait() จะไม่มีวันคืนค่า — worker ดื้อไม่มีวันเรียก wg.Done() → fatal error: all goroutines are asleep - deadlock! พร้อม goroutine 1 [sync.WaitGroup.Wait]
นี่คือรูปธรรมของกฎข้อ 6 ในบทเรียน 24: ทุก goroutine ต้องตอบได้ว่าจะจบเมื่อไหร่

ท้าทาย 2: ตัวที่หมดก่อนชนะเสมอ ผมวัดจริงทั้งสองแบบ:

parent 5s, child 1s → child จบที่ 1s
parent 1s, child 5s → child จบที่ 1s   ← ไม่ได้ 5s

child ถูกยกเลิกทันทีที่ parent ถูกยกเลิก ตั้ง 5 วินาทีบน parent 1 วินาที ก็ได้จริงแค่ 1 วินาที
context ขยายเวลาให้ตัวเองไม่ได้ ทำได้แค่หดสั้นลง — เพราะแบบนี้ HTTP handler ที่รับ r.Context() มา จึงยกเลิกทั้งเส้นได้ทันทีที่ผู้ใช้ปิดหน้าเว็บ

💬 ท้าทาย 2 คือสิ่งที่ทำให้ context ทรงพลัง — ต้นทางยกเลิกได้เสมอ ปลายทางขัดขืนไม่ได้
บทถัดไปเอาทุกอย่างของ Day 6 มารวมกันเป็นรูปแบบเดียว: Worker Pool

อ้างอิง