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

Ảnh chụp đoạn mã Go nền tối minh hoạ mô hình GMP goroutine chạy trên đâu G goroutine ghép lên M OS thread qua P processor, ba trụ cột của bộ lập lịch Go G goroutine đơn vị công việc rất nhẹ stack khoảng 2KB ban đầu M machine một OS thread thật do kernel lập lịch P processor ngữ cảnh lập lịch giữ hàng đợi G cục bộ số P bằng GOMAXPROCS mặc định bằng số lõi CPU quy tắc một M phải gắn một P mới chạy được G G nhiều hơn hẳn M xấp xỉ P, đo thật tạo 100000 goroutine cùng chặn const N 100000 release make chan struct for i 0 i nhỏ hơn N i cộng cộng go func ready Done nhận release chặn ready Wait fmt Println G runtime NumGoroutine đếm goroutine fmt Println P runtime GOMAXPROCS 0 đếm processor, xem M thread bằng GODEBUG in trạng thái scheduler mỗi 1ms GODEBUG schedtrace 1 go run clean.go dòng SCHED cho biết gomaxprocs threads M runqueue

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:

Ảnh chụp kết quả đo thật nền tối 100000 goroutine chạy trên bao nhiêu OS thread Go 1.23 10 lõi, kết quả chương trình P GOMAXPROCS 10 G goroutine sống 100001 100000 goroutine chặn cộng 1 main, một dòng SCHED thật GODEBUG schedtrace 1 SCHED 0ms gomaxprocs 10 idleprocs 7 threads 5 spinningthreads 1 runqueue 0 threads 5 chỉ 5 OS thread M dấu ngoặc là hàng đợi cục bộ của 10 P, bức tranh GMP thành phần G goroutine số lượng 100001 công việc mỗi cái vài KB stack M OS thread 5 thread thật kernel lập lịch P processor 10 bằng GOMAXPROCS bằng số lõi, 100001 goroutine ghép lên chỉ 5 thread qua 10 processor G nhiều hơn hẳn M xấp xỉ P nếu mỗi goroutine là một OS thread 100k thread sẽ ngốn hàng GB RAM và sập runtime ghép nhiều G lên ít M đó là lý do Go tạo goroutine gần như miễn phí

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ề

  1. 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.
  2. Đ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í.
  3. GODEBUG=schedtrace=1 là 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.