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.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn code sau tái hiện phép đo cốt lõi của bài, với runtime.GOMAXPROCS(1):
runtime.GOMAXPROCS(1)
var fds [2]int
syscall.Pipe(fds[:]) // pipe rỗng, chưa có byte nào
done := make(chan struct{})
var counter int64
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.Println("counter =", atomic.LoadInt64(&counter))
Chỉ có một P (GOMAXPROCS=1) và goroutine đầu tiên đang kẹt vô hạn trong syscall.Read. Vậy sau 300ms, counter sẽ in ra 0 hay một số dương lớn? Giải thích cơ chế đứng sau câu trả lời.
Đáp án
counter in ra một số dương rất lớn (bài đo được 103.227.955). Khi goroutine A vào syscall.Read, runtime đánh dấu M của A là "đang syscall" và nhả P ra khỏi M đó. Vì syscall kéo dài, sysmon phát hiện P đang bị "treo" theo một M kẹt syscall nê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) để chạy goroutine B. Nhờ vậy dù chỉ có một P, B vẫn chạy full tốc trong lúc A kẹt syscall — đây chính là cơ chế "giành lại P" mà sysmon đảm nhiệm.
Bài 2 (sửa lỗi). Một người muốn tái hiện phép đo trên nhưng viết lại phần đồng bộ hoá như sau:
var done bool // BUG
go func() {
buf := make([]byte, 1)
syscall.Read(fds[0], buf)
done = true
}()
go func() {
for !done {
atomic.AddInt64(&counter, 1)
}
}()
Đoạn code này có lỗi gì về đồng thời, và tại sao nó khác bản chuẩn dùng channel trong bài viết?
Đáp án
Đây là data race: biến done (kiểu bool thường) được một goroutine ghi và một goroutine khác đọc lặp lại trong vòng lặp mà không có bất kỳ đồng bộ hoá nào (không mutex, không atomic, không channel). Trình biên dịch/runtime không đảm bảo goroutine đọc for !done sẽ thấy được giá trị mới ghi — chương trình có hành vi không xác định (go run -race sẽ báo lỗi), và về lý thuyết vòng lặp có thể chạy mãi không thấy done đổi. Bản chuẩn trong bài dùng close(done) trên một chan struct{} — thao tác đóng channel là một điểm đồng bộ hoá được Go memory model đảm bảo, nên goroutine B chắc chắn thấy được tín hiệu dừng.
Bài 3 (vận dụng). Một dịch vụ Go chạy với GOMAXPROCS=8 nhưng khi soi bằng /proc/self/status, bạn thấy nó đang có 200 OS thread. Dựa vào nội dung bài viết, hãy nêu hướng chẩn đoán: (a) đây có phải là rò rỉ hay lỗi không, và (b) công cụ/chỉ số nào giúp xác nhận nguyên nhân.
Đáp án
(a) Không nhất thiết là rò rỉ — đây thường là hệ quả bình thường của sysmon: mỗi syscall blocking đồng thời (gọi cgo, I/O file đồng bộ, DNS lookup đồng bộ) cần một M (OS thread) riêng để nằm chờ, trong khi P được sysmon trao cho M khác chạy việc mới. Bài đã đo trực tiếp: 40 syscall blocking đồng thời đẩy số thread từ 5 lên 43. (b) Dùng GODEBUG=schedtrace để xem cột threads so với gomaxprocs/idleprocs/runqueue, và dùng runtime/pprof (goroutine/block profile) để xem các lời gọi đang kẹt syscall/cgo đến từ đâu. Nếu số thread phình quá mức thực tế cần, nên soi lại các lời gọi cgo hoặc I/O đồng bộ trên đường nóng.
Bài 4 (bẫy/đánh đổi). Sau khi 40 syscall blocking trong phép đo của bài kết thúc, số OS thread vẫn giữ nguyên ở 43 chứ không giảm ngay. Đây có phải là dấu hiệu rò rỉ thread không? Và nếu một goroutine bị kẹt vì một vòng lặp CPU thuần (không gọi syscall), sysmon có "giành lại P" cho nó được không?
Đáp án
Không phải rò rỉ. Runtime giữ các M rảnh trong một pool thay vì huỷ ngay để tái dùng cho lần sau, nên số thread không tự co lại ngay lập tức sau một đợt tải — đây là hành vi thiết kế bình thường. Với vòng lặp CPU thuần không có safepoint (không phải syscall), cơ chế "giành P" của sysmon không áp dụng — đó là hai cơ chế khác nhau. Trường hợp này cần tiền chiếm bất đồng bộ qua tín hiệu SIGURG (async preemption) mới cứu được, dù sysmon vẫn là tay điều phối chung cho cả hai cơ chế.
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.