Bạn viết go f() và một goroutine ra đời. Nhưng nó chạy ở đâu? Máy chỉ có 10 lõi CPU, vậy 100.000 goroutine chen chúc thế nào? Câu trả lời là mô hình GMP — trái tim của bộ lập lịch Go, thứ khiến goroutine rẻ tới mức bạn tạo hàng trăm nghìn cái mà không nghĩ ngợi. Series "Golang: Zero to Hero" đã dạy cách dùng goroutine; series này mở nắp capô xem cách nó chạy. Bài mở đầu: GMP là gì, và đo thật bao nhiêu OS thread thật sự được dùng.
G, M, P là ba thứ gì
Bộ lập lịch Go xoay quanh ba trụ cột:
- G (goroutine): đơn vị công việc bạn tạo bằng
go. Cực nhẹ — stack khởi đầu chỉ ~2 KB (tăng động khi cần). Một chương trình có thể có hàng triệu G. - M (machine): một OS thread thật, do kernel lập lịch. Tạo M tốn kém (mỗi thread ngốn bộ nhớ stack cỡ MB và tài nguyên kernel), nên Go cố giữ số M nhỏ.
- P (processor): một ngữ cảnh lập lịch — không phải CPU vật lý, mà một "chỗ" để chạy G. Số P =
GOMAXPROCS(mặc định bằng số lõi CPU). Mỗi P giữ một hàng đợi goroutine cục bộ.
Quy tắc cốt lõi: một M phải gắn với một P mới chạy được G. Vì số P cố định (= số lõi) và số M được giữ nhỏ, cả một biển goroutine được ghép (multiplex) lên một nhúm thread. G nhiều ≫ M ≈ P.
const N = 100000
release := make(chan struct{})
for i := 0; i < N; i++ {
go func() { ready.Done(); <-release }() // chặn tới khi release
}
ready.Wait()
fmt.Println("G:", runtime.NumGoroutine()) // đếm goroutine sống
fmt.Println("P:", runtime.GOMAXPROCS(0)) // đếm processor

Hình 1: Ba trụ cột GMP và đoạn mã đo — tạo 100.000 goroutine cùng chặn để đếm G, P; dùng GODEBUG=schedtrace để thấy M.
Đo thật: 100.001 goroutine, 5 thread
Tôi tạo 100.000 goroutine, mỗi cái lập tức chặn trên một channel (nên tất cả sống cùng lúc), rồi đếm. Kèm GODEBUG=schedtrace=1 để in trạng thái scheduler:

Hình 2: Đo thật — 100.001 goroutine (G) chạy trên chỉ 5 OS thread (M) qua 10 processor (P). Dòng SCHED cho thấy threads=5, và [0 0 ...] là độ dài hàng đợi cục bộ của 10 P.
Con số thật:
- P = 10 (
GOMAXPROCS, bằng số lõi). - G = 100.001 goroutine sống (100.000 goroutine chặn + goroutine
main). - M = 5 OS thread (từ dòng
SCHED ... threads=5).
Một trăm nghìn goroutine chạy trên chỉ 5 thread. Nếu Go tạo một OS thread cho mỗi goroutine (như mô hình 1:1 của nhiều ngôn ngữ), 100.000 thread sẽ ngốn hàng GB RAM (mỗi thread ~1-8 MB stack) và làm sập máy — chưa kể chi phí kernel lập lịch từng ấy thread. Thay vào đó, runtime ghép nhiều G lên ít M: khi một G chặn (chờ channel, I/O, khoá), M được thả ra chạy G khác. Đây chính là lý do goroutine gần như miễn phí — bạn tạo nó thoải mái vì nó không phải là thread.
Đọc một dòng SCHED
Dòng GODEBUG=schedtrace là công cụ chẩn đoán scheduler quan trọng nhất. Giải mã dòng thật ở trên:
gomaxprocs=10— số P.idleprocs=7— 7 P đang rảnh (không có G để chạy tại thời điểm chụp).threads=5— tổng số M runtime đang giữ.spinningthreads=1— M đang "quay" tìm việc (chi tiết ở bài scheduler).runqueue=0— hàng đợi G toàn cục rỗng.[0 0 0 0 0 0 0 0 0 0]— độ dài hàng đợi cục bộ của từng P (10 số cho 10 P).
Ở snapshot này, các goroutine đã chặn nên hàng đợi rỗng và nhiều P rảnh — đúng như mong đợi khi chúng đang chờ channel.
Đánh đổi cần cân nhắc
Goroutine rẻ nhưng không phải miễn phí tuyệt đối. Mỗi G vẫn tốn stack (khởi đầu ~2 KB, tăng động) và một chút metadata. 100.000 goroutine chặn chiếm bộ nhớ thật — chỉ là ít hơn 100.000 thread hàng nghìn lần. Tạo hàng triệu goroutine vẫn có thể hết RAM; goroutine rẻ không có nghĩa là vô hạn.
Số M có thể tăng khi G chặn trong syscall. Ở ví dụ này G chặn trên channel (do runtime quản lý), nên M ít. Nhưng khi G chặn trong một syscall thật (đọc file, gọi mạng blocking), M đó bị "mắc kẹt" trong kernel, và runtime tạo M mới để giữ P bận. Với nhiều syscall blocking đồng thời, số M có thể tăng vọt — chủ đề bài về netpoller và syscall.
GOMAXPROCS mặc định = số lõi máy, không phải quota container. Trong container giới hạn CPU, GOMAXPROCS vẫn lấy số lõi vật lý của host, dễ tạo quá nhiều P so với CPU thật được cấp — gây tranh chấp. Đây là một cạm bẫy production, xử lý ở bài GOMAXPROCS và container.
Ba ý mang về
- GMP là ba trụ cột: G (goroutine, rất nhẹ) ghép lên M (OS thread thật, giữ ít) qua P (processor = GOMAXPROCS = số lõi) — một M phải gắn một P mới chạy được G, nên cả biển goroutine được multiplex lên một nhúm thread.
- Đo thật cho thấy 100.001 goroutine chạy trên chỉ 5 OS thread qua 10 processor — nếu mỗi goroutine là một thread thì 100k thread ngốn hàng GB RAM và sập; việc ghép nhiều G lên ít M chính là lý do goroutine gần như miễn phí.
GODEBUG=schedtrace=1là cửa sổ nhìn vào scheduler — đọc được số P (gomaxprocs), số M (threads), hàng đợi toàn cục (runqueue) và hàng đợi cục bộ của từng P; nhưng nhớ số M có thể tăng khi G chặn trong syscall thật, và GOMAXPROCS mặc định không biết quota container.
Phần sau ta xem P lấy việc cho nhau thế nào khi hàng đợi lệch tải: Phần sau mổ xẻ bộ lập lịch work-stealing — cách một P rảnh "đánh cắp" goroutine từ hàng đợi của P khác để không lõi nào ngồi không.