Hình dung một người giao hàng phải trao gói tận tay bạn. Bạn đặt hẹn mười phút; hết mười phút không thấy họ, bạn bỏ đi. Nhưng họ vẫn đang làm — gói hàng xong, họ tới cửa, gõ, và... không còn ai nhận. Nếu luật bắt phải trao tận tay, họ sẽ đứng đó mãi mãi ôm cái gói. Đó đúng là cái bẫy rò rỉ goroutine tệ nhất khi dùng context: hàm của bạn hết giờ và trả về, nhưng goroutine bên trong làm xong việc, cố trao kết quả cho một channel không ai đọc, rồi kẹt vĩnh viễn. Và cách chữa, như bạn sắp thấy, chỉ là đặt một cái hộp thư trước cửa — đúng một ký tự. Bài 29 giới thiệu context; bài này về cách dùng nó cùng goroutine, nơi nó thật sự cần thiết.

Ba chỗ context phải xuất hiện

Một: mọi lệnh gửi vào channel trong goroutine sống lâu.

select {
case out <- v:
case <-ctx.Done():
	return
}

Không có nhánh thứ hai, goroutine kẹt vĩnh viễn khi hạ nguồn ngừng đọc — bài 38 đã đo.

Hai: mọi vòng lặp chờ việc.

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case v, ok := <-viec:
		if !ok { return nil }
		xuLy(v)
	}
}

Ba: truyền xuống mọi lời gọi có thể chặn — truy vấn CSDL, HTTP, đọc tệp lớn. Thư viện chuẩn đều nhận ctx ở tham số đầu.

Mẫu kết quả có đệm

Đây là mẹo tôi thấy đáng nhớ nhất trong bài này — và là cái hộp thư trước cửa:

func Lay(ctx context.Context) (int, error) {
	ch := make(chan int)              // KHÔNG đệm -> rò rỉ
	go func() { ch <- tinhLau() }()
	select {
	case v := <-ch:      return v, nil
	case <-ctx.Done():   return 0, ctx.Err()
	}
}

Khi hết giờ, hàm trả về ngay. Goroutine bên trong xong việc, cố gửi vào ch, và không ai nhận nữa — nó kẹt vĩnh viễn, đúng người giao hàng đứng trước cánh cửa đã khoá.

Sửa bằng đúng một ký tự:

ch := make(chan int, 1)

Giờ goroutine gửi được rồi thoát, kể cả khi không ai lấy — bỏ gói vào hộp thư rồi đi. Giá trị nằm trong đệm và được thu hồi cùng channel.

Quy tắc tổng quát: channel trả kết quả cho một lời gọi có thể bị bỏ dở luôn nên có đệm bằng số lượng người gửi.

Huỷ không dừng công việc đang chạy

Điểm này hay bị hiểu nhầm:

select {
case <-ctx.Done():
	return ctx.Err()
}

ctx.Done() chỉ báo rằng nên dừng — như một mẩu giấy nhét qua khe cửa "anh nghỉ được rồi". Nó không giết goroutine, không ngắt vòng lặp tính toán, không huỷ lời gọi hệ thống đang chạy. Người nhận vẫn phải ngẩng lên đọc mẩu giấy đó.

Nghĩa là một vòng lặp tính toán thuần không dừng được nếu bạn không tự kiểm:

for i := 0; i < n; i++ {
	if i%1000 == 0 {
		select {
		case <-ctx.Done(): return ctx.Err()
		default:
		}
	}
	tinh(i)
}

Kiểm mỗi vòng thì tốn; kiểm mỗi nghìn vòng là cân bằng hợp lý.

Với I/O thì thư viện chuẩn lo hộ: http.Client, database/sql, net đều huỷ thao tác khi context hết hạn.

Đừng cất context vào struct

type Dich struct {
	ctx context.Context     // ĐỪNG
}

context gắn với một lời gọi, không gắn với một đối tượng sống lâu. Cất vào struct nghĩa là mọi lời gọi dùng chung một hạn chờ, và bạn mất khả năng huỷ từng request.

Ngoại lệ hiếm: struct đại diện cho chính một tác vụ đang chạy — một worker, một phiên. Ngay cả khi đó, đặt tên trường là ctx và ghi rõ trong comment.

context và WaitGroup

Hai thứ giải hai bài toán khác nhau và thường dùng cùng nhau:

ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup

for i := 0; i < n; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		lam(ctx)
	}()
}

cancel()      // bảo mọi người dừng
wg.Wait()     // chờ họ dừng xong

Thứ tự quan trọng: cancel() trước wg.Wait(). Ngược lại là chờ những goroutine chưa được bảo dừng.

Bài mai sẽ cho thấy errgroup gói cả hai vào một.

Nếu muốn tận mắt thấy cái gói hàng bị bỏ quên, thử đúng ba mươi giây:

func f(ctx context.Context) int {
	ch := make(chan int)
	go func() { time.Sleep(time.Second); ch <- 1 }()
	select {
	case v := <-ch: return v
	case <-ctx.Done(): return -1
	}
}

ctx, c := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer c()
f(ctx)
time.Sleep(2 * time.Second)
fmt.Println(runtime.NumGoroutine())

In ra 2 — cái goroutine vẫn đứng ôm gói. Thêm đệm make(chan int, 1) rồi chạy lại: in ra 1. Ba mươi giây, một ký tự, và một lớp rò rỉ biến mất.

Mẫu số chung

Cái bẫy lớn nhất của bài này không phải cú pháp Go, mà là một nhầm lẫn phổ quát: "tôi ngừng chờ" không giống "việc đã ngừng chạy". Hết giờ một lời chờ không dừng công việc bên dưới — nó vẫn chạy tiếp, và nếu cố trả kết quả cho người không còn đợi, nó rò rỉ. Mọi runtime bất đồng bộ đều vấp đúng chỗ này.

  • JavaScript: Promise.race([việc, hẹngiờ]) trả về khi cái nào xong trước, nhưng cái thua không bị huỷ — lời hứa "việc" vẫn chạy tới cùng, fetch vẫn bay, tài nguyên vẫn mở. Đây là footgun kinh điển, và cách chữa đúng là AbortController, không phải race.
  • Java: future.get(timeout) ném TimeoutException, nhưng tác vụ bên dưới vẫn chạy trong pool trừ khi bạn future.cancel(true) và tác vụ chịu kiểm interrupted.
  • .NET: Task.WhenAny với một task hẹn giờ để lại task chậm chạy tiếp y hệt.

Go viết cái hộp-thư-trước-cửa bằng make(chan int, 1): làm cho việc trao kết quả không chặn, để người sản xuất luôn xong và thoát được dù người tiêu thụ còn đợi hay không. Nhưng đó mới là nửa sau; nửa đầu là cùng một bài học ở mọi ngôn ngữ. Sợi chỉ chung đáng mang theo: hết giờ cái chờ không bằng huỷ cái việc — muốn việc thật sự dừng thì phải gửi tín hiệu huỷ xuống tận nơi (context, AbortController, cancel), và ngay cả khi đã gửi, một vòng tính toán chặt vẫn phớt lờ nó cho tới khi tự ngẩng lên đọc. Quên điều đó thì bạn không tiết kiệm được gì cả — bạn chỉ ngừng nhìn một goroutine, một lời hứa, một luồng pool vẫn đang âm thầm chạy và rò rỉ sau lưng.

Ngày mai: sync/atomic và mô hình bộ nhớ Go.

Bài tập làm thử

Bài 1 (đọc hiểu). Sau khi chạy đoạn mã sau và chờ 2 giây, runtime.NumGoroutine() sẽ tăng hay giữ nguyên so với trước khi gọi f? Giải thích.

func f(ctx context.Context) int {
	ch := make(chan int)
	go func() { time.Sleep(time.Second); ch <- 1 }()
	select {
	case v := <-ch:
		return v
	case <-ctx.Done():
		return -1
	}
}

ctx, c := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer c()
f(ctx)
Đáp án

Tăng thêm 1 và không bao giờ giảm lại — đây là một rò rỉ goroutine vĩnh viễn. Context hết hạn sau 10ms, nhánh <-ctx.Done() thắng và f trả về -1 ngay. Nhưng goroutine bên trong (được tạo ra để gửi vào ch) vẫn tiếp tục chạy — sau 1 giây nó cố gửi ch <- 1, nhưng ch là channel không đệm và không còn ai đọc nó (hàm f đã trả về từ lâu). Goroutine đó kẹt tại lệnh gửi mãi mãi. Bài viết ví đây như "người giao hàng làm xong việc, tới cửa gõ, nhưng không còn ai nhận — đứng đó ôm gói mãi mãi."

Bài 2 (sửa lỗi). Sửa đoạn mã ở Bài 1 để không còn rò rỉ goroutine, chỉ bằng cách đổi đúng một ký tự/con số trong khai báo channel.

Đáp án
ch := make(chan int, 1) // thêm đệm 1

Với đệm 1, goroutine gửi có thể gửi xong rồi thoát ngay kể cả khi không còn ai nhận — giá trị nằm trong đệm và được thu hồi cùng channel khi không còn tham chiếu nào. Đây là quy tắc tổng quát bài viết đưa ra: "channel trả kết quả cho một lời gọi có thể bị bỏ dở luôn nên có đệm bằng số lượng người gửi."

Bài 3 (đọc hiểu). Đoạn mã sau có dừng được không khi ctx bị huỷ giữa chừng? Giải thích theo đúng khái niệm "huỷ không dừng công việc đang chạy" trong bài.

func TinhToan(ctx context.Context, n int) int {
	tong := 0
	for i := 0; i < n; i++ {
		tong += i * i
	}
	return tong
}
Đáp án

Không dừng được, dù ctx có bị huỷ. Hàm này không hề kiểm tra ctx.Done() ở đâu cả — ctx.Done() chỉ báo hiệu rằng nên dừng (như "một mẩu giấy nhét qua khe cửa"), nó không tự động ngắt vòng lặp tính toán đang chạy. Một vòng lặp tính toán thuần (không có I/O) sẽ chạy tới hết bất kể context có bị huỷ hay không, trừ khi tự kiểm tra ctx.Done() định kỳ trong thân vòng lặp.

Bài 4 (vận dụng thực tế). Viết lại hàm TinhToan ở Bài 3 để nó dừng sớm và trả về lỗi khi ctx bị huỷ, kiểm tra sau mỗi 1000 vòng lặp theo đúng mẫu cân bằng mà bài viết đề xuất.

Đáp án
func TinhToan(ctx context.Context, n int) (int, error) {
	tong := 0
	for i := 0; i < n; i++ {
		if i%1000 == 0 {
			select {
			case <-ctx.Done():
				return 0, ctx.Err()
			default:
			}
		}
		tong += i * i
	}
	return tong, nil
}

Kiểm tra mỗi vòng lặp thì tốn chi phí không cần thiết; kiểm tra mỗi nghìn vòng là điểm cân bằng hợp lý giữa khả năng phản hồi và hiệu năng.

Bài 5 (bẫy/đánh đổi). Bài viết so sánh cái bẫy này với Promise.race trong JavaScript và future.get(timeout) trong Java. Nêu điểm chung của cả ba trường hợp, và giải thích tại sao thứ tự gọi cancel() trước wg.Wait() (chứ không phải ngược lại) lại quan trọng khi dùng context cùng WaitGroup.

Đáp án

Điểm chung: "tôi ngừng chờ" không giống "việc đã ngừng chạy". Promise.race của JavaScript trả về khi một trong các promise xong trước, nhưng promise thua cuộc không bị huỷ — nó vẫn chạy tiếp ngầm. future.get(timeout) của Java ném TimeoutException nhưng tác vụ vẫn chạy trong pool trừ khi gọi thêm future.cancel(true) và tác vụ chịu kiểm tra interrupted. Go cũng vậy: hết hạn một select không tự dừng goroutine phía sau.

Về thứ tự: cancel() phải gọi trước wg.Wait() vì cancel() là tín hiệu bảo mọi goroutine dừng, còn wg.Wait() là chờ chúng dừng xong. Nếu gọi wg.Wait() trước, chương trình sẽ chờ những goroutine chưa hề được bảo dừng — có thể chờ vô thời hạn nếu goroutine đó đang đợi context bị huỷ để thoát ra khỏi vòng lặp.