Bài GMP kết thúc với một cạm bẫy: số OS thread (M) có thể tăng vọt khi goroutine chặn trong syscall thật. Vậy sao một server Go xử lý được hàng chục nghìn kết nối đồng thời mà không tạo hàng chục nghìn thread? Vì I/O mạng không phải syscall blocking bình thường — nó đi qua netpoller, cơ chế tích hợp epoll (Linux) / kqueue (BSD) vào scheduler. Bài này mổ xẻ netpoller và đo thật sự khác biệt khổng lồ giữa I/O mạng và syscall thường.

Cơ chế: chặn goroutine, không chặn thread

Khi một goroutine gọi conn.Read() mà socket chưa có dữ liệu, thay vì chặn cả OS thread trong kernel, runtime làm năm bước:

  1. Đăng ký file descriptor (fd) với hệ thống thông báo sự kiện của OS — epoll trên Linux, kqueue trên BSD/macOS.
  2. Park goroutine — gỡ nó khỏi M (OS thread) và cất đi.
  3. Thả M — thread được giải phóng để chạy goroutine khác ngay lập tức.
  4. Scheduler định kỳ gọi epoll_wait (trong vòng tìm việc và trong sysmon) để hỏi OS những fd nào đã sẵn sàng.
  5. Khi fd có dữ liệu, goroutine tương ứng được đánh thức và đưa lại vào hàng đợi để chạy tiếp.

Kết quả: N goroutine chờ mạng chỉ cần một nhúm M giữ chỗ, không phải N thread. Đây là chìa khóa để Go giải bài C10k/C1M — hàng trăm nghìn kết nối đồng thời trên một pool thread nhỏ.

ln, _ := net.Listen("tcp", "127.0.0.1:0")
go func(conn net.Conn) {
    buf := make([]byte, 16)
    conn.Read(buf)   // CHẶN chờ dữ liệu -> netpoller park goroutine, KHÔNG giữ M
}(c)
// mở 5000 kết nối, đếm NumGoroutine vs threads (schedtrace)

Ảnh chụp đoạn mã Go nền tối minh hoạ netpoller vì sao 5000 kết nối chỉ tốn một nhúm thread I/O mạng chặn goroutine mà không chặn OS thread nhờ epoll kqueue, khi goroutine gọi conn Read mà chưa có dữ liệu 1 runtime đăng ký file descriptor fd với epoll Linux kqueue BSD 2 park goroutine gỡ nó khỏi M OS thread 3 M được thả chạy goroutine khác ngay 4 scheduler định kỳ gọi epoll_wait tìm fd đã sẵn sàng 5 fd có dữ liệu goroutine tương ứng được đánh thức cho chạy lại N goroutine chờ mạng chỉ tốn một nhúm M không phải N thread, đo thật 5000 goroutine chặn trên network Read ln net Listen tcp go func conn net Conn buf make byte 16 conn Read buf chặn chờ dữ liệu netpoller park không giữ M mở 5000 kết nối đếm NumGoroutine vs threads schedtrace, đối chứng syscall blocking không qua netpoller syscall Nanosleep syscall thật giữ M trong kernel N syscall blocking đồng thời runtime phải tạo N thread M file I/O trên đĩa cũng vậy đĩa không pollable tốn M

Hình 1: Năm bước của netpoller — đăng ký fd với epoll, park goroutine, thả M, poll tìm fd sẵn sàng, đánh thức goroutine. Kèm mã đo và đối chứng với syscall blocking.

Đo thật: 16 thread hay 203 thread

Tôi đo hai kịch bản đối lập trên cùng một máy:

Ảnh chụp kết quả đo thật nền tối I/O mạng vs syscall chặn tốn bao nhiêu OS thread Go 1.23, kịch bản Network Read qua netpoller goroutine chặn 5000 OS thread M 16 tỉ lệ khoảng 312 G trên 1 M, Syscall Nanosleep không netpoller goroutine chặn 200 OS thread M 203 tỉ lệ khoảng 1 G trên 1 M, vì sao khác biệt khổng lồ Network Read fd được đăng ký với epoll goroutine bị park khỏi thread 5000 goroutine chờ mạng chỉ 16 thread giữ chỗ đây là cách Go giải bài C10k C1M hàng trăm nghìn kết nối đồng thời trên một nhúm thread, Syscall Nanosleep syscall chặn giữ M trong kernel runtime không lấy lại được thread đó buộc phải tạo M mới để giữ P bận 200 syscall đồng thời khoảng 200 thread file I/O đĩa không pollable cũng tốn M kiểu này, ý nghĩa thực tế server mạng HTTP gRPC DB client dùng goroutine thoải mái netpoller lo nhiều file I/O CGO blocking đồng thời số thread phình cần giới hạn worker pool kẻo tạo hàng nghìn OS thread

Hình 2: Network Read qua netpoller — 5000 goroutine chặn chỉ dùng 16 thread (~312 G/M). Syscall Nanosleep không qua netpoller — 200 goroutine chặn ngốn 203 thread (~1 G/M). Khác biệt là bản chất netpoller.

Con số thật:

  • Network Read (qua netpoller): 5.000 goroutine chặn trên conn.Read → chỉ 16 OS thread. Tỉ lệ ~312 goroutine trên mỗi thread.
  • Syscall Nanosleep (không qua netpoller): 200 goroutine chặn trong một syscall thật → 203 thread — gần như một thread cho mỗi goroutine.

Khác biệt không phải tình cờ mà là bản chất:

  • I/O mạng: fd được đăng ký với epoll, goroutine bị park khỏi thread. Thread rảnh chạy việc khác. 5000 kết nối chờ chỉ tốn 16 thread giữ chỗ.
  • Syscall blocking: syscall.Nanosleep (hay bất kỳ syscall chặn nào) giữ M trong kernel — runtime không lấy lại được thread đó, buộc phải tạo M mới để giữ P bận. 200 syscall đồng thời → ~200 thread. File I/O trên đĩa cũng như vậy: đĩa cứng không "pollable" với epoll, nên đọc file blocking tốn một M.

Ý nghĩa thực tế

Đây là kiến thức có sức nặng khi thiết kế hệ thống:

  • Server mạng (HTTP, gRPC, client database qua TCP): cứ tạo một goroutine cho mỗi kết nối/request thoải mái. Netpoller lo phần chặn I/O, số thread giữ nhỏ. Đây là lý do mô hình "một goroutine mỗi request" của Go vừa đơn giản vừa mở rộng tốt tới hàng trăm nghìn kết nối.
  • File I/O hoặc CGO blocking đồng thời: đây là điểm cần cẩn thận. Nếu bạn mở hàng nghìn goroutine cùng đọc/ghi file đĩa (hoặc gọi hàm C blocking), số OS thread có thể phình lên hàng nghìn — tốn bộ nhớ và làm scheduler nặng nề. Giải pháp: giới hạn số goroutine làm file I/O đồng thời bằng một worker pool hay semaphore (bài riêng sẽ đo).

Đánh đổi cần cân nhắc

Netpoller không miễn phí về độ trễ đánh thức. Goroutine bị park được đánh thức khi scheduler gọi epoll_wait và thấy fd sẵn sàng — có một độ trễ nhỏ giữa lúc dữ liệu tới và lúc goroutine chạy lại. Với đại đa số ứng dụng, độ trễ này không đáng kể; nhưng với hệ thống cần độ trễ cực thấp (high-frequency trading), người ta đôi khi dùng cơ chế polling riêng để tránh qua netpoller.

Không phải mọi fd đều pollable. Netpoller chỉ áp dụng cho socket, pipe, và các fd hỗ trợ epoll/kqueue. Regular file trên đĩa không pollable trên Linux — đó là lý do file I/O rơi vào mô hình "một thread mỗi thao tác blocking". Biết cái nào đi qua netpoller (mạng, pipe) và cái nào không (file đĩa, CGO) giúp bạn dự đoán số thread.

Số M vẫn có trần và có chi phí. Runtime giới hạn số M ở 10000 mặc định (runtime/debug.SetMaxThreads). Nếu file I/O blocking tạo quá nhiều M chạm trần này, chương trình sẽ panic. Ngay dưới trần, hàng nghìn thread cũng tốn bộ nhớ stack và làm kernel lập lịch nặng. Vì thế giới hạn concurrency cho blocking I/O là thực hành quan trọng.

Ba ý mang về

  1. Netpoller tích hợp epoll/kqueue vào scheduler để I/O mạng chặn goroutine mà không chặn thread: khi socket chưa sẵn sàng, runtime đăng ký fd với epoll, park goroutine khỏi M, và thả M chạy việc khác — đo thật 5000 goroutine chờ network Read chỉ dùng 16 thread.
  2. Syscall blocking (và file I/O đĩa) KHÔNG qua netpoller nên tốn một M mỗi thao tác: đo thật 200 goroutine chặn trong syscall.Nanosleep ngốn 203 thread — vì syscall giữ M trong kernel, buộc runtime tạo M mới; đĩa không pollable cũng vậy.
  3. Thực tế: server mạng cứ dùng goroutine thoải mái (netpoller lo), nhưng giới hạn concurrency cho file I/O và CGO blocking bằng worker pool/semaphore để số OS thread không phình lên hàng nghìn — và nhớ netpoller có độ trễ đánh thức nhỏ, số M có trần mặc định 10000.

Phần sau ta gặp "người gác đền" đã nhắc nhiều lần: Phần sau mổ xẻ sysmon — thread giám sát nền không gắn P, chịu trách nhiệm gửi tín hiệu preempt, thu hồi P khỏi syscall dài, và kích hoạt GC khi cần.