Bạn deploy một service Go lên Kubernetes với giới hạn cpu: "2", chạy trên node 64 nhân. Bên trong pod, runtime.GOMAXPROCS(0) trả về... 64. Runtime của Go dựng 64 P để chạy song song, nhưng nền tảng chỉ cấp cho pod của bạn 2 CPU-giây mỗi giây. Kết quả là một mớ P tranh nhau lượng CPU nhỏ xíu. Đây là một trong những cạm bẫy vận hành phổ biến nhất của Go, và để hiểu đúng nó ta phải phân biệt hai kiểu giới hạn CPU mà đa số người nhầm là một.

Hai kiểu "giới hạn CPU" hoàn toàn khác nhau

Go đặt GOMAXPROCS mặc định bằng runtime.NumCPU(). Vấn đề nằm ở chỗ "số CPU" nghĩa là gì trong một container, vì có hai cơ chế giới hạn CPU khác hẳn nhau:

  • cpuset / affinity (docker --cpuset-cpus, taskset, cpuset cgroup): quyết định tiến trình được phép chạy trên những CPU nào. Đây là một mặt nạ (mask) — ví dụ "chỉ CPU 0 và 1".
  • CFS bandwidth quota (docker --cpus=2, cpu.max quota, Kubernetes limits.cpu): giới hạn tổng thời gian CPU tiến trình được dùng mỗi chu kỳ. Tiến trình vẫn "thấy" và chạy được trên mọi CPU, nhưng khi dùng hết quota trong chu kỳ, nó bị throttle (đình lại) tới chu kỳ sau.

Điểm mấu chốt: runtime.NumCPU() của Go đọc mặt nạ affinity (sched_getaffinity), không đọc CFS quota. Nên Go hiểu kiểu thứ nhất mà mù kiểu thứ hai.

Ảnh chụp đoạn mã Go nền tối minh hoạ GOMAXPROCS mặc định bằng số CPU Go nhìn thấy, hàm main in runtime NumCPU đọc affinity mask và runtime GOMAXPROCS bằng NumCPU mặc định, workload đo hậu quả 200 goroutine cùng quay vòng CPU-bound đọc getrusage đếm số lần chuyển ngữ cảnh cưỡng bức nivcsw, hàm burn nhận iter quay vòng xor dịch bit, bảng hai kiểu giới hạn CPU của container cpuset affinity docker cpuset-cpus taskset giới hạn chạy trên CPU nào Go CÓ nhận biết vì NumCPU đọc sched_getaffinity, CFS bandwidth quota docker cpus bằng 2 cpu max quota giới hạn thời gian CPU Go 1.23 KHÔNG đọc vẫn thấy đủ 10 CPU host đặt GOMAXPROCS quá cao, vì sao quan trọng đa số nền tảng Kubernetes limits ECS dùng CFS quota không phải cpuset nên pod giới hạn 2 CPU trên node 64 nhân khiến Go đặt GOMAXPROCS bằng 64 thừa mứa P tranh nhau 2 CPU thực

Hình 1: GOMAXPROCS = NumCPU = số CPU trong mặt nạ affinity. cpuset thì Go hiểu; CFS quota thì Go 1.23 bỏ qua — mà K8s/ECS dùng chính CFS quota.

Đo thật: Go hiểu affinity

Trước tiên chứng minh Go có nhận biết affinity. Ghim tiến trình vào 2 CPU bằng taskset -c 0,1 rồi hỏi lại NumCPU:

fmt.Println("NumCPU   =", runtime.NumCPU())
fmt.Println("GOMAXPROCS =", runtime.GOMAXPROCS(0))

Ảnh chụp bảng kết quả đo thật nền tối trong container Go 1.23 linux arm64 host 10 core về GOMAXPROCS và container, phần 1 Go nhận biết affinity cpuset taskset chạy bình thường NumCPU 10 GOMAXPROCS 10 taskset ghim 2 CPU NumCPU 2 GOMAXPROCS 2 giới hạn bằng affinity thì Go tự chỉnh đúng không phải vấn đề, phần 2 hậu quả khi GOMAXPROCS quá cao so với CPU thực mô phỏng CFS quota taskset ghim 2 CPU nhưng ép GOMAXPROCS 10 GOMAXPROCS 10 thời gian 690ms chuyển ngữ cảnh cưỡng bức nivcsw 549 và 543 GOMAXPROCS 2 thời gian 686ms nivcsw 135 và 151 thời gian gần như nhau CPU-bound thuần nhưng chuyển ngữ cảnh cưỡng bức gấp 4 lần 545 so với 140 chi phí lập lịch phí phạm, phần 3 cách sửa đặt GOMAXPROCS khớp quota GOMAXPROCS 2 chương trình NumCPU 10 GOMAXPROCS 2 biến môi trường GOMAXPROCS hoặc runtime GOMAXPROCS 2 hoặc thư viện uber automaxprocs đọc cpu max tự đặt Go 1.25 đã tự nhận biết CFS quota mặc định Go 1.23 thì chưa

Hình 2: taskset -c 0,1 → NumCPU=2, GOMAXPROCS=2 — Go tự chỉnh đúng theo affinity. Nhưng ép GOMAXPROCS=10 trên 2 CPU thực (mô phỏng CFS quota) làm chuyển ngữ cảnh cưỡng bức gấp 4 lần.

Kết quả: chạy bình thường NumCPU=10; dưới taskset -c 0,1 thì NumCPU=2, GOMAXPROCS=2. Go tự chỉnh đúng theo affinity — đây không phải chỗ có vấn đề. Nếu bạn giới hạn container bằng --cpuset-cpus, Go làm đúng.

Đo thật: hậu quả khi GOMAXPROCS thừa

Vấn đề thật nằm ở CFS quota. Tiếc là bên trong container này cgroup ở chế độ chỉ-đọc nên tôi không đặt được quota thật để đo. Nhưng tôi mô phỏng triệu chứng — GOMAXPROCS cao hơn số CPU thực sự chạy — bằng cách ghim 2 CPU (taskset -c 0,1) nhưng ép GOMAXPROCS=10, đúng như điều xảy ra dưới --cpus=2 với mặc định của Go. Workload là 200 goroutine CPU-bound, đo số lần chuyển ngữ cảnh cưỡng bức (nivcsw từ getrusage):

var ru0, ru1 syscall.Rusage
syscall.Getrusage(syscall.RUSAGE_SELF, &ru0)
// ... chạy 200 goroutine burn() ...
syscall.Getrusage(syscall.RUSAGE_SELF, &ru1)
fmt.Println("nivcsw =", ru1.Nivcsw-ru0.Nivcsw)  // chuyển ngữ cảnh cưỡng bức
  • GOMAXPROCS=10 trên 2 CPU: thời gian ~690 ms, nivcsw ≈ 545.
  • GOMAXPROCS=2 trên 2 CPU: thời gian ~690 ms, nivcsw ≈ 140.

Nói thẳng và trung thực: với workload CPU-bound thuần này, thời gian gần như không đổi — vì tổng công việc là cố định và hệ điều hành vẫn chia đều 2 CPU cho dù có 10 hay 2 luồng. Nhưng số lần chuyển ngữ cảnh cưỡng bức gấp khoảng 4 lần (545 so với 140). Đó là chi phí lập lịch phí phạm thuần túy: 10 luồng OS tranh nhau 2 CPU, bị kernel ngắt qua lại liên tục, kéo theo mất mát cache mỗi lần chuyển.

Hậu quả trên wall-clock nhỏ ở đây, nhưng nó lớn hơn hẳn với các workload thực tế: nhạy độ trễ (tail latency phình vì goroutine bị throttle giữa chừng khi hết quota), nhiều GC (thừa P nghĩa là thừa goroutine assist tranh CPU khi thu gom), hoặc tranh khoá. Đây là lý do các đội vận hành Go trên K8s coi việc chỉnh GOMAXPROCS là chuẩn mực.

Cách sửa

Đặt biến môi trường GOMAXPROCS. Cách đơn giản nhất, không đổi code: đặt GOMAXPROCS=2 khớp với limits.cpu. Đo thật: GOMAXPROCS=2 ./chương-trình cho NumCPU=10 nhưng GOMAXPROCS=2.

Dùng uber-go/automaxprocs. Import _ "go.uber.org/automaxprocs" — thư viện này đọc cpu.max (CFS quota) lúc khởi động và tự đặt GOMAXPROCS khớp. Đây là giải pháp phổ biến nhất cho Go ≤ 1.24 trên K8s.

Nâng lên Go 1.25+. Từ Go 1.25, runtime tự nhận biết CFS quota cgroup theo mặc định và đặt GOMAXPROCS cho khớp (điều chỉnh được qua GODEBUG=containermaxprocs). Nếu đang ở Go 1.23 như container này, hành vi đó chưa có sẵn — phải tự lo.

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

Đừng đặt GOMAXPROCS quá thấp. Nếu limits.cpu là phân số (ví dụ 500m = 0,5 CPU), đặt GOMAXPROCS=0 là không hợp lệ; tối thiểu nên là 1. Và nếu request/limit chênh nhau nhiều, đặt theo limit có thể bỏ phí lúc node rảnh. Cân nhắc đặt theo request nếu bạn ưu tiên thông lượng khi có tài nguyên dư.

GOMAXPROCS không phải giới hạn số goroutine. Nó chỉ là số P (số goroutine chạy song song tối đa). Bạn vẫn spawn hàng nghìn goroutine như thường; chúng chỉ được ghép lên số P này. Giảm GOMAXPROCS không giảm khả năng đồng thời, chỉ khớp mức song song với CPU thực.

Đo trước khi chỉnh trên workload cụ thể. Như phép đo cho thấy, hại của GOMAXPROCS thừa phụ thuộc bản chất workload — CPU-bound thuần ít bị, còn nhạy-độ-trễ/nhiều-GC bị nặng. Đừng chỉnh mù; đo tail latency và mức throttle (container_cpu_cfs_throttled_periods_total) trước và sau.

Ba ý mang về

  1. Có hai kiểu giới hạn CPU khác nhau: affinity (cpuset/taskset) mà Go nhận biết qua NumCPU (đo thật taskset → NumCPU=2), và CFS bandwidth quota (--cpus, K8s limits.cpu) mà Go 1.23 bỏ qua — mà K8s lại dùng chính kiểu thứ hai.
  2. GOMAXPROCS thừa gây lãng phí lập lịch: đo thật, GOMAXPROCS=10 trên 2 CPU cho số chuyển ngữ cảnh cưỡng bức gấp ~4 lần (545 so với 140); wall-clock của CPU-bound thuần ít đổi, nhưng workload nhạy độ trễ/nhiều GC bị hại nặng hơn.
  3. Sửa bằng cách đặt GOMAXPROCS khớp quota: biến môi trường GOMAXPROCS, thư viện uber-go/automaxprocs đọc cpu.max, hoặc nâng lên Go 1.25+ (tự nhận biết CFS quota mặc định) — và luôn đo tail latency, throttle trước/sau khi chỉnh.

Phần sau ta đi sâu hơn vào chính cấu trúc mà GOMAXPROCS điều khiển: Phần sau mổ xẻ hàng đợi chạy cục bộ (mỗi P) và hàng đợi toàn cục — vì sao có hai loại, khi nào goroutine rơi vào cái nào, và ảnh hưởng tới độ trễ lập lịch.