Hình dung một cái công-tơ điện trả trước. Bạn nạp đúng 5 số điện cho hôm nay, và cái công-tơ cắt khi bạn dùng hết — dù nửa đêm cả khu phố tắt đèn đi ngủ, lưới điện dư thừa, thì bạn vẫn không có thêm một watt nào. Nó không phải kiểu "giờ cao điểm thì bạn xuống hạng", mà là một cái trần: hết là hết, bất kể xung quanh còn bao nhiêu. Giới hạn CPU của cgroup hoạt động đúng như vậy — và đó là chỗ tôi từng hiểu sai.

Mỗi container Docker bạn chạy với --cpus=0.5 hay --memory=512m thực ra đang dùng một cơ chế nhân Linux tên là cgroups (control groups) — thứ giới hạn và kế toán tài nguyên theo nhóm tiến trình. Nó là nền tảng của mọi container và của cả systemd. Tôi từng hình dung giới hạn CPU của cgroup giống như nice ở phần 17: một kiểu "ưu tiên thấp", chỉ làm chậm khi máy bận. Tôi đo trong Docker (Docker đặt cgroup dưới nền), và con số cho thấy nó là một thứ khác hẳn — một cái trần cứng, tuyệt đối, bó bạn ngay cả khi cả máy đang rảnh.

cgroup CPU: một hạn ngạch, không phải một ưu tiên

Giới hạn CPU của cgroup hoạt động bằng hạn ngạch (quota): file điều khiển cpu.max chứa hai số "quota period". Ví dụ 50000 100000 nghĩa là "được dùng 50.000 micro-giây CPU trong mỗi 100.000 micro-giây" — tức 50% của một lõi. Cơ chế rất thẳng thừng: tiến trình chạy cho tới khi dùng hết 50ms quota trong period, rồi bị đóng băng (throttle) cho tới đầu period sau mới được chạy tiếp. Đây là hạn ngạch tuyệt đối, không phải một mức ưu tiên tương đối.

Khác biệt với nice là mấu chốt, và nó tinh vi hơn ta tưởng. nice chỉ ảnh hưởng khi có tranh chấp — máy rảnh thì một tiến trình nice 19 vẫn dùng trọn CPU. Còn cgroup quota bó bạn bất kể máy rảnh hay bận. Tôi kiểm chứng bằng cách chạy một workload CPU thuần, đơn luồng, công cố định (khoảng 1,85 giây CPU) trong các container với --cpus khác nhau, trên một máy 10 lõi mà 9 lõi ngồi không.

Đo: bó cứng dù 9 lõi rảnh

Công cố định ~1,85s CPU, đơn luồng, máy 10 lõi (9 lõi rảnh):
  cpu.max của --cpus=0.5 : "50000 100000"  (quota 50ms / period 100ms)

  --cpus=1     : 1,85 s   (baseline)
  --cpus=0.5   : 4,06 s   (~2,2 lần) — dù 9 lõi ngồi không, vẫn bị bó
  --cpus=0.25  : 12,1 s   (~6,5 lần) — tệ hơn cả 4 lần
  --cpus=2     : 1,85 s   (đơn luồng nên thêm quota vô ích)

Với --cpus=0.5, workload mất 4,06 giây thay vì 1,85 — chậm ~2,2 lần. Điều làm tôi dừng lại: máy có 10 lõi và 9 lõi đang rảnh hoàn toàn, nhưng tiến trình vẫn bị bó ở nửa một lõi. Nó không "mượn tạm" lõi rảnh như nice cho phép; hết quota là bị đóng băng, chấm hết. Đây là bằng chứng trực tiếp: cgroup quota là trần cứng tuyệt đối, không phải ưu tiên.

Với --cpus=0.25, còn một bất ngờ khiến tôi phải nhìn lại: workload mất 12,1 giây — chậm ~6,5 lần, tệ hơn cái 4 lần mà toán học ngây thơ (1/0.25) dự đoán. Vì sao? Vì cơ chế throttle đóng băng tiến trình theo đợt dài: với quota 25ms mỗi period 100ms, tiến trình chạy 25ms rồi ngủ đông 75ms. Những đợt đóng băng dài đó không chỉ làm chậm tuyến tính — chúng thêm độ trễ (và trên môi trường ảo hóa, chi phí đánh thức lại). Nên ở hạn ngạch nhỏ, hiệu ứng còn tệ hơn tỉ lệ. Ngược lại, --cpus=2 cho workload đơn luồng chẳng giúp gì (vẫn 1,85s) — quota là trần, cho dư trần mà việc không dùng tới thì vô ích.

Còn một cái bẫy nữa lộ ra khi đo: bên trong container --cpus=0.5, lệnh nproc vẫn báo 10 — số lõi vật lý, không phản ánh hạn ngạch. Một chương trình dựng thread pool theo nproc (như thread pool phần 30 Series 25) sẽ tạo 10 luồng trong một container chỉ được nửa lõi — oversubscribe nặng.

(Môi trường đo: Docker trên host 10 lõi, workload CPU thuần đơn luồng công cố định, mỗi số lấy MIN sau warmup. Con số đổi theo máy; cái cần nhìn là hình dạng: --cpus dưới 1 bó cứng dù host rảnh, và hạn ngạch càng nhỏ càng tệ hơn tỉ lệ.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với niềm tin rằng giới hạn CPU của cgroup là một dạng "ưu tiên thấp": chỉ làm chậm khi máy bận, còn máy rảnh thì tiến trình chạy thoải mái. Con số phá tan điều đó: --cpus=0.5 bó workload xuống 2,2 lần dù 9 lõi ngồi không. Nó là hạn ngạch, không phải ưu tiên — và hai thứ này hành xử hoàn toàn khác nhau. nice là tương đối (chia phần khi tranh); cgroup quota là tuyệt đối (một cái trần bạn không vượt được, kể cả khi có thừa CPU). Nếu tin "cgroup giống nice, máy rảnh thì không sao", bạn sẽ bối rối khi thấy một container chạy chậm rề trên một host gần như không tải — rồi đi tìm nút thắt ở sai chỗ (CPU host, I/O…) trong khi thủ phạm là chính cái --cpus bạn đặt. Đây cũng là lý do một service trong Kubernetes với cpu limit thấp có thể bị throttle nặng và trễ, dù cluster còn rất nhiều CPU.

Nhưng có một niềm tin ngược lại cũng sai không kém, và tôi thấy nó nhiều hơn: "quota bó cứng thế thì cứ đặt --cpus thật cao (hoặc bỏ hẳn) cho an toàn". Không hẳn — cgroup sinh ra để cách ly và chia một máy giữa nhiều nhóm cho công bằng và an toàn. Bỏ trần đi là để một container ngốn CPU giết chết mọi container khác trên cùng host. Vấn đề không phải "đừng giới hạn", mà là đặt trần đúng theo nhu cầu thật đã đo, và nhớ rằng trần quá thấp làm chậm chính bạn ngay trên một máy trống — chứ không phải cứ nới trần là xong.

Về đến code hằng ngày

Điều đầu tiên: hiểu --cpus / cpu limit là hạn ngạch, không phải ưu tiên — và đặt nó cẩn thận. Một giới hạn CPU quá thấp sẽ làm dịch vụ của bạn chậm và trễ ngay cả khi host rảnh, vì nó bị đóng băng mỗi period. Nếu bạn thấy một container chậm bất thường mà host không tải, hãy kiểm cpu.max và thống kê throttle (cpu.stat) trước khi nghi ngờ code. Với tác vụ nhạy độ trễ, hạn ngạch nhỏ đặc biệt hại vì các đợt đóng băng dài.

Điều thứ hai: đừng dựa vào nproc (hay số lõi hệ thống) để dựng thread pool trong container. nproc báo số lõi vật lý, không phải hạn ngạch cgroup, nên bạn dễ tạo quá nhiều luồng cho quota thực có — dẫn tới oversubscription và chuyển ngữ cảnh bắt buộc. Hãy đọc hạn ngạch thật (cpu.max, hoặc các runtime hiện đại đã tự nhận), hoặc đặt số luồng theo cấu hình rõ ràng.

Chỗ này lý giải một loạt "phép màu" và "cú vấp" ở tầng cao mà thoạt nhìn khó hiểu. JVM cũ (trước Java 8u191) đọc Runtime.availableProcessors() ra số lõi host chứ không phải quota, nên một app Java trong container 0,5 lõi tự dựng thread pool và GC theo 10 lõi — bản vá -XX:+UseContainerSupport (giờ mặc định bật) chính là để đọc cpu.max thay vì nproc. Go từng cần GOMAXPROCS chỉnh tay vì cùng lý do, và cả một thư viện automaxprocs ra đời chỉ để đọc quota cgroup. Còn cái memory.max của cùng cơ chế này thì tuyệt đối hơn nữa: vượt trần là OOM kill ngay (xem phần 29), không có kiểu "chậm dần" như CPU. Cùng một bài học công-tơ-trả-trước, lặp lại ở mọi runtime chạy trong container.

Muốn tự thấy thì chạy một vòng lặp tính toán ngốn CPU trong hai container Docker: một --cpus=1, một --cpus=0.5, và bấm giờ cả hai — trên máy của bạn, dù nó có bao nhiêu lõi rảnh. Bạn sẽ thấy cái --cpus=0.5 mất gấp đôi thời gian, không phải vì thiếu lõi (còn thừa), mà vì hạn ngạch đóng băng nó nửa thời gian mỗi period. Rồi docker exec vào container --cpus=0.5 và chạy cat /sys/fs/cgroup/cpu.max (thấy 50000 100000) và nproc (vẫn thấy tất cả lõi vật lý — cái bẫy). Vài phút đó cho bạn thấy điều mà chữ "giới hạn CPU" hay bị hiểu lầm: nó không phải "ưu tiên thấp, máy rảnh thì thoải mái", mà là một cái trần cứng — bạn không vượt qua nó dù cả thế giới đang rảnh.