Bạn thuê một văn phòng trong tòa nhà mười thang máy, nhưng hợp đồng ghi công ty bạn chỉ được dùng hai. Nhân viên nhìn lên bảng thấy mười thang máy sáng đèn, cứ đinh ninh cả mười đều dùng được, rồi tám người ùa ra cùng lúc — và bảo vệ chặn lại: "công ty anh hai thang thôi". Tám người chen trong hai thang, xếp hàng dài, chậm hơn cả khi chỉ có hai người đi. Container với cgroup đúng là cái tòa nhà đó: cái bạn nhìn thấy không phải cái bạn được dùng, và tôi đã mất thời gian vì tin vào cái bảng đèn. Ở 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: nếu tôi đặt "container này chỉ được 2 CPU" thì chuyện gì xảy ra khi code tạo tám luồng bận?

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ả.

nproc nói dối, và tôi tin sái cổ

Nhìn kỹ dòng gây sốc 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.max và cpu.stat, không phải nproc hay top trên host.

Điều rút ra: 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ề đến code hằng ngày

Điều đầ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.

Điều 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_throttled và throttled_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.

Và cái bẫy "hỏi sai nguồn nên đo ra sai bản chất giới hạn" này không riêng gì CPU hay container — nó là một họ lỗi khắp mọi tầng ảo hóa. Runtime.availableProcessors() của JVM đời cũ, hay os.cpus() của Node, đọc số lõi vật lý chứ không đọc quota cgroup, nên ForkJoinPool.commonPool mặc định phình quá cỡ trong pod nhỏ — đúng cái bẫy nproc. runtime.NumCPU() của Go cũng vậy nếu không có automaxprocs. Ngay cả CSDL: một Postgres trong container thấy RAM của cả host qua /proc/meminfo (không phải memory.max), nên nếu bạn chỉnh shared_buffers theo RAM host thì nó xin nhiều hơn cgroup cho phép và ăn OOM-kill. Mẫu số chung: trong môi trường ảo hóa, cái mà API "tài nguyên hệ thống" cổ điển trả về là của máy vật lý, không phải giới hạn thật của bạn — phải đọc đúng nơi công bố giới hạn (cgroup) mới ra con số dùng được. Gói lại bằng số: cgroup cpu.max bó tổng CPU cả nhóm vào một quota (2 lõi = 200000/100000) bất kể máy 10 lõi — tám luồng chỉ đạt 4 giây-lõi thay vì 16, bị đóng băng 20/21 chu kỳ (~12 giây throttled) — trong khi nproc vẫn báo 10; 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.

Muốn tự thấy thì 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.