context là tham số đầu tiên của gần như mọi hàm trong thư viện chuẩn có thể chặn. Bài này giải thích vì sao, bằng số đo.
Huỷ lan xuống mọi tầng con
ctx, cancel := context.WithCancel(context.Background())
for i := 1; i <= 3; i++ {
go conCham(ctx, ...) // mỗi goroutine chờ 5 giây
}
time.Sleep(30 * time.Millisecond)
cancel()
con-2: dừng vì context canceled
con-3: dừng vì context canceled
con-1: dừng vì context canceled
Một lời gọi cancel(), ba goroutine dừng. Chúng không biết nhau, không có kênh riêng nào — chỉ cùng nhận một ctx.
Đây là bài toán mà context sinh ra để giải: người dùng đóng trình duyệt, và mọi thứ đang chạy cho request đó phải dừng — truy vấn CSDL, lời gọi API, goroutine xử lý nền.
Mẫu bên trong goroutine luôn là select:
select {
case <-time.After(d):
// làm xong
case <-ctx.Done():
return ctx.Err()
}
ctx.Done() là một channel đóng lại khi context bị huỷ. Chi tiết về select ở bài 33.
Cây context
Context tạo thành cây, và huỷ chỉ đi xuống:
cha 200ms, con 50ms:
con Done sau 50ms: context deadline exceeded
cha vẫn sống: Err()=<nil>
huỷ cha -> con Done, Err()=context canceled
Con hết hạn không ảnh hưởng cha. Cha bị huỷ thì mọi con chết theo.
Hệ quả thực dụng: hạn chờ của con không bao giờ vượt được cha. Đặt WithTimeout(ctx, 30*time.Second) trên một context còn 2 giây thì bạn vẫn chỉ có 2 giây.
Hai loại lỗi, phân biệt được
hết hạn : Is(DeadlineExceeded)=true Is(Canceled)=false
bị huỷ : Is(DeadlineExceeded)=false Is(Canceled)=true
Phân biệt này quan trọng trong dịch vụ thật:
DeadlineExceeded → hệ thống chậm. Trả 504, ghi log, có thể cần cảnh báo.
Canceled → người dùng bỏ đi. Đây là chuyện bình thường, không phải sự cố. Log ở mức debug và đừng đưa vào biểu đồ tỷ lệ lỗi — nếu không, mỗi lần có người bấm nút dừng là bảng cảnh báo của bạn kêu.
Kiểm bằng errors.Is như bài 24, đừng so == vì lỗi thường đã bị bọc.
Luôn defer cancel()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
Quên cancel() là rò rỉ: context cha giữ tham chiếu tới con mãi mãi, cùng với timer của nó. Trong một dịch vụ xử lý hàng nghìn request, đó là rò rỉ bộ nhớ thật.
Gọi cancel() nhiều lần là an toàn — nó idempotent. Nên cứ defer kể cả khi bạn đã gọi ở nhánh khác.
go vet bắt được trường hợp rõ ràng nhất: lostcancel.
WithValue: dùng rất tiết chế
type khoa string
ctx = context.WithValue(ctx, khoa("maRequest"), "req-1")
đọc lại: req-1 | khoá sai kiểu: <nil>
Chú ý khoá phải là kiểu riêng, không phải string trần. Dùng string thì hai package có thể va nhau, và như kết quả đo cho thấy, khoá "maRequest" kiểu string không đọc được giá trị lưu bằng khoá kiểu khoa.
Nhưng lời khuyên chính là: hạn chế dùng WithValue.
Nó phá vỡ tính rõ ràng — hàm nhận ctx không cho biết nó cần giá trị gì, và bạn chỉ phát hiện thiếu lúc chạy. Không có kiểm tra kiểu, không có gợi ý từ IDE.
Chỉ dùng cho dữ liệu xuyên suốt request và không thuộc nghiệp vụ: mã tương quan, trace ID, thông tin xác thực đã giải mã. Mọi thứ khác nên là tham số tường minh.
Quy ước
ctx là tham số đầu tiên, luôn luôn:
func Lay(ctx context.Context, ma string) (*Don, error)
Đừng cất context vào struct. Nó gắn với một lời gọi, không gắn với một đối tượng. Ngoại lệ hiếm là struct đại diện cho chính một tác vụ đang chạy.
Đừng truyền nil. Dùng context.TODO() khi chưa biết lấy đâu ra, context.Background() ở gốc (main, khởi tạo, test).
context.TODO() là dấu hiệu cho người đọc: chỗ này cần sửa. Nó khác Background() về ý nghĩa chứ không khác về hành vi.
Trong HTTP
net/http cho sẵn context của request:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // huỷ khi client ngắt kết nối
don, err := svc.Lay(ctx, ma)
...
}
Truyền ctx này xuống mọi tầng, và toàn bộ chuỗi xử lý sẽ dừng khi client bỏ đi. Đây là lợi ích lớn nhất của context trong dịch vụ web, và nó gần như miễn phí — bạn chỉ cần chuyền tham số.
Thử ba mươi giây
Tìm trong dự án của bạn:
grep -rn 'context.WithTimeout\|context.WithCancel' --include='*.go' -A1 | grep -v 'defer cancel'
Mỗi chỗ tạo context mà dòng ngay sau không phải defer cancel() là một rò rỉ tiềm tàng. go vet bắt được một phần, nhưng không phải tất cả.
Ngày mai: đóng tài nguyên đúng cách — và vì sao defer f.Close() đôi khi nuốt mất lỗi quan trọng.