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.
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.