bài về rlimit ta thấy giới hạn tài nguyên cho một tiến trình. Nhưng cả một container Docker hay pod Kubernetes cần giới hạn cho cả nhóm tiến trình — và cơ chế đó là cgroup. Câu hỏi thực tế nhất mà mọi lập trình viên chạy trên container gặp phải: nếu tôi đặt "container này chỉ được 2 CPU" thì chuyện gì xảy ra khi code của tôi tạo tám luồng bận? Tôi đo trong Docker, và con số phơi bày một cú lừa mà tôi từng mắc.

cgroup giới hạn CPU

cgroup bó CPU theo nhóm, bằng quota

cgroup (control group) là cơ chế của nhân để giới hạn và đo tài nguyên cho một nhóm tiến trình. Docker, Kubernetes, systemd đều dùng nó. Với CPU, cgroup v2 dùng file cpu.max chứa hai số: quota / period. Ví dụ 200000 100000 nghĩa là: trong mỗi chu kỳ (period) 100.000 micro giây (100ms), cả nhóm được dùng tối đa 200.000 micro giây thời gian CPU — tức 200ms CPU mỗi 100ms tường, bằng hai lõi. Con số này là tổng cho cả nhóm, bất kể máy có bao nhiêu lõi hay nhóm chạy bao nhiêu luồng.

Cơ chế thực thi rất thẳng: khi nhóm dùng hết quota trong một chu kỳ, nhân đóng băng (throttle) mọi luồng của nhóm cho tới đầu chu kỳ sau. File cpu.stat ghi lại: nr_periods (số chu kỳ đã qua), nr_throttled (số chu kỳ bị đóng băng), và throttled_usec (tổng thời gian bị đóng băng). Tôi đo bằng cách chạy tám luồng bận CPU trong hai giây, một lần không giới hạn và một lần với docker run --cpus=2 (đặt cpu.max thành 200000 100000).

Đo: mười lõi trong tầm mắt, hai lõi trong túi

Không giới hạn (cpu.max = "max 100000"):
  nproc thấy = 10 lõi
  8 luồng CPU-bound, 2s tường -> dùng 16,0 giây-lõi CPU  (~8 lõi song song)
  nr_throttled = 0

--cpus=2 (cpu.max = "200000 100000"):
  nproc thấy = 10 lõi   (!)
  8 luồng CPU-bound, 2s tường -> dùng 4,0 giây-lõi CPU   (~2 lõi)
  nr_throttled = 20 / 21 chu kỳ, throttled_usec ~12 giây

Không giới hạn, tám luồng tiêu thụ 16 giây-lõi CPU trong 2 giây tường — tức chúng chạy trên ~8 lõi song song, không bị đóng băng lần nào. Với --cpus=2, cùng tám luồng đó chỉ dùng được 4 giây-lõi — đúng hai lõi — dù trên máy còn tám lõi rảnh. Và cpu.stat kể rõ vì sao: trong 20 trên 21 chu kỳ, nhóm dùng hết quota và bị nhân đóng băng phần còn lại của chu kỳ, tổng cộng ~12 giây thời gian bị treo. Các luồng của tôi không chậm vì thiếu việc hay thiếu lõi — chúng bị cgroup bóp lại theo lịch, đóng băng rồi thả, đóng băng rồi thả.

Một lần tôi đo hớ: nproc nói dối, và tôi tin

Nhìn kỹ dòng in đậm nhất: với --cpus=2, nproc vẫn báo 10 lõi. Đây chính là cú lừa từng làm tôi mất thời gian. Chương trình hỏi hệ thống "tôi có bao nhiêu lõi?" và nhận về 10 — nên nếu tôi đặt một thread pool cỡ bằng nproc (một mặc định cực phổ biến), tôi tạo mười luồng bận để chạy trên một ngân sách chỉ hai lõi. Kết quả: mười luồng chen chúc trong quota hai-lõi, tranh nhau, chuyển ngữ cảnh liên tục, và cả nhóm bị throttle — chậm hơn nếu tôi chỉ tạo hai luồng.

Cái bẫy chẩn đoán còn tệ hơn: một ứng dụng chạy "chậm" trong container, tôi mở máy chủ ra xem thì CPU máy còn rảnh chán, top trên host thấy các lõi nhàn. Phản xạ tự nhiên là kết luận "không phải do CPU, chắc do I/O hay code". Nhưng sự thật nằm trong cpu.stat của cgroup: nr_throttled cao nghĩa là ứng dụng liên tục bị đóng băng vì đụng quota — nó chậm vì bị bóp, ngay giữa lúc máy rảnh. Đây là "hai con số mâu thuẫn": máy rảnh nhưng app nghẽn CPU, và mâu thuẫn đó chỉ được hóa giải khi đọc đúng nguồn — cpu.maxcpu.stat, không phải nproc hay top trên host.

Bài học đo lường: trong container, nproc (và cả bộ nhớ, tải) không phản ánh giới hạn cgroup. Hỏi sai nguồn thì đo ra sai bản chất. Số lõi "khả dụng" thật của bạn không phải cái nproc nói, mà là quota / period trong cpu.max.

Trần cứng hay chia tỉ lệ, và không chỉ CPU

cpu.max là một trần cứng: dùng hết quota là bị đóng băng, kể cả khi các lõi máy đang rảnh — như phép đo cho thấy. Nhưng cgroup còn một cách chia CPU mềm hơn: cpu.weight. Thay vì đặt trần tuyệt đối, nó cho mỗi nhóm một trọng số, và CPU được chia theo tỉ lệ trọng số chỉ khi có tranh chấp — giống hệt vai trò của nice giữa các tiến trình mà bài CFS đã đo, nhưng ở cấp nhóm. Khác biệt quan trọng: một nhóm chỉ có cpu.weight (không có cpu.max) sẽ dùng được cả máy khi máy rảnh, chỉ bị ép xuống phần của mình khi các nhóm khác cũng cần — nên nó không gây throttle vô lý lúc máy nhàn. Nhiều sự cố "throttle dù host rảnh" đến từ việc đặt cpu.max (giới hạn cứng) khi thật ra chỉ cần cpu.weight (ưu tiên tương đối).

Và cgroup không chỉ giới hạn CPU. memory.max bó bộ nhớ cả nhóm — vượt là kích hoạt OOM killer trong nhóm đó (chủ đề bài sau); io.max bó băng thông đĩa; pids.max bó số tiến trình. Toàn bộ container của bạn sống trong một cái lồng cgroup như vậy, và mọi "giới hạn kỳ lạ" bạn gặp trong container thường là một file trong /sys/fs/cgroup đang nói, chứ không phải phần cứng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: đặt cỡ thread pool theo quota cgroup, không theo nproc. Nhiều thư viện mặc định tạo số luồng bằng số lõi hệ thống — hỏng trong container bị giới hạn. Runtime hiện đại đã học điều này: JVM mới đọc cpu.max để tính availableProcessors, Go có GOMAXPROCS nên đặt theo quota (thư viện như automaxprocs làm tự động). Khi tự viết pool, hãy đọc /sys/fs/cgroup/cpu.max để biết ngân sách thật, đừng tin nproc.

Hệ quả thứ hai: hiểu throttling để chẩn đoán "chậm bí ẩn". Khi một dịch vụ trong container chậm hoặc trễ đuôi cao mà host rảnh, kiểm cpu.stat: nr_throttledthrottled_usec tăng đều là bằng chứng nó bị bóp. Cách chữa là nâng giới hạn CPU của container (hay pod), hoặc giảm số luồng cho khớp quota — không phải nâng cấp máy. Đây là một trong những sự cố phổ biến nhất của ứng dụng chạy trên Kubernetes: đặt cpu limit quá thấp làm dịch vụ bị throttle dù cụm còn thừa CPU.

Hệ quả thứ ba là bài học bao trùm của cả nhánh giới hạn tài nguyên: giới hạn là chính sách, không phải vật lý, và bạn phải đọc đúng nơi công bố nó. Con số mang theo: cgroup cpu.max bó tổng CPU của cả nhóm vào một quota (2 lõi) bất kể máy có bao nhiêu lõi — tám luồng chỉ đạt 4 giây-lõi thay vì 16, bị đóng băng 20/21 chu kỳ — trong khi nproc vẫn báo 10 lõi; app "chậm" trong container dù host rảnh thường là bị throttle, chẩn đoán bằng cpu.stat chứ không bằng nproc/top. Trên container, giới hạn thật nằm ở cgroup, không ở phần cứng bạn nhìn thấy.

Thử ba mươi giây

Nếu bạn có một tiến trình chạy trong Docker, tìm cgroup của nó và đọc hai file: cat /sys/fs/cgroup/cpu.max (bên trong container) cho bạn quota period — nếu quota là một số (không phải max), chia nó cho period để ra số lõi thật bạn được dùng. Rồi cat /sys/fs/cgroup/cpu.stat và để ý nr_throttled: nếu nó tăng theo thời gian khi ứng dụng bận, container của bạn đang bị bóp CPU. So con số lõi-thật đó với nproc — nếu chúng khác nhau, bạn vừa tìm ra lý do vì sao thread pool cỡ-nproc của mình hoạt động không như mong đợi, và vì sao "thêm lõi cho máy" không giúp gì khi cái nghẽn là một con số trong cpu.max.