Interface Context
Mọi thứ trong package này đều xây trên một interface duy nhất:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Deadline()trả về thời điểm context này sẽ tự động bị hủy, vàokcho biết có đặt deadline hay không — một context không có deadline không bị hủy theo thời gian, dù nó vẫn có thể bị hủy một cách tường minh.Done()trả về một channel sẽ được đóng ngay khi context bị hủy hoặc deadline của nó trôi qua. Một context không bao giờ có thể bị hủy — như những context trả về từBackground()vàTODO()— sẽ trả về channelniltừDone(), và nhận từ channelnilsẽ block mãi mãi, đây chính xác là hành vi mong muốn: mộtselecttrên nó đơn giản là không bao giờ được kích hoạt.Err()cho biết vì saoDone()bị đóng. Nó trả vềnilkhi context vẫn còn hoạt động, và trả về một error khácnilkhi context đã kết thúc — xem "các giá trị của ctx.Err()" bên dưới để biết hai sentinel error mà bạn sẽ thực sự gặp.Value(key)tra cứu một giá trị gắn với context này (hoặc bất kỳ context tổ tiên nào của nó) theo key, trả vềnilnếu không tìm thấy.
CancelFunc
type CancelFunc func()
CancelFunc báo cho thao tác gắn với context biết cần từ bỏ công việc của nó. Gọi hàm này không chờ công việc đó thực sự dừng lại — nó chỉ báo hiệu việc hủy bằng cách đóng channel Done() và đặt Err() — nên phía gọi vẫn cần để các goroutine đã bị hủy tự hoàn tất việc thoát ra. Gọi CancelFunc nhiều lần là an toàn; lần gọi đầu tiên thực hiện việc hủy, mọi lần gọi sau đó đều không có tác dụng gì (no-op). Gọi nó đồng thời từ nhiều goroutine cũng an toàn.
Background() và TODO()
ctx := context.Background()
Background() trả về một Context khác nil, rỗng, không bao giờ bị hủy, không có deadline, và không mang giá trị nào. Đây là gốc của mọi cây context, và là thứ bạn dùng trong main, trong khởi tạo package, trong test, và nói chung ở tầng trên cùng của chương trình nơi chưa có context cha nào để dẫn xuất.
ctx := context.TODO()
TODO() trả về đúng thứ giống hệt Background(), nhưng nó truyền tải một ý nghĩa khác cho người đọc: "hàm này rồi sẽ cần nhận một context từ caller của nó, nhưng phần kết nối đó chưa được làm" — hoặc "tôi chưa chắc nên dùng context nào ở đây". Đây là một dấu hiệu cho việc refactor sau này, không phải một loại context khác về mặt chức năng.
WithValue
func main() {
processID := "abc-xyz"
ctx := context.Background()
ctx = context.WithValue(ctx, "processID", processID)
ProcessRequest(ctx)
}
func ProcessRequest(ctx context.Context) {
value := ctx.Value("processID")
fmt.Printf("Processing ID: %v", value)
}
$ go run main.go
Processing ID: abc-xyz
WithValue trả về một bản sao của context cha mà thêm vào đó có mang theo cặp key/value đã cho; tra cứu bất kỳ key nào khác vẫn sẽ đi ngược lên context cha. main gắn processID vào context, và đến khi ProcessRequest gọi ctx.Value("processID"), nó nhận lại được chuỗi đó, nên chương trình in ra Processing ID: abc-xyz.
⚠️ Key kiểu string thô dễ va chạm:
context.WithValue(ctx, "processID", processID)dùng chuỗi"processID"thuần túy làm key. Bất kỳ package nào khác cũng tình cờ gọictx.WithValue(ctx, "processID", giaTriKhac)sẽ âm thầm ghi đè hoặc che khuất giá trị của bạn, vì hai key string giống hệt nhau là không thể phân biệt được đối vớiValue(), bất kể package nào đặt chúng. Cảgo vetlẫnstaticcheckđều đánh dấu (flag) các kiểu built-in (string,int, v.v.) khi được dùng làm context key chính vì lý do này. Cách khắc phục là định nghĩa một kiểu key tùy chỉnh không export, để compiler đảm bảo chỉ package của bạn mới có thể tạo ra một key khớp:type ctxKey string
const processIDKey ctxKey = "processID"
ctx = context.WithValue(ctx, processIDKey, processID)
// later:
value := ctx.Value(processIDKey)Vì
ctxKeylà một kiểu riêng biệt, một string literal"processID"từ một package không liên quan sẽ không bao giờ có thể va chạm vớiprocessIDKey, dù nội dung chuỗi giống hệt nhau. Cũng cần nhớ rằngWithValueđược thiết kế cho dữ liệu meta theo phạm vi request kiểu như thế này, không phải để truyền các tham số tùy chọn của hàm — xem "values chỉ dành cho dữ liệu meta theo phạm vi request" bên dưới.
WithCancel
ctx, cancel := context.WithCancel(parent)
WithCancel trả về một context dẫn xuất cùng với một CancelFunc. Gọi cancel() sẽ đóng channel Done() của context dẫn xuất đó (và channel Done() của mọi context dẫn xuất từ nó), đặt Err() của nó thành context.Canceled. Quy ước là gọi cancel ngay khi công việc mà nó bảo vệ hoàn tất — dù thành công hay không — thay vì giữ nó lại và truyền đi để gọi ở đâu đó khác về sau.
⚠️ Luôn
defer cancel(): mọi context được trả về từWithCancel,WithTimeout, hayWithDeadlineđều giữ tài nguyên bên trong nó — ít nhất là một goroutine theo dõi việc hủy của context cha và một mục trong danh sách con của context cha để thông báo. Nếu bạn không bao giờ gọicancel, những tài nguyên đó sẽ được giữ lại cho đến khi chính context cha bị hủy, mà với một context cha sống lâu dài (như context gốc của một HTTP server) thì có thể là không bao giờ. Cách làm đúng chuẩn là gọidefer cancel()ngay sau khi tạo context, để nó luôn được kích hoạt bất kể hàm bao quanh trả về theo cách nào:ctx, cancel := context.WithCancel(context.Background())
defer cancel() // luôn giải phóng tài nguyên, kể cả khi return sớm hoặc panic
doWork(ctx)
WithDeadline và WithTimeout
ctx, cancel := context.WithDeadline(parent, someTime)
WithDeadline trả về một context tự động bị hủy khi đồng hồ vượt qua someTime, hoặc sớm hơn nếu cancel được gọi thủ công, hoặc sớm hơn nữa nếu context cha bị hủy trước — điều gì xảy ra trước sẽ thắng.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
WithTimeout đơn giản là cách viết tắt của WithDeadline(parent, time.Now().Add(timeout)) — đây là dạng phổ biến hơn trong thực tế vì hầu hết code suy nghĩ theo kiểu "nên chờ bao lâu" thay vì "nên dừng vào thời điểm đồng hồ nào".
⚠️ Cả hai hàm này cũng trả về một
CancelFunc, và quy tắcdefer cancel()tương tự vẫn áp dụng dù context rồi sẽ tự hủy khi deadline trôi qua. Gọicancelsớm — ngay khi công việc được bảo vệ hoàn tất thành công, từ lâu trước deadline — sẽ dừng timer nội bộ ngay lập tức thay vì để nó tiếp tục chạy (và giữ tài nguyên của nó) cho đến khi deadline tự đến.
Dùng context trong HTTP server
func handleRequest(w http.ResponseWriter, req *http.Request) {
fmt.Println("Handler started")
context := req.Context()
select {
case <-time.After(5 * time.Second):
fmt.Fprintf(w, "Response from the server")
case <-context.Done():
err := context.Err()
fmt.Println("Error:", err)
}
fmt.Println("Handler complete")
}
func main() {
http.HandleFunc("/request", handleRequest)
fmt.Println("Server is running...")
http.ListenAndServe(":4000", nil)
}
Mỗi *http.Request đến đều mang theo một context truy cập được qua req.Context(), và standard library tự động hủy nó khi kết nối phía client đóng lại. Handler đua hai thứ trong select của nó: một timer 5 giây qua time.After, và context.Done(). Nếu client curl chờ đủ hết năm giây, time.After kích hoạt trước và handler ghi ra "Response from the server". Nhưng nếu bạn ngắt client giữa chừng — chẳng hạn bấm Ctrl+C trên lệnh curl trước khi nó hoàn tất — kết nối đóng lại, req.Context() bị hủy, context.Done() kích hoạt trước, và server ghi log Error: context canceled thay vào đó. Pattern này chính xác là cách bạn tránh thực hiện những công việc tốn kém tài nguyên (truy vấn database, gọi API downstream) thay mặt cho một client đã không còn ở đó nữa.
Nâng cao: quy tắc lan truyền (propagation) context
Một số ít quy ước giúp việc dùng context trở nên dễ đoán trên toàn bộ codebase, và tuân theo chúng chính là điều giúp các package không liên quan kết hợp với nhau mà không gây bất ngờ:
- Context luôn là tham số đầu tiên, và theo quy ước nó được đặt tên
ctx:func FetchUser(ctx context.Context, id string) (*User, error) { ... }
- Không bao giờ lưu context bên trong một struct. Rất dễ bị cám dỗ lưu context của một request làm field trong struct để khỏi phải liên tục truyền nó qua từng chữ ký hàm, nhưng làm vậy sẽ gắn chặt vòng đời của struct với việc hủy và deadline của một request duy nhất, điều này thường không phải điều bạn muốn nếu struct sống lâu hơn request hoặc được tái sử dụng qua nhiều request. Thay vào đó, hãy truyền
ctxmột cách tường minh qua mọi lời gọi hàm trong chuỗi — đây là ngoại lệ duy nhất mà standard library và các style guide của Go chấp nhận đối với nguyên tắc "đừng lặp lại chính mình" (don't repeat yourself), chính vì việc lan truyền ngầm sẽ đánh bại mục đích của một tín hiệu hủy tường minh. - Dẫn xuất, đừng thay đổi. Mọi hàm
With*đều trả về một context mới; context cha không bao giờ bị thay đổi. Đây chính là điều khiến context an toàn để chia sẻ giữa các goroutine — việc hủy ở một con không bao giờ có thể quay ngược lại hủy một context anh em hoặc context cha.
Nâng cao: các giá trị của ctx.Err()
Một khi Done() đóng, ctx.Err() luôn trả về đúng một trong hai sentinel error, và việc kiểm tra cái nào cho bạn biết lý do, có thể rẽ nhánh bằng errors.Is:
context.Canceled— trả về khi context bị hủy bởi một lời gọi tường minh tớiCancelFunccủa nó.context.DeadlineExceeded— trả về khi deadline hoặc timeout của context trôi qua trước khi nó bị hủy thủ công.
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
log.Println("operation timed out")
} else {
log.Println("operation canceled:", ctx.Err())
}
case result := <-resultCh:
use(result)
}
Nâng cao: WithTimeout cho HTTP client và truy vấn database
Các lời gọi ra bên ngoài chính là nơi mà việc chờ không giới hạn có thể biến một dependency chậm duy nhất thành một sự cố lan tỏa (cascading outage), nên bọc mỗi lời gọi ngoài bằng timeout riêng của nó là thông lệ chuẩn:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", id)
Cả net/http (qua http.NewRequestWithContext) lẫn database/sql (qua các biến thể phương thức *Context như QueryRowContext) đều nhận context chính là để một lời gọi mạng chậm hay một truy vấn bị kẹt có thể bị hủy thay vì làm goroutine gọi nó treo vô thời hạn.
Nâng cao: AfterFunc và WithoutCancel
Hai bổ sung từ Go 1.21 đáng để biết dù bạn hiếm khi dùng đến. context.AfterFunc(ctx, f) sắp xếp để chạy f trong goroutine riêng của nó ngay khi ctx kết thúc, hữu ích để gắn logic dọn dẹp vào việc hủy của một context mà không phải tự viết tay vòng lặp select/Done(). context.WithoutCancel(ctx) trả về một bản sao của ctx vẫn mang theo mọi giá trị của nó nhưng đã tách khỏi việc hủy và deadline của context cha — hữu ích cho trường hợp hiếm khi bạn muốn khởi chạy công việc nền (chẳng hạn ghi audit log) cần sống lâu hơn cả request đã kích hoạt nó, trong khi vẫn mang theo trace ID của request hay dữ liệu meta khác.
Nâng cao: values chỉ dành cho dữ liệu meta theo phạm vi request
WithValue được thiết kế có chủ đích giới hạn cho những thứ như trace ID, request ID, hoặc một user/session đã xác thực lấy từ auth header — dữ liệu mô tả chính request đó và cần chạm tới code nằm sâu bên trong mà không phải luồn qua từng chữ ký hàm trung gian. Nó không phải là thứ thay thế cho tham số hàm thông thường. Nếu một giá trị là bắt buộc để một hàm làm đúng việc của nó — chứ không chỉ "có thì tốt cho việc logging" — hãy truyền nó tường minh như một tham số. Các giá trị nhét trong context là vô hình tại nơi gọi, không có kiểu cho đến khi bạn assert chúng, và dễ bị âm thầm bỏ sót; một tham số bắt buộc bị thiếu là lỗi compile, còn một giá trị context bị thiếu thường chỉ là một nil mà bạn không nhận ra cho đến khi lên production.
DUKE
Bình luận