Menu

Context trong Golang: hủy, timeout và giá trị

Cách context.Context mang tín hiệu hủy, deadline và các giá trị gắn với request đi khắp một chương trình Go: Background, WithCancel, WithTimeout, WithValue, ctx.Done trong select, và context trong máy chủ và máy khách HTTP.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Một timeout trong mười dòng

Nhiệm vụ của context.Context là báo cho code biết khi nào cần dừng. Ở đây một thao tác chậm có 50 ms, và bỏ cuộc khi context bảo vậy:

Lần gọi đầu xong trong 10 ms và trả về rows <nil>. Lần thứ hai cần 200 ms, nhưng context hết hạn ở mốc 50 ms (tính từ lúc nó được tạo), nên nó trả về context deadline exceeded.

Không có gì bị dừng cưỡng bức. Go không có cách nào giết một goroutine từ bên ngoài. Context là một tín hiệu, và code phải tự kiểm tra nó: bằng cách select trên ctx.Done(), kiểm tra ctx.Err() giữa các bước, hoặc truyền ctx vào các lời gọi thư viện (http.NewRequestWithContext, db.QueryContext, exec.CommandContext) để chúng kiểm tra giúp bạn.

Interface Context

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
Phương thứcTrả về
Done()một channel được đóng khi context bị hủy hoặc hết giờ (nil với context không bao giờ bị hủy)
Err()nil khi còn hoạt động, sau đó là context.Canceled hoặc context.DeadlineExceeded
Deadline()deadline và true, hoặc ok == false nếu không có
Value(key)giá trị lưu dưới key trong context này hoặc một context tổ tiên, hoặc nil

Context là bất biến. Bạn không bao giờ thay đổi một context; bạn tạo ra một context con từ nó bằng một trong các hàm With, và context con thêm vào một tín hiệu hủy, một deadline hoặc một giá trị.

Context đến từ đâu

Mọi cây context đều bắt đầu từ một gốc:

  • context.Background() cho main, init, các bài test và phần thiết lập cấp cao nhất của máy chủ.
  • context.TODO() khi một hàm cần nhận context nhưng bên gọi chưa có. Nó hoạt động giống hệt Background; cái tên chỉ là dấu hiệu để refactor sau này.

Bên trong một HTTP handler, bạn không tạo gốc. Bạn dùng r.Context(), context mà máy chủ hủy khi máy khách ngắt kết nối hoặc khi handler trả về.

WithCancel: dừng khi cần

context.WithCancel trả về một context con và một hàm cancel. Gọi cancel sẽ đóng channel Done của context con và các channel Done của mọi context sinh ra từ nó.

Lần gửi của producer nằm trong một select cạnh ctx.Done(). Chính điều đó cho phép nó dừng: một lệnh out <- i trơn sẽ chặn mãi mãi khi consumer ngừng đọc, và goroutine sẽ bị rò rỉ. Vòng for range nums cuối cùng chờ cho đến khi producer đóng channel. Trong lúc nó rút hết dữ liệu, producer vẫn có thể kịp gửi thêm một hai giá trị, vì khi cả hai nhánh của select đều sẵn sàng thì Go chọn ngẫu nhiên một nhánh; việc hủy diễn ra nhanh chóng chứ không tức thì.

cancel gọi nhiều lần và từ bất kỳ goroutine nào cũng an toàn. Chỉ lần gọi đầu tiên có tác dụng.

WithTimeout và WithDeadline

WithTimeout(parent, d) chính là WithDeadline(parent, time.Now().Add(d)). Dùng timeout cho "tối đa chừng này thời gian" và deadline khi bạn có một thời điểm tuyệt đối.

Khi thời gian trôi qua, Done đóng lại và Err trả về context.DeadlineExceeded. Nếu cancel được gọi trước, Err trả về context.Canceled. Kiểm tra là trường hợp nào bằng errors.Is, vì các thư viện thường bọc lỗi lại:

Luôn gọi cancel, kể cả với một timeout sẽ tự kích hoạt. Context giữ một bộ hẹn giờ và một chỗ trong context cha cho đến khi một trong hai điều xảy ra, và defer cancel() giải phóng cả hai ngay khi hàm trả về. go vet báo lỗi khi một hàm cancel bị bỏ đi: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.

Context con không sống lâu hơn context cha

Các context tạo thành một cây. Hủy context cha sẽ hủy mọi context con cháu. Một context con có thể có deadline ngắn hơn context cha, không bao giờ dài hơn: deadline sớm hơn luôn thắng.

Đây là điều khiến context hữu ích qua nhiều tầng. Một HTTP handler nhận một context chết cùng request; một lời gọi cơ sở dữ liệu ở ba tầng bên dưới tạo một timeout 2 giây từ đó. Nếu máy khách ngắt sau 100 ms, truy vấn bị hủy ngay lúc đó, không phải hai giây sau.

Luôn select trên ctx.Done() khi bị chặn

Mọi goroutine phải chờ (một lần gửi channel, một lần nhận, một bộ hẹn giờ) đều nên chờ ctx.Done() cùng lúc. Với các vòng lặp nặng CPU không bao giờ bị chặn, thỉnh thoảng hãy kiểm tra ctx.Err():

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

Dùng time.After trong một select cho một lần chờ đơn giản, nhưng nên dùng một bộ hẹn giờ có thể dừng (hoặc một timeout của context) khi lần chờ có thể thường xuyên bị hủy.

Nguyên nhân hủy (Go 1.20 và 1.21)

ctx.Err() chỉ cho biết canceled hoặc deadline exceeded. Để ghi lại lý do, hãy dùng các biến thể Cause:

WithCancelCause có từ Go 1.20, WithTimeoutCause và WithDeadlineCause có từ Go 1.21. Err vẫn trả về các giá trị chuẩn để những đoạn kiểm tra hiện có tiếp tục hoạt động; context.Cause cho biết chi tiết.

WithValue, dùng tiết chế

context.WithValue(parent, key, value) gắn thêm một giá trị. ctx.Value(key) tìm nó qua chuỗi các context cha.

Quy tắc cho giá trị:

  • Dùng một kiểu không export cho khóa, đừng bao giờ dùng string trơn. Hai package cùng dùng "user" sẽ ghi đè lên nhau. (go vet không bắt được lỗi này; staticcheck thì có.)
  • Bọc việc truy cập trong các hàm hỗ trợ có kiểu như WithRequestID và RequestID, để bên gọi không bao giờ thấy any hay khóa.
  • Chỉ lưu dữ liệu gắn với request đi xuyên qua các API: trace ID và request ID, người dùng đã xác thực, một logger. Không bao giờ lưu tham số tùy chọn, handle cơ sở dữ liệu hay cấu hình. Những thứ đó thuộc về đối số hàm hoặc trường của struct, nơi trình biên dịch kiểm tra được và người đọc nhìn thấy được.
  • Việc tra cứu đi qua chuỗi từng context cha một, nên mỗi giá trị bạn thêm vào khiến việc tra cứu các giá trị khác dài thêm một bước.

Quy ước

  • ctx context.Context là tham số đầu tiên của mọi hàm thực hiện I/O, bị chặn, hoặc gọi thứ gì đó làm vậy: func Fetch(ctx context.Context, url string) error.
  • Đừng lưu context trong struct. Hãy truyền nó vào từng lời gọi phương thức. Một context thuộc về một thao tác, và struct thường sống lâu hơn thao tác đó. (Ngoại lệ là một kiểu đại diện cho đúng một thao tác, như http.Request.)
  • Không bao giờ truyền nil làm context. Hãy dùng context.TODO() nếu không có gì tốt hơn.
  • Trả về ctx.Err(), hoặc bọc nó bằng %w, khi bạn dừng vì context, để bên gọi phân biệt được timeout với một lỗi thật sự.

Context trong máy chủ và máy khách HTTP

Phía máy chủ: r.Context() bị hủy khi máy khách ngắt kết nối, khi handler trả về, hoặc khi một stream HTTP/2 bị reset. Phía máy khách: http.NewRequestWithContext khiến request tôn trọng timeout hoặc tín hiệu hủy. Chương trình này chạy cả hai đầu qua httptest:

Máy khách bỏ cuộc ở mốc 50 ms và đóng kết nối. Máy chủ nhận ra, context của request bị hủy, và handler dừng lại thay vì tốn thêm 450 mili giây cho một báo cáo không ai đọc. Trong một handler thực tế, bạn truyền r.Context() xuống mọi lời gọi cơ sở dữ liệu và HTTP, và tất cả cùng dừng.

Các hàm hỗ trợ khác (Go 1.21)

  • context.WithoutCancel(ctx) trả về một context có cùng các giá trị nhưng không bị hủy khi ctx bị hủy. Dùng nó cho công việc phải hoàn thành sau khi request kết thúc, như ghi audit log.
  • context.AfterFunc(ctx, f) chạy f trong goroutine riêng khi ctx kết thúc, và trả về một hàm stop để hủy đăng ký.

Lỗi thường gặp

  • Không gọi cancel. Luôn defer cancel() ngay sau WithCancel, WithTimeout hoặc WithDeadline.
  • Khởi chạy một goroutine bỏ qua ctx. Nếu nó bị chặn mà không select trên ctx.Done(), việc hủy không có tác dụng gì và goroutine bị rò rỉ.
  • Tạo một context.Background() mới ở sâu bên trong chuỗi lời gọi. Việc này cắt đứt liên kết với deadline và tín hiệu hủy của bên gọi. Hãy truyền ctx bạn đã nhận được.
  • So sánh lỗi bằng ==. Hãy dùng errors.Is(err, context.DeadlineExceeded); phần lớn thư viện đều bọc lỗi này.
  • Dùng WithValue cho các phụ thuộc. Một handle cơ sở dữ liệu giấu trong context là một tham số mà trình biên dịch không còn kiểm tra được.
  • Mong việc hủy diễn ra tức thì. Code chỉ nhận ra ở lần kiểm tra tiếp theo. Một vòng lặp dài không có lần kiểm tra nào sẽ tiếp tục chạy.

Câu hỏi thường gặp

Context trong Go dùng để làm gì?

Một context.Context cho một hàm và mọi thứ hàm đó gọi biết khi nào nên dừng: vì bên gọi đã hủy, vì đã quá deadline, hoặc vì máy khách đã ngắt kết nối. Nó cũng có thể mang các giá trị gắn với request như request ID. Theo quy ước, nó là tham số đầu tiên, đặt tên là ctx.

context.Background và context.TODO khác nhau thế nào?

Cả hai đều trả về một context rỗng, không bao giờ bị hủy, không có deadline hay giá trị nào. Chúng hoạt động y hệt nhau. Background() là gốc cho main, các bài test và phần thiết lập cấp cao nhất. TODO() đánh dấu một chỗ lẽ ra phải truyền một context thật nhưng code xung quanh chưa có, nhờ vậy sau này dễ tìm lại.

Vì sao phải gọi cancel sau context.WithTimeout?

WithTimeout, WithDeadline và WithCancel đăng ký context mới với context cha và có thể khởi chạy một bộ hẹn giờ. Gọi cancel giải phóng các tài nguyên đó ngay khi bạn xong việc, thay vì đợi đến lúc hết giờ hoặc context cha bị hủy. Hãy viết defer cancel() ngay sau khi tạo; go vet cảnh báo khi một hàm cancel bị bỏ đi.

"context deadline exceeded" trong Go nghĩa là gì?

Đó là nội dung của context.DeadlineExceeded, lỗi mà ctx.Err() trả về khi deadline của context đã qua. Các hàm tôn trọng context, như máy khách HTTP và driver cơ sở dữ liệu, trả về lỗi này (thường đã được bọc) khi hết thời gian. Kiểm tra nó bằng errors.Is(err, context.DeadlineExceeded).

Có nên dùng context.WithValue để truyền tham số không?

Không. Chỉ dùng nó cho dữ liệu gắn với request đi xuyên qua ranh giới API mà các hàm ở giữa không cần biết tới, như trace ID hay người dùng đã xác thực. Mọi thứ một hàm cần để làm việc đều thuộc về tham số của nó, nơi trình biên dịch kiểm tra được.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu