Context trong Go: timeout, cancel và truyền giá trị
1. Tổng quan
Khi viết service Go xử lý nhiều goroutine, bạn cần một cách thống nhất để nói với goroutine con: “dừng lại, tôi không cần kết quả nữa” hoặc “chỉ chạy trong 2 giây rồi bỏ”. context.Context sinh ra để giải quyết đúng vấn đề đó.
Bài này đi qua ba việc chính bạn làm với context: hủy (cancel) thủ công, đặt timeout/deadline, và truyền giá trị request-scoped qua các lớp gọi hàm. Đọc xong bạn sẽ viết được HTTP handler, hàm gọi DB và hàm gọi goroutine con biết tự dừng đúng lúc, không để goroutine rò rỉ.
2. Context là gì và vì sao cần nó
context.Context là một interface mô tả “ngữ cảnh” của một luồng xử lý (thường là một request). Nó mang theo:
- Tín hiệu hủy (cancel signal).
- Deadline/timeout.
- Một số giá trị key-value nhỏ, gắn với request.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Done()trả về channel, đóng lại khi context bị hủy hoặc hết hạn.Err()cho biết lý do:context.Canceledhaycontext.DeadlineExceeded.Value()lấy giá trị đã gắn vào context.
Lưu ý: Context không dùng để truyền optional parameter tùy ý. Nó chỉ nên mang tín hiệu điều khiển và dữ liệu request-scoped như request ID, user hiện tại — không phải config hay business object quan trọng.
3. Cancel thủ công với WithCancel
Dùng khi bạn muốn tự quyết định lúc nào hủy, ví dụ khi một trong nhiều goroutine đã có kết quả thì hủy các goroutine còn lại.
func fetchFromMultipleSources(ctx context.Context) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // luôn gọi cancel để giải phóng resource
resultCh := make(chan string, 1)
for _, src := range []string{"cache", "db", "api"} {
go func(source string) {
select {
case <-time.After(100 * time.Millisecond):
resultCh <- source
cancel() // có kết quả rồi, hủy các source khác
case <-ctx.Done():
return // bị hủy, thoát sớm
}
}(src)
}
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
context.WithCancel(ctx)tạo context con và hàmcancelđể hủy nó.defer cancel()bắt buộc phải có: nếu không gọi, context con “treo” đến khi parent bị hủy, gây leak goroutine đangselecttrênctx.Done().- Goroutine nào xong trước gọi
cancel(), các goroutine còn lại nhận tín hiệu quactx.Done()và dừng.
Lỗi hay gặp: quên defer cancel(). Nếu hàm trả về sớm (ví dụ do error) mà không hủy context, goroutine con vẫn sống tới khi bị garbage collect theo cách gián tiếp — thực chất là leak.
4. Timeout và deadline với WithTimeout, WithDeadline
4.1. WithTimeout cho giới hạn thời gian tương đối
func callPaymentService(ctx context.Context, orderID string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://payment.local/orders/"+orderID, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // có thể là context.DeadlineExceeded
}
defer resp.Body.Close()
return nil
}
context.WithTimeout(ctx, 2*time.Second)tự động hủy context sau 2 giây.http.NewRequestWithContextgắn context vào request; khi context hết hạn,http.Clienttự hủy request đang chạy.- Nếu quá 2 giây,
resp, err := http.DefaultClient.Do(req)trảerrbọccontext.DeadlineExceeded.
Kiểm tra lỗi timeout:
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("payment service timeout")
}
return err
}
4.2. WithDeadline cho mốc thời gian tuyệt đối
func processBeforeMidnight(ctx context.Context) error {
deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
select {
case <-time.After(5 * time.Hour):
return errors.New("đã xử lý xong")
case <-ctx.Done():
return ctx.Err()
}
}
WithDeadlinedùng khi bạn có một mốc thời gian cụ thể (ví dụ cuối ngày), khác vớiWithTimeoutlà khoảng thời gian tương đối từ bây giờ.- Về bản chất,
WithTimeout(ctx, d)chỉ là lớp mỏng gọiWithDeadline(ctx, time.Now().Add(d)).
5. Truyền giá trị qua context với WithValue
Dùng cho dữ liệu gắn với request, không phải tham số nghiệp vụ.
type requestIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey{}).(string)
return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
if id, ok := RequestIDFromContext(r.Context()); ok {
log.Printf("[%s] xử lý order", id)
}
}
- Dùng
type requestIDKey struct{}làm key riêng biệt — tránh dùngstringtrực tiếp vì dễ đụng key của package khác. WithRequestIDvàRequestIDFromContextđóng gói việc set/get, package khác không cần biết key thật là gì.r.WithContext(ctx)tạo request mới mang context đã gắn giá trị, truyền tiếp qua middleware chain.
Lưu ý: Không dùng
context.WithValueđể truyền*sql.DB, config, hay bất cứ thứ gì hàm cần để hoạt động đúng. Những thứ đó nên là tham số rõ ràng hoặc field của struct. Context value chỉ hợp với dữ liệu “nice to have” như request ID, trace ID, user đăng nhập.
6. Context.TODO và context.Background
func main() {
ctx := context.Background() // root context, không bao giờ bị hủy
run(ctx)
}
func legacyFunctionBeingRefactored() {
ctx := context.TODO() // placeholder, sẽ thay bằng context thật sau
_ = ctx
}
context.Background()là context gốc, dùng ởmain, test, hoặc nơi khởi tạo top-level.context.TODO()dùng khi bạn đang refactor và chưa chắc context nào nên truyền vào — về chức năng giốngBackground()nhưng báo hiệu rõ ý định cho người đọc code và tool linter.
7. Lan truyền context qua nhiều lớp gọi hàm
Quy ước: ctx context.Context luôn là tham số đầu tiên.
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
if err := s.validate(ctx, order); err != nil {
return err
}
return s.repo.Save(ctx, order)
}
func (r *OrderRepository) Save(ctx context.Context, order Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
return err
}
ExecContextnhận context, nếu request HTTP phía trên bị hủy (client đóng kết nối), driver DB hủy luôn câu query đang chạy — tránh giữ connection vô ích.- Quy tắc: hàm nào gọi I/O (HTTP, DB, gRPC) nên nhận context và truyền tiếp xuống các API hỗ trợ context (
*Contextsuffix).
8. Kiểm tra hành vi với test
func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
ctx := context.Background()
result, err := fetchFromMultipleSources(ctx)
require.NoError(t, err)
assert.Contains(t, []string{"cache", "db", "api"}, result)
}
func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
slowServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
}))
defer slowServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
_, err := http.DefaultClient.Do(req)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: thêm thư viện assertion (không phải XML, chỉ ghi chú) -->
go get github.com/stretchr/testify
- Test đầu dùng
context.Background()vì không cần kiểm soát timeout trong unit test bình thường. - Test sau cố tình đặt timeout 500ms ngắn hơn thời gian server xử lý (3s) để xác nhận lỗi trả về đúng là
context.DeadlineExceeded. httptest.NewServerdựng server giả, không cần mock phức tạp.
9. Lỗi hay gặp
- Không gọi
cancel(): luôndefer cancel()ngay sau khi gọiWithCancel/WithTimeout/WithDeadline, kể cả khi context sẽ tự hết hạn — Go vet sẽ cảnh báo nếu bạn bỏ quên. - Lưu context vào struct field: context nên truyền qua tham số hàm, không lưu làm field của struct (trừ vài trường hợp đặc biệt như
http.Request). Lưu vào field dễ dẫn tới context cũ, stale. - Dùng context để truyền tham số bắt buộc: nếu hàm không chạy đúng khi thiếu giá trị đó, hãy truyền nó như tham số thường, đừng giấu trong context.
- Tạo context mới thay vì dùng context cha: nếu bạn gọi
context.Background()bên trong một hàm đang nhậnctxtừ caller, bạn làm mất khả năng hủy/timeout từ phía trên — luôn derive từ context nhận được:context.WithTimeout(ctx, ...), không phảicontext.WithTimeout(context.Background(), ...).
Khi nào không cần context: với các hàm thuần tính toán (pure function), không I/O, không goroutine, bạn không cần context làm tham số — thêm vào chỉ gây nhiễu.
10. Kết luận
Context giải quyết ba việc cốt lõi trong Go concurrency: báo hiệu hủy, áp giới hạn thời gian, và mang theo vài giá trị nhỏ xuyên qua các lớp gọi hàm. Quy tắc an toàn nhất là luôn nhận ctx làm tham số đầu, luôn defer cancel(), và chỉ dùng WithValue cho dữ liệu request-scoped thật sự phụ. Nắm vững những nguyên tắc này, bạn sẽ viết được service Go không rò rỉ goroutine và phản hồi đúng khi client timeout hoặc hủy request.