Runtime của Go có một thread mà bạn không bao giờ thấy trong code, không đếm vào runtime.NumGoroutine(), và không gắn với GOMAXPROCS. Nó tên là sysmon (system monitor), sinh ra ngay khi chương trình khởi động và chạy nền tới lúc thoát. Nó là lý do một goroutine kẹt trong một syscall dài không kéo cả chương trình chết theo. Bài này đo thật những gì sysmon làm — không phải đọc lý thuyết, mà chạy code và xem con số.
Sysmon là gì và nó canh những gì
Sysmon là một OS thread đặc biệt: nó không cần P (processor logic) để chạy, khác với mọi goroutine thông thường. Nó ngủ theo chu kỳ (bắt đầu 20µs, tăng dần tối đa 10ms), tỉnh dậy, quét trạng thái toàn hệ thống rồi ngủ tiếp. Bốn việc chính của nó:
- Tiền chiếm (preempt) goroutine chạy quá lâu — gửi tín hiệu
SIGURG(bài async preemption đã đo). - Giành lại P từ M đang kẹt syscall dài — trọng tâm bài này.
- Ép chạy netpoller khi đã lâu không ai kiểm tra I/O mạng sẵn sàng.
- Ép GC mỗi 2 phút nếu chưa có lần thu gom nào.
Ngoài ra, chính sysmon là thread in ra dòng SCHED mỗi khi bạn bật GODEBUG=schedtrace.

Hình 1: GOMAXPROCS=1. A kẹt trong syscall.Read (pipe rỗng). Nếu B vẫn tăng được counter, sysmon đã trao P đi cho một M khác chạy B.
Đo thật: một P, một syscall kẹt, mà công việc khác vẫn chạy
Đây là phép đo cốt lõi. Ta ép GOMAXPROCS(1) — chỉ một P. Goroutine A gọi syscall.Read trên một pipe rỗng, nên nó block vô hạn cho tới khi có byte. Goroutine B đếm liên tục bằng atomic. Nếu không có cơ chế giành P, A giữ P duy nhất và B sẽ không bao giờ chạy.
runtime.GOMAXPROCS(1)
var fds [2]int
syscall.Pipe(fds[:]) // pipe rỗng
go func() {
buf := make([]byte, 1)
syscall.Read(fds[0], buf) // BLOCK tới khi có byte
close(done)
}()
go func() {
for { atomic.AddInt64(&counter, 1); select { case <-done: return; default: } }
}()
time.Sleep(300 * time.Millisecond)
fmt.Printf("B đã đếm = %d\n", atomic.LoadInt64(&counter))
Kết quả đo thật trong container Go 1.23:

Hình 2: B đếm được ~103 triệu lần trong khi A kẹt syscall 320ms — với chỉ một P. schedtrace (do chính sysmon in) cho idleprocs=0: P luôn bận. Và 40 syscall blocking đẩy số thread từ 5 lên 43.
Con số nói tất cả: B đếm được 103.227.955 lần trong 300ms, dù chỉ có một P và A đang giữ một M kẹt trong syscall. Chuyện gì xảy ra? Khi A vào syscall, runtime đánh dấu M của A là "đang syscall" và nhả P ra khỏi M đó. Nếu syscall trả về nhanh, M lấy lại P và chạy tiếp — rẻ. Nhưng nếu syscall kéo dài, sysmon phát hiện P đang bị treo theo một M kẹt syscall, bèn tách hẳn P ra và trao cho một M khác (đánh thức M rảnh hoặc tạo M mới). M mới đó chạy B. A vẫn nằm kẹt trong syscall.Read trên M cũ, nhưng nó không còn giữ P nữa.
Nhìn vào schedtrace — chính sysmon in ra: idleprocs=0 ở mọi mốc, nghĩa là P duy nhất luôn có việc (chạy B). Và threads=5 dù gomaxprocs=1: số thread nhiều hơn số P vì có một M riêng đang nằm kẹt trong syscall, tách biệt với M đang chạy B.
Cái giá: mỗi syscall blocking là một OS thread
Cơ chế giành P rất hay, nhưng không miễn phí. Nếu nhiều goroutine cùng kẹt syscall blocking một lúc, mỗi cái cần một M (OS thread) riêng để nằm chờ, còn P thì được trao cho M khác chạy việc mới. Ta đo trực tiếp số thread qua /proc/self/status:
runtime.GOMAXPROCS(2)
const N = 40
for i := 0; i < N; i++ {
syscall.Pipe(pipes[i][:])
go func(fd int) { syscall.Read(fd, make([]byte, 1)) }(pipes[i][0]) // kẹt
}
time.Sleep(400 * time.Millisecond)
fmt.Println("thread =", osThreads()) // đọc /proc/self/status
Kết quả: từ 5 thread ban đầu vọt lên 43 thread — khoảng 38 M nằm kẹt syscall cộng với các thread nền. Đây là điểm phân biệt cực quan trọng với I/O mạng: net.Conn.Read đi qua netpoller (epoll) nên hàng nghìn kết nối chỉ tốn ít thread (bài trước đã đo 5000 goroutine mạng chỉ 16 thread). Nhưng syscall blocking "thô" — đọc file, gọi C qua cgo, một số syscall không hỗ trợ non-blocking — thì đẻ ra thread thật, mỗi cái một OS thread. Đó là lý do một chương trình gọi nhiều cgo hoặc I/O file đồng bộ có thể có số thread cao bất ngờ.
Ứng dụng thực tế
Hiểu vì sao số thread cao hơn GOMAXPROCS. Khi bạn thấy một tiến trình Go có 200 thread trong khi GOMAXPROCS=8, thủ phạm thường là syscall blocking đồng thời (cgo, I/O file, DNS lookup đồng bộ). Đó không phải rò rỉ — đó là sysmon giữ cho P luôn có việc. Nhưng nếu số thread phình quá mức, hãy soi lại các lời gọi cgo hoặc dùng runtime/pprof xem chúng đến từ đâu.
GODEBUG=schedtrace là cửa sổ nhìn vào sysmon. Cột threads so với gomaxprocs, idleprocs, và runqueue cho bạn biết P có bị treo vì syscall không, có goroutine bị xếp hàng chờ không. Đây là công cụ chẩn đoán nền tảng khi nghi ngờ vấn đề lập lịch.
Cẩn thận với cgo và I/O đồng bộ ở quy mô lớn. Vì mỗi lời gọi cgo blocking chiếm một thread, một service gọi cgo trên đường nóng với hàng nghìn request đồng thời có thể tạo quá nhiều thread. Cân nhắc gộp lời gọi, dùng hàng đợi worker giới hạn, hoặc thay bằng thư viện thuần Go dùng netpoller.
Đánh đổi cần cân nhắc
Sysmon là thứ khiến "goroutine kẹt không giết cả chương trình" thành hiện thực — nhưng chỉ với syscall. Nếu goroutine kẹt vì một vòng lặp CPU không có điểm an toàn (safepoint) chứ không phải syscall, cơ chế giành P không áp dụng; khi đó tiền chiếm bất đồng bộ qua SIGURG mới là thứ cứu (bài async preemption). Hai cơ chế khác nhau, sysmon là tay điều phối cả hai.
Số thread không tự co lại ngay. Trong phép đo, sau khi 40 syscall thoát, số thread vẫn là 43 — runtime giữ các M rảnh trong pool thay vì huỷ ngay, để tái dùng cho lần sau. Điều này bình thường; đừng hoảng khi thấy thread không giảm tức thì sau một đợt tải.
Bạn không điều khiển trực tiếp được sysmon. Không có API bật/tắt hay chỉnh chu kỳ của nó (ngoài vài GODEBUG). Nó là phần cố định của runtime. Việc của kỹ sư là hiểu nó để đọc đúng các triệu chứng — thread cao, P treo, schedtrace bất thường — chứ không phải tinh chỉnh nó.
Ba ý mang về
- Sysmon là thread nền không gắn P, canh bốn việc: tiền chiếm, giành P từ syscall dài, ép netpoller, ép GC — và in schedtrace. Đo thật: GOMAXPROCS=1, một goroutine kẹt syscall 320ms mà goroutine khác vẫn đếm được 103 triệu lần, vì sysmon trao P đi.
- Syscall blocking đẻ ra OS thread thật — 40 syscall đồng thời đẩy số thread từ 5 lên 43, khác hẳn I/O mạng đi qua netpoller. Đây là lý do phổ biến khiến số thread vượt xa
GOMAXPROCS. GODEBUG=schedtracelà cửa sổ nhìn vào sysmon:idleprocs,threads,runqueuecho biết P có bị treo vì syscall không — công cụ chẩn đoán lập lịch nền tảng.
Phần sau ta mổ xẻ một cơ chế runtime khác làm goroutine rẻ đến kinh ngạc: Phần sau đo stack goroutine tăng trưởng động — vì sao một goroutine chỉ tốn 2KB lúc sinh ra, và runtime copy cả stack khi nó lớn lên như thế nào.