Ba bài trước — rate limiting, circuit breaker, retry — đều ngầm dựa vào một thứ nền tảng: timeout. Không có hạn thời gian, một thao tác chậm hay treo sẽ giữ luồng và kết nối mãi mãi, và tài nguyên cạn dần cho tới khi hệ sập. Tệ hơn: khi client đã bỏ đi, server vẫn cắm cúi làm việc vô ích. Trong Go, công cụ cho việc này là context — mang theo deadline và tín hiệu hủy xuyên suốt call stack. Nhưng có một cái bẫy: context chỉ báo hủy; hàm phải tôn trọng nó mới dừng được. Bài này (phần 4 loạt Hệ thống chịu tải) chạy thật để thấy cả tác dụng lẫn cái bẫy.

Cơ chế: context mang deadline và tín hiệu hủy

context.WithTimeout tạo một context tự hủy sau một khoảng thời gian; WithCancel cho phép hủy thủ công. Context này được truyền xuyên suốt call stack, và mỗi hàm con phải nghe nó:

ctx, cancel := context.WithTimeout(parent, 200*time.Millisecond)
defer cancel()  // LUÔN gọi cancel để giải phóng

// hàm phải TÔN TRỌNG ctx (select ctx.Done)
func slowOp(ctx context.Context) error {
    select {
    case <-time.After(2*time.Second): return nil       // việc mất 2s
    case <-ctx.Done():                return ctx.Err() // ctx hủy -> dừng NGAY
    }
}

Điểm mấu chốt — và là cái bẫy: nếu slowOp không có nhánh <-ctx.Done(), thì dù context đã hết hạn, hàm vẫn chạy tiếp đủ 2 giây. Timeout chỉ có tác dụng nếu hàm thực sự nghe context.

Ảnh chụp đoạn mã Go nền tối minh hoạ timeout và context, vấn đề không đặt hạn tài nguyên cạn dần thao tác chậm treo mà không có hạn giữ luồng kết nối mãi client bỏ đi rồi mà server vẫn làm việc vô ích goroutine kết nối rò rỉ dần dần cạn tài nguyên sập, cơ chế context mang deadline cộng tín hiệu hủy ctx cancel context WithTimeout parent 200 millisecond defer cancel luôn gọi cancel để giải phóng truyền ctx xuyên suốt call stack hàm con phải nghe ctx, hàm phải tôn trọng ctx select ctx Done func slowOp ctx context Context error select case time After 2 second return nil việc mất 2s case ctx Done return ctx Err ctx hủy dừng ngay hàm không nghe ctx Done timeout vô dụng việc vẫn chạy tiếp

Hình 1: context.WithTimeout tạo deadline, truyền xuyên call stack; hàm phải select trên ctx.Done() để dừng sớm — nếu không, timeout vô dụng và việc vẫn chạy tiếp.

Đo thật: timeout hoạt động, và bẫy rò rỉ goroutine

Mình đo ba thứ: timeout cắt thao tác chậm, goroutine không nghe ctx (rò rỉ), goroutine có nghe ctx (thoát sạch):

Ảnh chụp bảng kết quả chạy thật timeout context output thật, một thao tác 2s với context timeout 200ms trả về sau 209 ms lỗi context deadline exceeded không chờ hết 2s ctx Err bằng DeadlineExceeded fail nhanh giải phóng luồng, hai rò rỉ goroutine 100 goroutine không nghe ctx Done ban đầu 1 goroutine sau khi chạy 100 goroutine không nghe ctx 101 tăng 100 sau khi cancel ctx 101 vẫn cao goroutine không thoát bằng rò rỉ, ba goroutine có nghe ctx Done thoát sạch đang chạy 100 goroutine 201 tăng 100 sau khi cancel ctx 101 thoát sạch giải phóng 100 goroutine vừa tạo 101 vì còn 100 goroutine rò rỉ từ phần 2 cộng main, kết luận timeout fail nhanh sau deadline thay vì treo mãi 209ms vs 2s goroutine không nghe ctx Done rò rỉ dù đã cancel goroutine nghe ctx Done thoát sạch khi hủy đánh đổi timeout ngắn dễ cắt oan việc chậm hợp lệ phải truyền ctx xuống mọi tầng hàm không nghe ctx thì timeout vô dụng defer cancel

Hình 2: Chạy thật — timeout 200ms trên thao tác 2s trả về sau 209ms (DeadlineExceeded); 100 goroutine không nghe ctx vẫn 101 sau cancel (rò rỉ); 100 goroutine có nghe ctx về 101 sau cancel (thoát sạch).

Đọc kết quả đo được:

  • Timeout cắt thao tác chậm đúng lúc: thao tác 2 giây với context timeout 200ms trả về sau 209ms (không phải 2s), lỗi context deadline exceeded. Thay vì giữ luồng 2 giây chờ mòn mỏi, nó fail nhanh và giải phóng tài nguyên ngay khi hết hạn. Đây là nền cho mọi mẫu chịu tải: giới hạn thời gian mỗi thao tác.
  • Bẫy: goroutine không nghe ctx = rò rỉ: chạy 100 goroutine không có nhánh ctx.Done() — số goroutine nhảy từ 1 lên 101, và sau khi cancel() vẫn 101. Chúng không thoát: context bị hủy nhưng không ai nghe, nên 100 goroutine kẹt lại mãi mãi. Đây là rò rỉ goroutine kinh điển, âm thầm tích lũy tới khi hết bộ nhớ.
  • Làm đúng: goroutine nghe ctx = thoát sạch: 100 goroutine có nhánh <-ctx.Done() — chạy lên 201, và sau cancel() về 101 (giải phóng đúng 100 goroutine vừa tạo; 101 còn lại là 100 goroutine rò rỉ từ phần trước + main). Nghe context là điều kiện bắt buộc để hủy có tác dụng.

Đánh đổi cần cân nhắc

Timeout phải được truyền và tôn trọng ở MỌI tầng. Cái bẫy lớn nhất: đặt timeout ở tầng ngoài nhưng một hàm sâu bên trong (query database, gọi HTTP, đọc file) không nhận context, hoặc nhận nhưng bỏ qua. Khi đó timeout ngoài hết hạn, hàm ngoài trả lỗi, nhưng công việc bên trong vẫn chạy — vừa lãng phí vừa có thể gây rò rỉ. Quy tắc: mọi hàm làm I/O hay việc lâu phải nhận context.Context là tham số đầu tiên và truyền tiếp xuống; dùng các API nhận context (db.QueryContext, http.NewRequestWithContext).

Chọn giá trị timeout khó — quá ngắn cắt oan, quá dài vô dụng. Timeout quá ngắn sẽ cắt nhầm những request chậm nhưng hợp lệ (một query nặng, một lúc mạng chậm), gây lỗi giả và có thể kích retry storm. Quá dài thì không bảo vệ kịp — luồng vẫn bị giữ lâu khi có sự cố. Nên đặt timeout theo percentile cao của độ trễ thật (ví dụ p99 + biên), và timeout khác nhau cho các thao tác khác nhau (query nhanh vs báo cáo nặng), không một giá trị cho tất cả.

Luôn defer cancel(), kể cả khi đã có timeout. WithTimeout trả về một hàm cancel phải được gọi để giải phóng tài nguyên của context (một timer nội bộ). Quên gọi cancel gây rò rỉ nhỏ nhưng tích lũy. defer cancel() ngay sau khi tạo context là thói quen an toàn — nó vô hại kể cả khi timeout đã tự kích hoạt trước.

Ba ý mang về

  1. Context timeout cho fail nhanh thay vì treo mãi: đo thật thao tác 2s với timeout 200ms trả về sau 209ms (DeadlineExceeded) — giới hạn thời gian mỗi thao tác là nền cho mọi mẫu chịu tải, giải phóng tài nguyên ngay khi hết hạn.
  2. Timeout chỉ có tác dụng nếu hàm nghe context: đo thật 100 goroutine không nghe ctx.Done() rò rỉ dù đã cancel (vẫn 101), còn 100 goroutine có nghe thì thoát sạch (về 101) — nghe context là điều kiện bắt buộc để hủy hoạt động.
  3. Truyền ctx xuống mọi tầng, chọn timeout theo độ trễ thật, luôn defer cancel(): mọi hàm I/O nhận và truyền context; timeout theo p99 + biên, khác nhau cho từng loại thao tác; defer cancel() ngay sau khi tạo để tránh rò rỉ.

Nguồn

Phần sau ta gặp backpressure — khi bên sản xuất nhanh hơn bên tiêu thụ, hàng đợi có giới hạn chặn nguồn lại thay vì phình vô hạn tới hết bộ nhớ; đo thật bounded queue tạo áp lực ngược để hệ không tự làm mình quá tải.