Có lần mình ngồi debug một service Go gọi API đối tác. Log báo timeout sau 3 giây, nhưng CPU vẫn đều đều 40%, connection tới Postgres thì cứ tăng. Request bị hủy phía client từ lâu rồi, còn goroutine xử lý phía sau vẫn miệt mài chạy tới cùng. Thủ phạm không phải bug logic — mà là context bị bỏ quên.

Context sinh ra để làm gì?

Trong Go, mỗi request nên có một context.Context đi kèm. Nó mang theo ba thứ:

  • Deadline — hạn chót để công việc phải xong
  • Tín hiệu hủy — kênh Done() đóng khi công việc không cần làm nữa
  • Metadata request-scoped — request ID, trace ID, thông tin auth

Điểm dễ quên: context không phải "cái giỏ" để nhét dependency. Nó chỉ sống trong phạm vi một request.

Luôn defer cancel(), kể cả khi không timeout

func fetchUser(ctx context.Context, id string) (*User, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel() // thiếu dòng này là leak timer

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"/users/"+id, nil)
	if err != nil {
		return nil, err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err // trả về ctx.Err() khi bị hủy
	}
	defer resp.Body.Close()

	var u User
	if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
		return nil, err
	}
	return &u, nil
}

WithTimeout tạo ra một timer bên trong. Không gọi cancel() thì timer sống tới lúc hết hạn và giữ nguyên cả cây context cha trong bộ nhớ. Với service nhận vài trăm request mỗi giây, chuyện này tích tụ thành heap phình ra rất nhanh. go vet có analyzer lostcancel để bắt lỗi này — bật nó trong CI.

Truyền context xuyên suốt, đừng tạo mới giữa đường

Lỗi kinh điển mình thấy trong code review:

// ❌ handler đã có ctx, nhưng repository tự tạo context mới
func (r *Repo) GetOrder(id string) (*Order, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	return r.q.GetOrder(ctx, id)
}

Client đã bỏ đi rồi, nhưng query vẫn chạy đủ 5 giây. Toàn bộ ý nghĩa của deadline bốc hơi. Quy tắc: hàm I/O nhận ctx làm tham số đầu tiên và chuyển tiếp nó xuống dưới, không bao giờ tạo mới.

Ngoại lệ duy nhất là tầng ngoài cùng — main, worker khởi động nền — nơi dùng context.Background() là hợp lý.

Hủy mà không chặn cứng

Chỗ hay bị viết ẩu là chờ kết quả từ goroutine:

select {
case res := <-ch:
	return res, nil
case <-ctx.Done():
	return nil, ctx.Err()
}

select giúp thoát ngay khi context bị hủy thay vì ngồi đợi vô ích. Với vòng lặp dài — đọc file, xử lý batch — nên kiểm tra định kỳ:

for _, item := range items {
	if err := ctx.Err(); err != nil {
		return err
	}
	process(item)
}

Một điểm nữa: goroutine con phải có cách thoát. Nếu nó ghi vào channel không ai đọc, context hủy cũng không cứu được — nó kẹt luôn ở đó. Luôn cho goroutine một select với ctx.Done().

WithValue — chỉ dành cho dữ liệu của request

context.WithValue bị lạm dụng nhiều nhất: nhét config, nhét logger, nhét kết nối database. Vài tháng sau đọc code không biết giá trị đó từ đâu ra.

Cách dùng đúng là request ID, trace ID, user ID sau khi đã xác thực. Và key nên là kiểu riêng để tránh đụng nhau giữa các package:

type ctxKey struct{ name string }

var requestIDKey = ctxKey{"requestID"}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey, id)
}

func RequestID(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey).(string)
	return id
}

Dùng string thô làm key thì hai package cùng đặt "id" là ghi đè lẫn nhau, lỗi rất khó lần.

Vài chỗ mình từng vấp

  • errgroup hủy anh em: dùng errgroup.WithContext, chỉ cần một goroutine lỗi là ctx bị cancel và mọi goroutine khác nhận context.Canceled. Đó không phải bug, đó là thiết kế. Muốn gom nhiều lỗi thì xử lý trong từng nhánh rồi trả về.
  • Ghi log sau khi request hủy: handler bị hủy, nhưng vẫn cần ghi audit log. context.WithoutCancel (Go 1.21+) tạo context giữ nguyên giá trị nhưng không bị hủy theo — đúng cho công việc dọn dẹp.
  • Lưu context vào struct: context gắn với một request cụ thể, không phải thuộc tính của object sống lâu. Nhét vào struct là mời gọi bug use-after-cancel.

Checklist trước khi merge

  • Mọi hàm gọi I/O có ctx context.Context ở tham số đầu
  • Mọi WithCancel / WithTimeout đều có defer cancel()
  • Không có context.Background() rải rác trong handler hay repository
  • Mỗi goroutine đều có đường thoát qua ctx.Done()
  • Không truyền nil — cần chỗ giữ chỗ thì dùng context.TODO()

Context không phải tính năng hoa mỹ. Nó là hợp đồng giữa tầng gọi và tầng bị gọi: "không cần kết quả này nữa thì dừng lại giùm". Viết đúng từ đầu rẻ hơn nhiều so với ngồi soi heap profile lúc 2 giờ sáng.

Thử grep context.Background() trong repo của bạn một lần. Có bất ngờ nào không?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
context.Context Interface mang deadline, tín hiệu hủy và metadata của một request
Deadline Mốc thời gian mà sau đó công việc phải dừng
ctx.Done() Channel đóng lại khi context bị hủy hoặc hết hạn
ctx.Err() Trả về context.Canceled hoặc context.DeadlineExceeded
Goroutine leak Goroutine không bao giờ kết thúc, giữ tài nguyên tới lúc process chết
errgroup Package golang.org/x/sync/errgroup — chạy nhóm goroutine và gom lỗi đầu tiên
context.WithoutCancel Tạo context giữ nguyên giá trị nhưng không bị hủy theo cha (Go 1.21+)
lostcancel Analyzer của go vet phát hiện context tạo ra mà không gọi cancel()

Xem các bài viết khác