Go dùng mô hình G-M-P: G là goroutine (stack khởi đầu vài KB, tự lớn lên), M là OS thread, P là logical processor giữ hàng đợi chạy cục bộ. Một M phải cầm một P mới chạy được code Go, nên số P = GOMAXPROCS = số goroutine chạy song song thật sự.
Các cơ chế chính:
- Local runqueue + global runqueue: goroutine mới vào hàng đợi của P hiện tại, tránh tranh chấp trên một hàng đợi chung.
- Work stealing: P hết việc sẽ chọn ngẫu nhiên P khác và lấy khoảng một nửa hàng đợi của nó, sau đó mới ngó tới global queue.
- Blocking syscall: M bị chặn sẽ nhả P, thread giám sát sysmon giao P đó cho M khác — các goroutine còn lại vẫn chạy.
- Network I/O đi qua netpoller (epoll/kqueue): goroutine bị park mà không chiếm thread nào. Đây là lý do một service Go xử lý được hàng chục nghìn kết nối với vài chục thread.
- Preemption: trước Go 1.14 chỉ hợp tác tại điểm gọi hàm, nên vòng lặp chặt không cấp phát có thể giữ P vô hạn. Từ Go 1.14 có async preemption bằng signal: sysmon thấy G chạy quá ~10ms sẽ gửi signal để cắt ngang.
Trong container: trước Go 1.25, runtime đọc số CPU của host. Pod giới hạn 1 CPU chạy trên node 64 nhân vẫn nhận GOMAXPROCS=64 → 64 P tranh nhau một phần quota, tăng context switch và bị CFS throttling, p99 latency xấu đi rõ. Cách xử lý kinh điển là thư viện go.uber.org/automaxprocs.
Go 1.25 đưa việc này vào runtime: trên Linux nó đọc giới hạn CPU của cgroup, nếu thấp hơn NumCPU() thì lấy làm mặc định, và định kỳ kiểm tra lại khi limit thay đổi. Từ bản này trở đi thường không cần chỉnh tay nữa.