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

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

// Root contexts — never carry cancellation
context.Background() // top-level root, use in main or test setup
context.TODO() // placeholder when you are unsure which context to use
// Derived contexts — add cancellation/timeout on top of a parent
ctx, 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"]
การ cancel context หนึ่งจะ cancel ทุก context ที่ derive มาจากมัน ยกเว้นตัวที่สร้างด้วย WithoutCancel

เมื่อเรียก 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

ใน goroutine ctx.Done() return channel ที่ถูกปิดเมื่อ context ถูก cancel หรือ timeout ใช้ select เพื่อรวมกับงานจริง:

select {
case result := <-workCh:
return result, nil
case <-ctx.Done():
return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
}

context.WithValue แนบคู่ key/value เข้ากับ context ใช้เฉพาะกับข้อมูลที่เป็น request-scoped และข้ามขอบเขต API เท่านั้น (request ID, auth token) อย่าใช้มันเป็นกลไกทั่วไปสำหรับส่งผ่าน parameter — ค่าพวกนั้นควรอยู่ใน function argument แทน

เพื่อหลีกเลี่ยง key ชนกับ package อื่น ให้ใช้ type ที่เป็น unexported และเป็นของ package ตัวเองเป็น key เสมอ:

type contextKey string
const 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 เขียนด้านล่าง

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 sentinel
context.Cause(ctx) // errQuotaExceeded -- the specific reason

ctx.Err() ยังคงเป็น sentinel ที่คงที่ที่ผู้เรียกทุกคนเทียบได้; context.Cause(ctx) คือ error ที่ให้รายละเอียดมากกว่าสำหรับ log หรือ response ให้ client ถ้า cancel ถูกเรียกด้วย nil cause จะเป็น context.Canceled — เหมือนกับ WithCancel ธรรมดา

งานเบื้องหลังที่ต้องมีชีวิตอยู่นานกว่า 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 แนบไว้ต้นทาง

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

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 ของบทนี้บอกไปแล้ว

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
}

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.WithCancelpropagate cancellation treeต้อง call cancel() เสมอ ไม่งั้น goroutine leak
context.WithTimeoutdeadline อัตโนมัติต้อง handle context.DeadlineExceeded
context.WithValuepass value ผ่าน call stacktype unsafe, ใช้ unexported key เพื่อหลีกเลี่ยง collision
ctx.Done() channelnon-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 สร้างมาใช้ 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:11
main.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 ของบทนี้

วิธี idiomatic ที่ถูกต้องในการจัดการ cancel function จาก context.WithCancel คืออะไร?
ctx.Done() return channel อะไร และถูกปิดเมื่อไหร่?
ctx.Err() return อะไรเมื่อ deadline ของ context เกินแล้ว?
ทำไมควรใช้ unexported package-private type เป็น context.WithValue key?
โค้ดนี้ print อะไร?
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errQuotaExceeded)
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
เกิดอะไรขึ้นเมื่อรัน?
type ctxKey string

func requestID(ctx context.Context) string {
    return ctx.Value(ctxKey("reqID")).(string)
}

func main() {
    fmt.Println(requestID(context.Background()))
}