Ba bài trước ta thấy scheduler phân phối goroutine ra các processor rất khéo. Nhưng có một tình huống làm cả hệ thống đó sụp: một goroutine chạy vòng lặp chặt không gọi hàm nào. Trước Go 1.14, một for {} như vậy có thể độc chiếm một processor mãi mãi, bỏ đói mọi goroutine khác và làm treo cả garbage collector. Go 1.14 giải bài toán này bằng async preemption — cắt ngang goroutine cứng đầu bằng tín hiệu hệ điều hành. Bài này mổ xẻ cơ chế và đo thật hậu quả khi thiếu nó.
Vấn đề: lập lịch hợp tác cần "safe-point"
Trước Go 1.14, scheduler chỉ có thể cắt (preempt) một goroutine tại các safe-point — những chỗ runtime đã cài sẵn kiểm tra: lúc gọi hàm, lúc cấp phát bộ nhớ, lúc quay lại đầu một số vòng lặp. Đây là lập lịch hợp tác: goroutine phải "tự nguyện" đi qua một safe-point thì mới bị cắt.
Vấn đề nảy sinh khi goroutine không bao giờ tới safe-point:
go func() {
for {} // vòng lặp chặt: KHÔNG gọi hàm, KHÔNG cấp phát
}() // -> không tới safe-point -> ĐỘC CHIẾM P mãi mãi
Vòng lặp này không gọi hàm, không cấp phát, nên không có safe-point. Với lập lịch hợp tác, nó giữ processor vĩnh viễn. Hậu quả: các goroutine khác trên processor đó chết đói, và tệ hơn — garbage collector cần stop-the-world (cắt mọi goroutine để dọn dẹp) sẽ treo vì không cắt được goroutine này.

Hình 1: Vấn đề lập lịch hợp tác (một for{} không có safe-point giữ P mãi) và giải pháp Go 1.14: sysmon phát hiện G chạy quá lâu, gửi tín hiệu SIGURG để cắt ngang.
Giải pháp Go 1.14: cắt bằng tín hiệu
Async preemption dùng cơ chế tín hiệu của hệ điều hành, hoạt động qua bốn bước:
- Thread sysmon (thread giám sát nền — bài riêng sẽ mổ xẻ) theo dõi các goroutine, phát hiện một G đã chạy quá lâu (~10 ms).
- Runtime gửi một tín hiệu hệ điều hành (
SIGURGtrên Unix) tới chính OS thread đang chạy goroutine đó. - Trình xử lý tín hiệu của runtime cắt ngang goroutine tại một điểm an toàn bất kỳ — không cần safe-point cài sẵn.
- Goroutine bị đưa về hàng đợi, processor được thả cho goroutine khác chạy.
Điểm mấu chốt: tín hiệu cắt được ở gần như mọi chỗ, kể cả giữa một vòng lặp thuần tính toán. Vòng lặp chặt không còn giữ processor được mãi.
Đo thật: exit 0 hay exit 124
Tôi dựng kịch bản kinh điển: GOMAXPROCS=1 (một processor duy nhất), một goroutine chạy for {} vô hạn, và main ngủ 100 ms rồi cố in một dòng. Câu hỏi: sau khi ngủ, main có lấy lại được processor để in không? So sánh bật (mặc định) và tắt async preemption:

Hình 2: Bật async preemption — main in "MAIN QUAY LẠI ĐƯỢC", exit 0, xong <1s. Tắt (asyncpreemptoff=1) — main không in gì, exit 124 (bị timeout giết), treo như Go trước 1.14.
Kết quả thật, dứt khoát:
- Bật (mặc định Go 1.14+):
mainin được "MAIN QUAY LẠI ĐƯỢC", chương trình kết thúc bình thường với exit code 0 trong dưới một giây. sysmon thấy vòng lặp chạy quá lâu, gửi SIGURG cắt ngang, processor được thả chomain. - Tắt (
GODEBUG=asyncpreemptoff=1, mô phỏng Go trước 1.14):mainkhông in gì cả, chương trình bịtimeoutgiết sau 4 giây với exit code 124. Không có cơ chế cắt, vòng lặpfor {}giữ processor duy nhất vĩnh viễn;mainkhông bao giờ lấy lại được để in.
Đây chính xác là hành vi từng làm khổ lập trình viên Go trước 1.14: một for {} vô tình (hoặc một vòng lặp tính toán nặng không gọi hàm) có thể treo cả chương trình hoặc làm GC đứng.
Vì sao dùng tín hiệu, không kiểm tra trong vòng lặp
Một cách thay thế là compiler chèn mã "kiểm tra có cần cắt không?" vào mỗi vòng lặp. Go chọn tín hiệu vì:
- Không chi phí trên đường chạy nóng: không phải chèn mã kiểm tra vào mọi vòng lặp, nên vòng lặp chạy đúng tốc độ tối đa. Kiểm tra chỉ xảy ra khi tín hiệu thực sự tới (hiếm).
- Cắt được gần như mọi điểm: kể cả giữa một biểu thức tính toán dài, không phụ thuộc cấu trúc mã.
Đổi lại là độ phức tạp: khi tín hiệu tới giữa chừng, runtime phải lưu và khôi phục chính xác trạng thái thanh ghi của goroutine để nó chạy tiếp đúng chỗ sau khi được lập lịch lại — một cơ chế tinh vi ở tầng assembly.
Đánh đổi cần cân nhắc
Async preemption gần như trong suốt, hiếm khi bạn phải nghĩ tới. Từ Go 1.14, bạn có thể viết vòng lặp tính toán nặng mà không sợ treo scheduler — runtime lo. Bạn hầu như không bao giờ cần GODEBUG=asyncpreemptoff=1 (nó chỉ để gỡ lỗi runtime hoặc né một bug hiếm liên quan tín hiệu).
Nó không biến vòng lặp vô hạn thành "an toàn". Async preemption chỉ đảm bảo goroutine khác không bị chết đói và GC chạy được — nó không dừng vòng lặp for {} của bạn. Một vòng lặp vô hạn vô tình vẫn đốt 100% một core mãi mãi; async preemption chỉ khiến nó không kéo sập phần còn lại. Rò rỉ goroutine chạy vòng lặp vẫn là bug thật.
Cắt bằng tín hiệu có chi phí nhỏ khi xảy ra. Mỗi lần preempt là một tín hiệu + lưu/khôi phục ngữ cảnh — không đáng kể với goroutine bình thường (bị cắt hiếm), nhưng nếu bạn có nhiều goroutine tính toán rất dài bị cắt liên tục ở tần suất cao, chi phí này tích lại. Thực tế hiếm gặp; chỉ cần biết nó tồn tại.
Ba ý mang về
- Trước Go 1.14, scheduler chỉ cắt goroutine tại safe-point (gọi hàm, cấp phát), nên một vòng lặp
for {}chặt không có safe-point độc chiếm processor mãi mãi — bỏ đói goroutine khác và làm treo garbage collector. - Go 1.14 thêm async preemption bằng tín hiệu: sysmon phát hiện G chạy quá ~10ms, gửi SIGURG cắt ngang tại điểm an toàn bất kỳ — đo thật cho thấy bật thì main quay lại được (exit 0) còn tắt thì treo tới timeout (exit 124), đúng hành vi Go cũ.
- Dùng tín hiệu để vòng lặp chạy đúng tốc độ (0 chi phí trên đường nóng) và cắt được gần như mọi điểm, đổi lấy độ phức tạp lưu/khôi phục thanh ghi — nhưng nhớ nó không dừng vòng lặp vô hạn của bạn, chỉ ngăn nó kéo sập phần còn lại; rò rỉ goroutine vẫn là bug.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho đoạn code sau chạy với GOMAXPROCS=1:
func main() {
go func() {
for {} // vòng lặp chặt: không gọi hàm, không cấp phát
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("MAIN QUAY LẠI ĐƯỢC")
}
Chạy chương trình này bằng go run main.go (mặc định, không đặt GODEBUG) trên Go 1.23, chương trình sẽ in ra gì và thoát với mã nào? Nếu chạy lại với GODEBUG=asyncpreemptoff=1, kết quả thay đổi thế nào?
Đáp án
Mặc định (async preemption bật): chương trình in "MAIN QUAY LẠI ĐƯỢC" và thoát với exit code 0 trong dưới 1 giây — sysmon phát hiện goroutine for {} chạy quá lâu, gửi tín hiệu SIGURG cắt ngang, processor được thả cho main chạy tiếp sau khi ngủ. Với GODEBUG=asyncpreemptoff=1: chương trình không in gì cả và sẽ treo vô hạn (trong bài đo bằng timeout nên bị giết ở giây thứ 4, thoát với exit code 124) — vì không có cơ chế cắt, for {} giữ processor duy nhất (GOMAXPROCS=1) mãi mãi, main không bao giờ lấy lại được P để in.
Bài 2 (sửa lỗi). Một lập trình viên viết goroutine dưới đây với niềm tin sai: "từ Go 1.14, async preemption sẽ tự động dừng vòng lặp này khi done được đóng, không cần tôi kiểm tra gì thêm":
func worker(done <-chan struct{}) {
for {
// async preemption sẽ tự dừng vòng lặp này khi done đóng
}
}
Niềm tin này sai ở đâu, và sửa lại worker để nó thực sự dừng khi done đóng?
Đáp án
Sai: async preemption chỉ tạm nhường processor cho goroutine khác chạy (để chúng không bị chết đói và GC chạy được) — nó không hề dừng hay kết thúc goroutine đang chạy for {}. Bài nêu rõ: "Nó không biến vòng lặp vô hạn thành an toàn... async preemption chỉ khiến nó không kéo sập phần còn lại. Rò rỉ goroutine chạy vòng lặp vẫn là bug thật." Sửa lại để worker thực sự dừng:
func worker(done <-chan struct{}) {
for {
select {
case <-done:
return
default:
}
}
}
Bài 3 (vận dụng). Liệt kê đúng thứ tự bốn bước của cơ chế async preemption khi runtime phát hiện một goroutine chạy quá lâu (~10ms).
Đáp án
- Thread
sysmontheo dõi các goroutine, phát hiện một G đã chạy quá ~10ms. - Runtime gửi tín hiệu hệ điều hành (
SIGURGtrên Unix) tới chính OS thread đang chạy goroutine đó. - Trình xử lý tín hiệu của runtime cắt ngang goroutine tại một điểm an toàn bất kỳ (không cần safe-point cài sẵn).
- Goroutine bị đưa về hàng đợi, processor được thả cho goroutine khác chạy.
Bài 4 (bẫy/đánh đổi). Vì sao Go chọn cắt ngang bằng tín hiệu hệ điều hành thay vì để compiler chèn mã "kiểm tra có cần cắt không?" vào mỗi vòng lặp? Nêu cái được và cái phải đánh đổi.
Đáp án
Cái được: không tốn chi phí trên đường chạy nóng (không phải chèn mã kiểm tra vào mọi vòng lặp nên vòng lặp chạy đúng tốc độ tối đa), và có thể cắt được ở gần như mọi điểm, kể cả giữa một biểu thức tính toán dài, không phụ thuộc cấu trúc mã. Cái phải đánh đổi: độ phức tạp lớn ở tầng thực thi — khi tín hiệu tới giữa chừng, runtime phải lưu và khôi phục chính xác trạng thái thanh ghi của goroutine để nó chạy tiếp đúng chỗ, một cơ chế tinh vi ở tầng assembly.
Bài 5 (bẫy/đánh đổi). Sau khi bật async preemption, một goroutine chạy for {} vô tình vẫn còn tồn tại trong hệ thống production suốt nhiều giờ. Dựa vào bài, điều gì vẫn xảy ra với goroutine đó dù async preemption đang hoạt động bình thường, và điều gì không xảy ra?
Đáp án
Vẫn xảy ra: goroutine đó vẫn đốt 100% một core mãi mãi — async preemption chỉ ngăn nó bỏ đói các goroutine khác và ngăn nó làm treo garbage collector, chứ không dừng vòng lặp. Không xảy ra: goroutine không tự kết thúc, không được "sửa" thành an toàn — đây vẫn là một bug rò rỉ goroutine thật cần được lập trình viên tự phát hiện và sửa (ví dụ thêm điều kiện dừng), async preemption không thay thế việc đó.
Phần sau ta xem runtime xử lý goroutine chặn I/O thế nào mà không tốn một thread cho mỗi kết nối: Phần sau mổ xẻ netpoller — cách Go tích hợp epoll/kqueue vào scheduler để hàng nghìn goroutine chờ mạng chỉ chiếm một nhúm thread.