Bốn mươi tám phần trước đo từng thứ một: bộ nhớ, đĩa, tiến trình, lời gọi hệ thống. Bài này làm ngược lại — dựng sẵn bốn sự cố khác nhau, tất cả đều biểu hiện thành đúng một câu "dịch vụ chậm", rồi xem chỉ số nào tách được chúng ra. Tôi cố tình không nhìn vào nguyên nhân khi đọc số, để biết chỉ số nào thật sự chẩn đoán được.
Bảng số liệu
Công việc thử nghiệm là một vòng tính toán thuần CPU, mất 1,85 s khi không bị cản trở gì. Bốn hoàn cảnh, mỗi hoàn cảnh chạy 3 lần trong container debian:12-slim với cgroup v2:
| A. Bình thường | B. --cpus=0.3 |
C. 12 tiến trình tranh CPU | D. Ghi đĩa liên tục | |
|---|---|---|---|---|
| Thời gian | 1,851 s | 11,379 s | 4,623 s | 1,965 s |
| Chậm gấp | 1,00× | 6,15× | 2,50× | 1,06× |
nr_throttled |
0 | 114 | 0 | 0 |
throttled |
0 | 7 952 ms | 0 | 0 |
cpu.pressure some |
0,12 ms | 7 886 ms | 1 395 ms | 0,41 ms |
io.pressure some |
0 | 0 | 0 | 306 ms |
nproc |
10 | 10 | 10 | 10 |
loadavg |
0,40–2,82 | 0,31–2,20 | 1,33–3,14 | 1,38–3,13 |
Hai hàng cuối là hai hàng vô dụng, và tôi để chúng lại chính vì thế.
Điều đáng nhớ
Nói lại theo cách khác:
Ba sự cố khác nhau về bản chất cho ra cùng một lời phàn nàn. B chậm 6,15 lần, C chậm 2,50 lần — nhìn từ phía người dùng thì cả hai chỉ là "hôm nay hệ thống ì". Không có cách nào phân biệt từ bên ngoài.
Nhưng dấu vân tay bên trong thì tách bạch tuyệt đối. B có nr_throttled = 114 còn C có nr_throttled = 0; ngược lại C có cpu.pressure cao mà không hề bị bóp. D thì cả hai chỉ số CPU đều gần 0 trong khi io.pressure là 306 ms. Không có trường hợp nào mập mờ giữa hai cột.
Và hai công cụ mà ai cũng gõ đầu tiên lại là hai công cụ vô dụng nhất. nproc trả về 10 ở cả bốn cột, kể cả khi container chỉ được cấp 0,3 nhân. loadavg còn tệ hơn: nó không đo container mà đo cả máy chủ, nên ở cột A — hoàn cảnh không có tải gì thêm — vẫn có lần nó hiện 2,82, cao hơn cả cột B đang bị bóp nghẹt.
Vì sao
nproc đọc số CPU mà nhân nhìn thấy, còn hạn mức của container nằm ở chỗ khác hoàn toàn: cpu.max. Trong kịch bản B, cpu.max là 30000 100000 — ba mươi mili giây CPU cho mỗi chu kỳ một trăm mili giây. Nhân vẫn có mười nhân và vẫn nói với bạn là có mười nhân; nó chỉ không cho tiến trình của bạn dùng quá 0,3 nhân trên trung bình. Đây là lý do vì sao các thư viện tự chọn số luồng theo nproc hay tạo ra mười luồng trong một container chỉ được cấp một phần ba nhân, rồi tất cả cùng chờ nhau.
loadavg thì không thuộc về cgroup nào cả. Nó là số của toàn bộ nhân, và trong container nó vẫn là số của toàn bộ nhân. Bằng chứng nằm ngay trong bảng: các lần chạy của tôi nối tiếp nhau, và tải còn sót lại từ kịch bản trước vẫn hiện trong loadavg của kịch bản sau. Đó là lý do cột A có dải rộng từ 0,40 tới 2,82 dù nó không hề thay đổi gì.
Hai chỉ số có ý nghĩa nằm trong /sys/fs/cgroup:
cpu.statđếm chuyện bóp hạn mức.nr_throttledlà số chu kỳ mà cgroup bị chặn giữa chừng,throttled_useclà tổng thời gian bị chặn. Nó lớn hơn 0 chỉ khi có hạn mức và bạn đang chạm trần. Đây là một chẩn đoán dứt khoát, không phải một dấu hiệu gợi ý.cpu.pressurevàio.pressure(PSI) đếm thời gian có việc muốn chạy nhưng phải chờ. Chúng phân biệt được "chờ CPU" với "chờ đĩa", điều mà%CPUkhông làm được.
Ba con số này cộng lại thành cây quyết định trong sơ đồ, và nó đọc theo đúng thứ tự: bị bóp trước, tranh giành sau, nghẽn I/O sau nữa, còn lại mới là lỗi của mã.
Bị bóp còn đắt hơn tỷ lệ bị bóp
Đây là phần tôi không chờ đợi. Ở kịch bản B, hạn mức là 0,3 nhân nên tôi tưởng công việc 1,85 s sẽ mất 1,85 / 0,3 ≈ 6,2 s. Thực tế nó mất 11,05 s.
Đo usage_usec thì thấy lý do: cùng một công việc tiêu thụ 3,34 s CPU khi bị bóp, so với 1,83 s khi tự do. Tổng thời gian khớp chính xác — 3,34 s chạy cộng 7,60 s bị chặn bằng đúng 10,94 s. Không phải công việc bị kéo dài, mà là nó tốn nhiều CPU hơn để làm cùng một việc.
Quét bốn mức hạn mức cho thấy đây là hiệu ứng có mức độ, không phải nhiễu:
--cpus |
Thời gian | CPU thực tiêu thụ | Hệ số CPU |
|---|---|---|---|
| 1,0 | 1,834 s | 1,833 s | 1,00× |
| 0,7 | 2,666 s | 1,891 s | 1,03× |
| 0,5 | 4,128 s | 2,087 s | 1,13× |
| 0,3 | 11,046 s | 3,342 s | 1,81× |
Bóp càng chặt thì mỗi giây CPU càng làm được ít việc. Ở mức 0,3, gần một nửa lượng CPU được cấp bị tiêu vào chi phí của chính việc bị ngắt quãng.
Nghĩa là gì trong thực tế
- Khi có người báo "dịch vụ chậm", chỉ số đầu tiên cần xem là
nr_throttled. Nếu nó tăng, mọi giờ bỏ ra để tối ưu mã đều là giờ phí — hãy sửa hạn mức. Đọc bằng một lệnh:cat /sys/fs/cgroup/cpu.stat. - Đừng để thư viện tự chọn số luồng theo
nproctrong container. Nó sẽ đếm nhầm và tạo thừa luồng. Khai tường minh theo hạn mức thật. - Đừng dùng
loadavgđể chẩn đoán bên trong container. Nó nói về hàng xóm chứ không nói về bạn. - Đặt hạn mức quá chặt là tự bắn vào chân hai lần: vừa được ít CPU hơn, vừa dùng lượng CPU đó kém hiệu quả hơn. Nếu buộc phải giới hạn, ở mức 0,7 chi phí phụ chỉ 3%, còn ở 0,3 nó là 81%.
- Bốn dòng đáng dán vào sổ tay:
cat /sys/fs/cgroup/cpu.stat # nr_throttled, throttled_usec
cat /sys/fs/cgroup/cpu.max # han muc thuc su
cat /sys/fs/cgroup/cpu.pressure # cho CPU
cat /sys/fs/cgroup/io.pressure # cho dia
Chỗ tôi không kết luận được
Tôi đo được hiệu ứng "bị bóp thì tốn thêm CPU", nhưng không chốt được nguyên nhân. Công việc thử nghiệm là một chuỗi tính toán thuần trên thanh ghi, gần như không chạm bộ nhớ, nên lời giải thích quen thuộc "bộ đệm nguội sau mỗi lần bị chặn" không đứng vững ở đây. Giả thuyết còn lại của tôi là bộ điều tần hoặc việc phân bổ nhân: một tiến trình chỉ chạy 30 ms trong mỗi 100 ms có thể bị hạ tần số hoặc bị xếp xuống nhân tiết kiệm điện — máy này là Apple M4, có cả nhân hiệu năng lẫn nhân tiết kiệm. Từ bên trong container tôi không nhìn thấy mình đang chạy trên loại nhân nào, nên đây vẫn là giả thuyết. Cái tôi tin là con số, vì nó lặp lại ở cả ba lần đo và tăng đều theo mức bóp.
Kịch bản D chỉ làm chậm 6%, nên nó không thật sự chứng minh được "nghẽn I/O làm chậm công việc CPU" — nó chỉ chứng minh io.pressure phát hiện được tải I/O trong khi các chỉ số CPU im lặng. Muốn dựng một sự cố I/O thật sự đau thì phải để công việc thử nghiệm cũng đụng vào đĩa, và đó là một phép đo khác.
Con số loadavg trong bảng là dải chứ không phải trung vị, vì trung vị của một đại lượng vô nghĩa vẫn vô nghĩa. Tôi để cả dải để thấy nó chồng lấn hoàn toàn giữa bốn cột.
Một chỗ suýt đo sai. Bản đầu của kịch bản C dùng kill %1 %2 %3 để dọn các tiến trình ăn CPU. Cú pháp điều khiển công việc đó không hoạt động trong sh chạy phi tương tác, nên đám tiến trình kia sống sót qua lần chạy và ăn vào kết quả của kịch bản D ngay sau. Cách phát hiện: kịch bản D lẽ ra không đụng gì tới CPU mà cpu.pressure lại có số. Chữa bằng cách ghi lại PID vào biến rồi kill $PIDS.
Thử ba mươi giây
docker run --rm --cpus=0.3 debian:12-slim sh -c '
echo "cpu.max : $(cat /sys/fs/cgroup/cpu.max)"
echo "nproc noi: $(nproc) <-- so nay khong dung han muc"
T0=$(awk "/throttled_usec/{print \$2}" /sys/fs/cgroup/cpu.stat)
end=$(( $(date +%s) + 5 ))
while [ $(date +%s) -lt $end ]; do :; done
T1=$(awk "/throttled_usec/{print \$2}" /sys/fs/cgroup/cpu.stat)
echo "bi chan : $(( (T1-T0)/1000 )) ms trong 5 giay"
awk "/nr_throttled/{print \"nr_throttled: \" \$2}" /sys/fs/cgroup/cpu.stat'
Chạy đúng lệnh đó, rồi đổi --cpus=0.3 thành --cpus=4 và chạy lại. nproc sẽ không đổi một chữ số nào, còn nr_throttled thì rơi về 0. Đó là toàn bộ điều bài này muốn nói.