Hình dung một siêu thị có nhiều quầy thanh toán. Bạn phát cho khách những tấm thẻ ưu tiên — thẻ vàng được phục vụ trước thẻ thường. Nhưng nếu mỗi khách bước tới một quầy riêng, đang trống, thì tấm thẻ chẳng có ý nghĩa gì: chẳng ai phải chờ ai, ai cũng được tính tiền ngay. Thứ hạng ưu tiên chỉ bắt đầu quyết định khi nhiều khách dồn vào cùng một quầy và phải xếp hàng. nice của Linux là đúng tấm thẻ ưu tiên đó, và bài này đo ra một điều bất ngờ: nó im lìm cho tới khi bạn ép các tiến trình chen chung một hàng.

CFS chia CPU giữa các tiến trình. Ta đo xem nó chia theo quy tắc nào — và tìm ra một điều kiện mà không tài liệu nào nhấn mạnh.

CFS chia đều

Container giới hạn 1 CPU:

1 tiến trình   93,3%
2 tiến trình   ~50% mỗi cái
4 tiến trình   ~30% mỗi cái
8 tiến trình    12% mỗi cái

Mỗi tiến trình được 1/N. Đây là điều CFS hứa và nó giữ đúng. Tên đầy đủ là Completely Fair Scheduler, và ý tưởng đơn giản: mỗi tiến trình có một bộ đếm "thời gian ảo đã dùng"; bộ lập lịch luôn chạy tiến trình có bộ đếm nhỏ nhất. Kết quả là mọi tiến trình tiến gần bằng nhau.

nice không có tác dụng gì

Hai tiến trình, không ghim nhân:

nice 0 vs 0    49,8% / 50,2%
nice 0 vs 5    49,8% / 50,2%
nice 0 vs 10   50,2% / 49,8%
nice 0 vs 19   50,2% / 49,8%

nice 19 — mức ưu tiên thấp nhất có thể — và nó vẫn nhận đúng một nửa. Tôi tưởng phép đo hỏng. Đo lại bằng hiệu số thời gian CPU trong /proc/<pid>/stat thay vì đọc %CPU của ps, kết quả không đổi.

Cho tới khi ghim cả hai vào cùng một nhân

nice 0 vs 0    49,9% / 50,1%
nice 0 vs 5    75,4% / 24,6%
nice 0 vs 10   90,4% /  9,6%
nice 0 vs 19   98,7% /  1,3%

Bây giờ nó hoạt động đúng như tài liệu. Và tỉ lệ khớp gần như chính xác với bảng trọng số của CFS:

nice trọng số Tỉ lệ so với nice 0 Đo được
0 1024 — —
5 335 3,06 3,07
10 110 9,31 9,4
19 15 68 76

Vì sao: nice là trọng số trong một hàng đợi chạy

Mỗi nhân CPU có hàng đợi chạy riêng. nice quyết định tiến trình được bao nhiêu phần của hàng đợi đó — tấm thẻ ưu tiên chỉ có nghĩa trong hàng người xếp ở một quầy.

Hai tiến trình ở hai nhân khác nhau không nằm cùng hàng đợi. Không có gì để cân — mỗi cái là tiến trình duy nhất trên nhân của nó, đứng một mình ở một quầy trống, và nó nhận 100% nhân đó. Trong phép đo, cả hai bị giới hạn bởi hạn ngạch cgroup (--cpus 1), và cơ chế hạn ngạch bóp CPU theo chu kỳ mà không nhìn nice. Nên hai tiến trình chia đôi hạn ngạch bất kể ưu tiên.

Kết luận thực dụng:

Trên máy nhiều nhân, nice chỉ có tác dụng khi số tiến trình sẵn sàng chạy vượt số nhân.

Máy 16 nhân với 4 tiến trình nặng: nice không làm gì cả. Cùng máy đó với 40 tiến trình: nice bắt đầu quyết định. Đây là lý do nice thường bị coi là "không hiệu quả" — người ta thử nó trên hệ thống chưa bão hoà và thấy không có gì đổi.

Và trong container, nice càng ít tác dụng

Với --cpus, Docker đặt hạn ngạch cgroup. Hạn ngạch là một cái trần cứng theo chu kỳ 100 ms — đúng cơ chế đo trong sê-ri Kubernetes phần 44. Muốn chia tỉ lệ giữa các container, dùng cpu.weight (cgroup v2) thay vì hạn ngạch:

docker run --cpu-shares 512 ...    # nua phan so voi mac dinh 1024

--cpu-shares là trọng số tương đối và chỉ có tác dụng khi CPU bão hoà — cùng điều kiện với nice. --cpus là trần tuyệt đối và luôn có tác dụng. Hai thứ này hay bị nhầm, và chúng giải quyết hai bài toán khác nhau: --cpus để giới hạn, --cpu-shares để ưu tiên.

Hai lớp lập lịch trên CFS

CFS chỉ áp cho lớp SCHED_OTHER — mặc định. Có hai lớp thời gian thực đứng trên nó:

chrt -f 50 ./chuong-trinh    # SCHED_FIFO, uu tien 50
chrt -r 50 ./chuong-trinh    # SCHED_RR

Tiến trình thời gian thực luôn chạy trước mọi tiến trình CFS, bất kể nice. Đây là công cụ thật cho công việc nhạy độ trễ — nhưng một vòng lặp bận ở SCHED_FIFO có thể treo cả máy, vì không gì đẩy nó ra được. Ngược lại, SCHED_IDLE chạy chỉ khi không còn gì khác:

chrt -i 0 ./cong-viec-nen

Cái này an toàn hơn nice 19 nhiều cho công việc nền thật sự — vì nice 19 vẫn nhận phần, còn SCHED_IDLE thì gần như không.

Mẫu số chung: ưu tiên tương đối chỉ hiện khi tài nguyên khan, và trần tuyệt đối thì luôn hiện

Bài này gói một phân biệt mà người ta nhầm ở mọi tầng: một cơ chế ưu tiên tương đối (nice, share, weight) chỉ đổi được kết quả khi tài nguyên đang bị tranh; còn một cái trần tuyệt đối (quota, limit) thì có tác dụng bất kể tải. Trộn hai thứ này là nguồn của vô số cấu hình "chỉnh mà không thấy gì".

Trong thế giới Java, đây đúng là thread priority so với một Semaphore/rate limiter: Thread.setPriority là ưu tiên tương đối, chỉ biểu hiện khi CPU bão hoà và luồng phải tranh — còn semaphore là trần cứng, chặn đúng N luồng bất kể hệ rảnh hay bận. Người ta chỉnh priority rồi ngạc nhiên vì chẳng đổi gì, vì hệ chưa hề nghẽn.

Trong Go, GOMAXPROCS là một cái trần (số P chạy song song, luôn có hiệu lực) trong khi trọng số bạn dựng bằng weighted-select giữa các channel là ưu tiên tương đối chỉ hiện khi các nhánh đều có việc chờ; đặt nhầm loại là chỉnh sai công cụ cho bài toán.

Còn ở cơ sở dữ liệu, đây là khác biệt giữa max_connections/statement quota (trần cứng, luôn chặn) và resource-group weight / priority (tương đối, chỉ phân xử khi hệ nghẽn). Nâng weight cho một workload lúc DB đang rảnh không làm nó nhanh hơn; nó chỉ thắng khi có tranh chấp thật.

Mẫu số chung: ưu tiên tương đối là quy tắc phân xử khi thiếu — nó vô hình khi tài nguyên còn dư; còn trần tuyệt đối là quy tắc chặn — nó luôn có hiệu lực. Chỉnh cái tương đối lúc chưa nghẽn thì không thấy gì, và dùng nó để 'giới hạn' thì không bao giờ chặn được. Nên trước khi chỉnh một tham số ưu tiên/giới hạn, câu hỏi đúng là "đây là trọng số tương đối hay trần tuyệt đối, và tài nguyên có đang thật sự bị tranh không" — vì --cpus để giới hạn và nice/--cpu-shares để ưu tiên là hai bài toán khác nhau, giải bằng hai công cụ khác nhau.

Muốn tự xem nice có tác dụng gì trên máy của bạn không:

# 1. so tien trinh san sang chay so voi so nhan
nproc
vmstat 1 3 | tail -1 | awk '{print "r=" $1}'

# 2. lop lap lich va nice cua cac tien trinh nang nhat
ps -eo pid,ni,cls,pcpu,comm --sort=-pcpu | head -10

Cột r nhỏ hơn số nhân nghĩa là hệ thống chưa bão hoà, và nice sẽ không đổi gì — đừng mất công chỉnh nó. Cột cls là lớp lập lịch: TS là CFS thường, FF/RR là thời gian thực, IDL là nền. Thấy FF hoặc RR ở một tiến trình không cố ý là điều đáng hỏi ngay — nó đứng trên tất cả những gì còn lại.

Phần sau: cgroup v2 — cách nhân thực sự giới hạn tài nguyên.