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.Canceled hay context.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àm cancel để 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 đang select trên ctx.Done().
  • Goroutine nào xong trước gọi cancel(), các goroutine còn lại nhận tín hiệu qua ctx.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.NewRequestWithContext gắn context vào request; khi context hết hạn, http.Client tự hủy request đang chạy.
  • Nếu quá 2 giây, resp, err := http.DefaultClient.Do(req) trả err bọc context.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()
	}
}
  • WithDeadline dùng khi bạn có một mốc thời gian cụ thể (ví dụ cuối ngày), khác với WithTimeout là 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ọi WithDeadline(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ùng string trực tiếp vì dễ đụng key của package khác.
  • WithRequestID và 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ống Background() 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
}
  • ExecContext nhậ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 (*Context suffix).

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.NewServer dựng server giả, không cần mock phức tạp.

9. Lỗi hay gặp

  • Không gọi cancel(): luôn defer cancel() ngay sau khi gọi WithCancel/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ận ctx từ 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ải context.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.

golangcontextconcurrencygoroutinetimeout