Context
context.Context คืออะไร?
หัวข้อที่มีชื่อว่า “context.Context คืออะไร?”context.Context เป็น standard interface ที่ส่งต่อ:
- Cancellation signal — goroutine สามารถตรวจสอบ
ctx.Done()เพื่อรู้ว่าควรหยุดทำงาน - Deadline หรือ timeout ที่เป็น optional — งานถูก cancel อัตโนมัติเมื่อถึงเวลา
- Request-scoped key/value pairs — metadata ขนาดเล็กเช่น request ID หรือ auth token
Package context แก้ปัญหาที่เป็นรูปธรรม: เมื่อ server request ถูก cancel (user ปิด browser, upstream timeout) goroutines ที่รันสำหรับ request นั้นจะรู้ได้อย่างไรว่าควรหยุด? การส่ง context.Context เป็น argument แรกให้ทุก I/O function คือคำตอบมาตรฐานของ Go
การสร้าง Contexts
หัวข้อที่มีชื่อว่า “การสร้าง Contexts”// Root contexts — never carry cancellationcontext.Background() // top-level root, use in main or test setupcontext.TODO() // placeholder when you are unsure which context to use
// Derived contexts — add cancellation/timeout on top of a parentctx, cancel := context.WithCancel(parent)ctx, cancel := context.WithTimeout(parent, 5*time.Second)ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))ctx2 := context.WithValue(parent, key, value)Derived contexts สร้าง tree การ cancel parent จะ cancel ลูกทั้งหมดอัตโนมัติ
flowchart TD BG["context.Background()"] --> Req["WithCancel (request ctx)"] Req --> DB["WithTimeout (db call)"] Req --> Val["WithValue (adds reqID)"] Req -.cancel called.-> Cancelled["Req, DB, and Val all cancelled together"] Req --> WOC["WithoutCancel (background audit)"] WOC -.immune to Req's cancel, keeps its values.-> Survives["outlives the request"]
Cancellation Contract
หัวข้อที่มีชื่อว่า “Cancellation Contract”เมื่อเรียก context.WithCancel หรือ context.WithTimeout คุณได้รับสองค่า: derived context และ cancel function คุณ ต้อง เรียก cancel() เมื่องานเสร็จ — แม้ context จะหมดอายุตามธรรมชาติ ถ้าไม่เรียก parent context จะถือ reference ถึง child context จนกว่าจะ cancel parent ด้วย ซึ่งทำให้ leak ทรัพยากร
ctx, cancel := context.WithCancel(context.Background())defer cancel() // alwaysฟัง Cancellation
หัวข้อที่มีชื่อว่า “ฟัง Cancellation”ใน goroutine ctx.Done() return channel ที่ถูกปิดเมื่อ context ถูก cancel หรือ timeout ใช้ select เพื่อรวมกับงานจริง:
select {case result := <-workCh: return result, nilcase <-ctx.Done(): return "", ctx.Err() // context.Canceled or context.DeadlineExceeded}context.WithValue
หัวข้อที่มีชื่อว่า “context.WithValue”context.WithValue แนบคู่ key/value เข้ากับ context ใช้เฉพาะกับข้อมูลที่เป็น request-scoped และข้ามขอบเขต API เท่านั้น (request ID, auth token) อย่าใช้มันเป็นกลไกทั่วไปสำหรับส่งผ่าน parameter — ค่าพวกนั้นควรอยู่ใน function argument แทน
เพื่อหลีกเลี่ยง key ชนกับ package อื่น ให้ใช้ type ที่เป็น unexported และเป็นของ package ตัวเองเป็น key เสมอ:
type contextKey stringconst requestIDKey contextKey = "requestID"ใช้ typed getter function แทน bare type assertion ทุกจุดที่เรียกใช้ — func requestID(ctx context.Context) (string, bool) { v, ok := ctx.Value(requestIDKey).(string); return v, ok } — เพื่อให้ค่าที่ขาดหายเป็น false ที่ตรวจสอบได้ แทนที่จะเป็น panic comma-ok form ตรงนี้สำคัญด้วยเหตุผลเดียวกับที่อธิบายใน section อ่านโค้ดที่ AI เขียนด้านล่าง
Cause: ทำไม Context ถึงถูกยกเลิก?
หัวข้อที่มีชื่อว่า “Cause: ทำไม Context ถึงถูกยกเลิก?”ctx.Err() return แค่หนึ่งใน sentinel value สองตัวเท่านั้น — context.Canceled หรือ context.DeadlineExceeded — ซึ่งบอกแค่ ว่าเกิดอะไรขึ้น แต่ไม่เคยบอกทำไม context.WithCancelCause (และ context.WithTimeoutCause สำหรับกรณี deadline) ให้คุณแนบเหตุผลจริงเข้าไปได้ แล้วดึงกลับมาทีหลังด้วย context.Cause:
ctx, cancel := context.WithCancelCause(parent)cancel(errQuotaExceeded) // the "cause"
ctx.Err() // context.Canceled -- always the generic sentinelcontext.Cause(ctx) // errQuotaExceeded -- the specific reasonctx.Err() ยังคงเป็น sentinel ที่คงที่ที่ผู้เรียกทุกคนเทียบได้; context.Cause(ctx) คือ error ที่ให้รายละเอียดมากกว่าสำหรับ log หรือ response ให้ client ถ้า cancel ถูกเรียกด้วย nil cause จะเป็น context.Canceled — เหมือนกับ WithCancel ธรรมดา
WithoutCancel: แยกตัวออกจาก Cancellation ของ Parent
หัวข้อที่มีชื่อว่า “WithoutCancel: แยกตัวออกจาก Cancellation ของ Parent”งานเบื้องหลังที่ต้องมีชีวิตอยู่นานกว่า request ที่เริ่มมัน — การ refresh cache, การเขียน audit log — ไม่ควรสืบทอด cancellation ของ parent แต่ปกติแล้วยังควรพก request-scoped value ไปด้วย (trace ID สำหรับ logging) context.WithoutCancel (Go 1.21) ทำแบบนั้นพอดี: context ที่ return มาจะไม่ report Done, Err, หรือ Cause จาก cancellation ของ parent เลย ในขณะที่ Value lookup ยังเดินขึ้นไปหา parent ตามปกติ
func handleRequest(ctx context.Context) { go auditLog(context.WithoutCancel(ctx), "request handled") // ...respond to the request; ctx may be cancelled the instant this returns}การหันไปใช้ context.Background() แทนคือความผิดพลาดที่ AI สร้างบ่อยตรงนี้ — มันแยกออกจาก cancellation ได้จริง แต่ก็ทิ้งทุก value ที่ context ต้นฉบับพกมาไปด้วย รวมถึงอะไรก็ตามที่ logging หรือ tracing middleware แนบไว้ต้นทาง
AfterFunc: ตอบสนองต่อ Cancellation โดยไม่ต้องใช้ select
หัวข้อที่มีชื่อว่า “AfterFunc: ตอบสนองต่อ Cancellation โดยไม่ต้องใช้ select”context.AfterFunc(ctx, f) (Go 1.21) รัน f ใน goroutine ของตัวเองทันทีที่ ctx เสร็จสิ้น — รวมถึงรันทันทีในกรณีที่ ctx เสร็จสิ้นไปแล้วตอนที่คุณเรียกมัน มัน return function stop ที่ยกเลิกความสัมพันธ์นั้น; stop() return true ก็ต่อเมื่อมันป้องกันไม่ให้ f รันได้จริง (ถ้า f เริ่มไปแล้ว stop return false และไม่รอให้ f ทำงานเสร็จ) นี่คือทางเลือกที่เบากว่าการเปิด goroutine เล็ก ๆ ที่ select บน ctx.Done() แค่เพื่อรัน cleanup callback
กฎของการใช้ Context
หัวข้อที่มีชื่อว่า “กฎของการใช้ Context”checklist สั้น ๆ ที่ครอบคลุมการละเมิดกฎ idiomatic-context ที่พบบ่อยเกือบทั้งหมดในโค้ดที่ generate มา:
ctxเป็น parameter แรกเสมอ ชื่อctxไม่เคยถูกเก็บไว้ใน struct field- อย่าส่ง context ที่เป็น
nilเลย — ใช้context.TODO()เมื่อยังไม่ชัดเจนจริง ๆ ว่า context ไหนควรใช้ แล้วแทนที่ด้วยตัวจริงเมื่อมันถูก thread ผ่านมา - HTTP handler เอา context มาจาก request:
r.Context()ไม่ใช่context.Background()ใหม่ mainที่ต้อง shutdown ตอนSIGINT/SIGTERMใช้signal.NotifyContext(context.Background(), os.Interrupt)แทนที่จะเขียน signal channel เอง- ทุก
WithCancel/WithTimeout/WithDeadlineต้องมีdefer cancel()ติดกันตรงจุดที่สร้าง ตามที่ callout ของบทนี้บอกไปแล้ว
errors.Is สำหรับ Context Error
หัวข้อที่มีชื่อว่า “errors.Is สำหรับ Context Error”ctx.Err() return sentinel value ที่แน่นอนคือ context.Canceled และ context.DeadlineExceeded ดังนั้นการเทียบด้วย == ตรง ๆ ยังใช้ได้วันนี้ แต่ควรใช้ errors.Is(err, context.Canceled) แทนอยู่ดี เมื่อ error นั้นผ่านอะไรก็ตามที่อาจ wrap มันไว้ — fmt.Errorf("fetch: %w", ctx.Err()) — เพราะ == จะหยุดทำงานทันทีที่ sentinel ถูก wrap ในขณะที่ errors.Is unwrap chain เพื่อหามันเจอ:
if errors.Is(err, context.Canceled) { // the caller gave up; not worth logging as a failure} else if errors.Is(err, context.DeadlineExceeded) { // ran out of time; may be worth retrying with a longer budget}เช็ก ctx.Err() ใน CPU-bound Loop
หัวข้อที่มีชื่อว่า “เช็ก ctx.Err() ใน CPU-bound Loop”select บน ctx.Done() ช่วยได้ก็ต่อเมื่อ loop body มี channel operation จริง ๆ tight loop แบบ CPU-bound ที่ไม่มี channel op เลยสักที่ — เช่นคำนวณบน slice ขนาดใหญ่ — ไม่มีวันไปถึง select เลย ดังนั้นมันจะรันจนจบแม้ caller จะ cancel ไปแล้วก็ตาม:
for i, item := range hugeSlice { if i%1024 == 0 && ctx.Err() != nil { // poll periodically, not every iteration return ctx.Err() } process(item)}การเช็กทุก iteration มักจะเป็น overhead ที่เสียเปล่าสำหรับ operation ต่อ item ที่ถูก การเช็กเป็นช่วง (ทุก item ที่ 1024 หรือทุก N มิลลิวินาทีผ่าน time.Since) รักษา overhead ให้น้อยมากในขณะที่ยังตอบสนองต่อ cancellation ได้ทันเวลา
package main
import ( "context" "fmt")
func fetchData(ctx context.Context, id int) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() default: return fmt.Sprintf("data-%d", id), nil }}
type ctxKey string
func main() { // WithCancel: normal fetch succeeds ctx, cancel := context.WithCancel(context.Background()) defer cancel()
result, err := fetchData(ctx, 1) if err != nil { fmt.Println("error:", err) } else { fmt.Println("got:", result) }
// Cancel, then try again — now the context is done cancel() result2, err2 := fetchData(ctx, 2) if err2 != nil { fmt.Println("cancelled:", err2) } else { fmt.Println("got:", result2) }
// WithValue: attach request-scoped metadata ctx3 := context.WithValue(context.Background(), ctxKey("requestID"), "abc-123") rid := ctx3.Value(ctxKey("requestID")).(string) fmt.Println("requestID:", rid)} Snippet ที่สองด้านล่างครอบคลุม WithCancelCause/Cause, WithoutCancel, และ AfterFunc ร่วมกัน
package main
import ( "context" "errors" "fmt")
var errQuotaExceeded = errors.New("quota exceeded")
type ctxKey string
func main() { // WithCancelCause / Cause: record *why* a context was cancelled, not just that it was. ctx, cancel := context.WithCancelCause(context.Background()) cancel(errQuotaExceeded) fmt.Println("Err():", ctx.Err()) // the generic sentinel fmt.Println("Cause():", context.Cause(ctx)) // the specific reason fmt.Println("errors.Is Canceled:", errors.Is(ctx.Err(), context.Canceled)) fmt.Println("errors.Is quota:", errors.Is(context.Cause(ctx), errQuotaExceeded))
// WithoutCancel: detach from the parent's cancellation but keep its values. parent := context.WithValue(context.Background(), ctxKey("reqID"), "abc-123") parentCtx, parentCancel := context.WithCancel(parent) parentCancel() // parent is already cancelled bg := context.WithoutCancel(parentCtx) fmt.Println("parent ctx.Err():", parentCtx.Err()) // context.Canceled fmt.Println("detached ctx.Err():", bg.Err()) // nil -- immune to the parent's cancellation fmt.Println("detached still has value:", bg.Value(ctxKey("reqID")))
// AfterFunc: run a callback when a context is done, without a manual select. done := make(chan struct{}) afterCtx, afterCancel := context.WithCancel(context.Background()) context.AfterFunc(afterCtx, func() { fmt.Println("AfterFunc ran because ctx was cancelled") close(done) }) afterCancel() <-done} ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| context.WithCancel | propagate cancellation tree | ต้อง call cancel() เสมอ ไม่งั้น goroutine leak |
| context.WithTimeout | deadline อัตโนมัติ | ต้อง handle context.DeadlineExceeded |
| context.WithValue | pass value ผ่าน call stack | type unsafe, ใช้ unexported key เพื่อหลีกเลี่ยง collision |
| ctx.Done() channel | non-blocking check via select | ต้อง check ในทุก loop iteration ที่อาจรันนาน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- context.WithValue เหมาะสำหรับ pass business data — ใช้สำหรับ request-scoped metadata เท่านั้น (trace ID, auth token)
- cancel() ทำให้ goroutine หยุดทันที — cancel() ส่ง signal ผ่าน channel — goroutine ต้องตรวจสอบ ctx.Done() เอง
- context ส่ง value ได้ทุก type — context.Value() คืน any ต้องใช้ type assertion และอาจ nil
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างมาใช้ context.Context ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- ทุกการเรียก
ctx.Value(key)ใช้ comma-ok type assertion (v, ok := ctx.Value(key).(T)) แทนที่จะเป็นแบบเปล่า (ctx.Value(key).(T)) ไหม? bare assertion จะ panic ทันทีที่ key ไม่เคยถูกตั้งบน context นั้น ctxถูกเก็บไว้ใน struct field แทนที่จะส่งเป็น parameter แรกของทุก function ที่ต้องใช้มันหรือเปล่า?- งานเบื้องหลังที่ต้องมีชีวิตอยู่นานกว่า request ใช้
context.WithoutCancel(ctx)ไหม — หรือ fallback ไปใช้context.Background()แล้วทิ้งทุก value ที่ context ต้นฉบับพกมาไปแบบเงียบ ๆ? - มี
case <-ctx.Done()ตรงไหนที่ CPU-bound loop ที่ไม่มี channel operation ควรจะยกเลิกได้ไหม? ถ้าไม่มีการเช็กctx.Err()ตรง ๆ ข้างใน loop body cancellation จะมองไม่เห็นเลยสำหรับมัน - error handling เทียบ
err == context.Canceledตรง ๆ ไหม ทั้งที่ error นั้นอาจถูก wrap ด้วย%wที่ไหนสักแห่งต้นทาง?errors.Isจำเป็นทันทีที่มีโอกาส wrap ได้
🐛 หา bug ในโค้ดนี้
type ctxKey string
func requestID(ctx context.Context) string { return ctx.Value(ctxKey("reqID")).(string) // bare assertion}
func main() { ctx := context.Background() // reqID was never attached to this context fmt.Println(requestID(ctx))}เฉลย
ctx.Value(ctxKey("reqID")) return nil (เป็น any) เพราะไม่มีใครเรียก context.WithValue ด้วย key นี้บน context นี้หรือบรรพบุรุษของมันเลย การ assert nil ที่เป็น any ตรง ๆ ไปเป็น string — concrete type ที่ไม่ใช่ interface — จะ panic แทนที่จะ return zero value:
panic: interface conversion: interface {} is nil, not string
goroutine 1 [running]:main.requestID(...) /tmp/ctxassert/main.go:11main.main() /tmp/ctxassert/main.go:16 +0x34วิธีแก้คือ comma-ok form: v, ok := ctx.Value(ctxKey("reqID")).(string); if !ok { return "" } (หรือ return error ขึ้นอยู่กับว่า request ID ที่หายไปคือสิ่งที่คาดไว้หรือเปล่า) นี่คือ typed-getter pattern เดียวกับที่แนะนำไว้ก่อนหน้านี้ใน section context.WithValue ของบทนี้