Hình dung context như sợi dây chỉ huy của một đoàn tìm kiếm có tổ chức. Chỉ huy hô "thu quân", và lệnh đó truyền xuống mọi đội, mọi đội con, tới từng người — ai cũng dừng và quay về. Mỗi đội đặt được giờ "phải có mặt" riêng cho mình, nhưng không bao giờ trễ hơn giờ của cả đoàn. Và một cuộc tìm kiếm kết thúc vì hai lý do rất khác nhau: hoặc chỉ huy gọi về (bình thường), hoặc khu vực đó hết giờ quy định (có vấn đề). Giữ hình ảnh đó, toàn bộ context trở nên rõ ràng. 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, và 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 — một tiếng "thu quân", cả ba đội về. Chúng không biết nhau, không có kênh riêng nào, chỉ cùng nắm một đầu dây 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 — chỉ huy gọi về thì cả đội con về, còn một đội con hết giờ thì cả đoàn vẫn tìm tiếp.

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 — giờ "phải về" của đội không thể muộn hơn giờ của cả đoàn.

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, khu vực hết giờ. Trả 504, ghi log, có thể cần cảnh báo.

Canceled → người dùng bỏ đi, chỉ huy gọi về. Đâ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ó — như một cái bộ đàm phát cho đội con rồi không ai thu lại. 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ố.

Muốn soi nhanh dự án mình có chỗ nào tuột bộ đàm không, thử đúng ba mươi giây:

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ả.

Mẫu số chung

"Làm sao bảo một tác vụ đang chạy dừng lại" là bài toán mọi runtime đồng thời hiện đại đều phải giải, và điều thú vị là tất cả đều hội tụ về cùng một câu trả lời: huỷ phải hợp tác và phải lan truyền theo cây lời gọi qua một tín hiệu bạn chuyền như tham số.

  • .NET là bản song sinh gần nhất: CancellationToken chuyền xuống khắp nơi, CancellationTokenSource để phát lệnh, và linked source chính là cây context của Go; hết hạn thì ném OperationCanceledException.
  • JavaScript có AbortController/AbortSignal: truyền signal vào fetch, gọi .abort() là huỷ, tín hiệu lan xuống — đúng context của nền web.
  • Rust mô hình hoá huỷ thành buông future (drop = huỷ), cộng CancellationToken của tokio. Java mới đây đi tới StructuredTaskScope sau nhiều năm vật lộn với Thread.interrupt vụng về.

Và đây là lý do sâu xa giải thích vì sao ở đâu cũng phải hợp tác: bạn không thể giết an toàn một luồng từ bên ngoài — Java từng có Thread.stop() và đã khai tử nó đúng vì điều đó, vì giết giữa chừng để lại khoá nửa mở và dữ liệu dở dang. Thế nên tác vụ phải tự hỏi "mình có nên dừng không?" tại các điểm chặn, mà muốn nó hỏi được thì tín hiệu phải chạm tới mọi tầng — đúng lý do ctx là tham số đầu tiên của mọi hàm. Sợi chỉ chung đáng mang theo: một lời gọi chặn mà không huỷ được là một cú treo đang chờ xảy ra; hãy luồn tín hiệu huỷ xuống tận đáy, và phân biệt "bị gọi về" (bình thường) với "hết giờ" (có vấn đề) — còn hạn chờ, nói cho cùng, chỉ là một lệnh huỷ tự hẹn giờ cho chính nó.

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.

Bài tập làm thử

Bài 1 (đọc hiểu). Một context cha có hạn chờ 2 giây (context.WithTimeout(ctx, 2*time.Second)), rồi một hàm con đặt tiếp context.WithTimeout(chaCtx, 30*time.Second). Context con thực tế sẽ hết hạn sau bao lâu, và vì sao?

Đáp án

Sau 2 giây, không phải 30 giây. Hạn chờ của con không bao giờ vượt được hạn chờ của cha — con thừa hưởng giới hạn của cha và chỉ có thể rút ngắn thêm, không thể kéo dài ra. Bài viết ví đây như "giờ phải về của một đội con không thể muộn hơn giờ của cả đoàn."

Bài 2 (sửa lỗi). Đoạn mã sau có một rò rỉ tài nguyên tinh vi. Tìm và sửa.

func XuLy(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	if xong := lamViec(ctx); !xong {
		return errors.New("chưa xong")
	}
	cancel()
	return nil
}
Đáp án

Lỗi: cancel() chỉ được gọi ở nhánh thành công. Nếu lamViec trả về false, hàm return sớm mà không gọi cancel() — context con và timer của nó bị rò rỉ (context cha giữ tham chiếu tới con mãi mãi). Sửa bằng defer cancel() ngay sau khi tạo, để nó chạy ở mọi đường thoát:

func XuLy(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if xong := lamViec(ctx); !xong {
		return errors.New("chưa xong")
	}
	return nil
}

Gọi cancel() nhiều lần là an toàn (idempotent), nên defer cancel() không gây lỗi kể cả khi bạn cũng gọi nó ở nhánh khác.

Bài 3 (đọc hiểu). Bài viết phân biệt hai loại lỗi context: DeadlineExceeded và Canceled. Trong một dịch vụ web, khi nào mỗi loại nên được xử lý khác nhau, và tại sao gộp chung chúng vào biểu đồ tỷ lệ lỗi là sai?

Đáp án

DeadlineExceeded nghĩa là hệ thống chậm hoặc quá tải — nên trả về HTTP 504, ghi log, và có thể cần cảnh báo vì đây là dấu hiệu sự cố thật. Canceled nghĩa là người dùng chủ động bỏ đi (ví dụ đóng tab trình duyệt) — đây là chuyện bình thường, không phải sự cố, nên chỉ log ở mức debug. Nếu gộp cả hai vào biểu đồ tỷ lệ lỗi, mỗi lần có người dùng bấm nút dừng hoặc đóng trang giữa chừng sẽ làm tăng "tỷ lệ lỗi" một cách giả tạo, khiến bảng cảnh báo kêu sai và làm nhiễu tín hiệu thật.

Bài 4 (vận dụng thực tế). Viết một hàm xử lý HTTP dùng context của request để đảm bảo toàn bộ chuỗi xử lý (bao gồm một lời gọi tới service tầng dưới) dừng ngay khi client ngắt kết nối.

Đáp án
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)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			return // client đã bỏ đi, không cần ghi lỗi ồn ào
		}
		http.Error(w, "lỗi máy chủ", http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(don)
}

net/http cho sẵn context của request qua r.Context(); chỉ cần truyền ctx này xuống mọi tầng xử lý (đây gần như miễn phí, chỉ cần chuyền tham số), toàn bộ chuỗi sẽ dừng khi client bỏ đi.

Bài 5 (bẫy/đánh đổi). Bài viết khuyên "hạn chế dùng WithValue" dù nó là một tính năng có sẵn. Nêu lý do chính, và cho một ví dụ hợp lệ về dữ liệu nên đặt trong WithValue theo đúng tiêu chí bài viết đưa ra.

Đáp án

Lý do chính: WithValue phá vỡ tính rõ ràng của chữ ký hàm — một hàm nhận ctx không cho biết nó cần giá trị gì bên trong context đó; nếu thiếu giá trị, lỗi chỉ phát hiện được lúc chạy (không có kiểm tra kiểu tĩnh, không có gợi ý từ IDE), khác hẳn việc thiếu một tham số tường minh sẽ bị bắt ngay lúc biên dịch.

Ví dụ hợp lệ: mã tương quan (correlation ID) hoặc trace ID dùng để theo dõi một request xuyên suốt nhiều tầng dịch vụ, hoặc thông tin xác thực đã giải mã — đây là dữ liệu "xuyên suốt request và không thuộc nghiệp vụ", đúng tiêu chí bài viết đặt ra. Mọi dữ liệu khác nên là tham số tường minh của hàm.