Xếp một người vào hàng ưu tiên ở sân bay chỉ giúp khi có hàng dài — nếu quầy đang vắng tanh, tấm thẻ VIP chẳng làm bạn qua nhanh hơn một giây, vì chẳng có ai để vượt. Tôi đã hiểu nice của Linux đúng kiểu nhầm lẫn đó suốt bao năm: tưởng nó là "thẻ VIP làm chương trình chạy nhanh hơn", trong khi thật ra nó chỉ là quy tắc xếp hàng khi đông. Máy bạn có 10 lõi nhưng chạy hàng trăm tiến trình. Ai được chạy, khi nào, bao lâu? Đó là việc của bộ lập lịch — trên Linux tên là CFS, Completely Fair Scheduler. Tôi đo trong container bằng cách ghim nhiều tiến trình vào một lõi và đếm công việc mỗi tiến trình làm được — rồi phát hiện mình hiểu sai nice.
CFS chia một lõi thế nào
Khi nhiều tiến trình cùng muốn chạy mà chỉ có một lõi, CFS chia thời gian lõi đó cho chúng theo lượt rất mịn: mỗi tiến trình chạy một lát ngắn rồi nhường, xoay vòng nhanh đến mức trông như chạy song song. "Công bằng" nghĩa là: các tiến trình cùng độ ưu tiên nhận phần thời gian CPU bằng nhau. Hai tiến trình đua một lõi thì mỗi cái được nửa lõi; bốn cái thì mỗi cái một phần tư.
Độ ưu tiên điều chỉnh bằng nice, một số từ -20 (ưu tiên cao nhất) tới +19 (thấp nhất, "nhường nhịn" nhất — đúng nghĩa chữ nice). CFS không coi nice là "tốc độ" mà là trọng số: mỗi mức nice thấp hơn cho tiến trình thêm khoảng 1,25 lần trọng số, và CPU được chia theo tỉ lệ trọng số. Tôi đo bằng cách cho mỗi tiến trình chạy một vòng lặp bận rộn, đếm số "đơn vị công việc" nó hoàn thành trong 2 giây — con số đó tỉ lệ thẳng với lượng CPU nó nhận được. Tất cả ghim vào lõi 0.
Đo: công bằng là chia đều, và total không đổi
Đầu tiên, chạy N tiến trình cùng độ ưu tiên, cùng ghim vào một lõi:
| Số tiến trình | Công việc mỗi cái | Tổng |
|---|---|---|
| 1 | 118311 (100%) | 118311 |
| 2 | ~59000 (50%) | 118044 |
| 3 | ~39000 (33%) | 117190 |
| 4 | ~29200 (25%) | 116996 |
Hai điều rõ ràng. Thứ nhất, CFS chia đều tăm tắp: hai tiến trình mỗi cái đúng một nửa, bốn cái mỗi cái đúng một phần tư — công bằng như tên gọi. Thứ hai, và quan trọng hơn: tổng công việc gần như không đổi (~117-118 nghìn) dù có 1 hay 4 tiến trình. Bộ lập lịch chia cái bánh CPU, chứ không nướng thêm bánh. Nhồi bốn tiến trình vào một lõi không làm được nhiều việc hơn chạy một — chỉ khiến mỗi tiến trình xong chậm gấp bốn. Đây là điều dễ quên khi ta tưởng "chạy nhiều tiến trình cùng lúc thì nhanh hơn": trên một lõi, đồng thời chỉ là ảo giác, tổng thông lượng vẫn thế.
nice không phải nút tốc độ — nó là trọng tài, chỉ ra sân khi có tranh chấp
Tôi vào bài với niềm tin gần như ai cũng có: "muốn một chương trình chạy nhanh hơn thì nice -20 nó, muốn nó nhường thì nice 19". Để chứng minh, tôi chạy một tiến trình một mình ở nice 19 (thấp nhất) và so với nice 0:
nice 0 , một mình: 118215 đơn vị
nice 19, một mình: 118345 đơn vị
Gần y hệt nhau. nice 19 chẳng làm tiến trình chậm đi một chút nào. Con số này đập thẳng vào niềm tin của tôi. Lý do, khi ngẫm ra, hiển nhiên: khi một tiến trình chạy một mình trên lõi, không có ai để tranh, thì kể cả tiến trình "nhường nhịn nhất" cũng được trọn 100% lõi rảnh — nhường cho ai khi chẳng có ai? nice không phải chân ga đạp cho nhanh; nó là quy tắc chia phần khi có nhiều miệng ăn.
nice chỉ tỏ tác dụng khi có tranh chấp. Tôi cho hai tiến trình đua một lõi với nice khác nhau:
nice 0 vs nice 5 -> 75% : 25%
nice 0 vs nice 10 -> 90% : 10%
Bây giờ nice mới lộ vai trò: tiến trình nice thấp hơn giành phần lớn hơn, đúng theo tỉ lệ trọng số (mỗi mức ~1,25 lần, nên chênh 5 mức ≈ 3:1, chênh 10 mức ≈ 9:1). Nhưng lưu ý: đây vẫn là chia một lõi — tiến trình nice 0 nhanh hơn vì nó cướp phần của tiến trình kia, không phải vì lõi tự dưng mạnh lên.
Điều rút ra: "nhanh" là vô nghĩa nếu chưa hỏi "trong điều kiện nào". Nếu tôi chỉ đo tiến trình nice 19 một mình rồi thấy nó vẫn 100%, tôi có thể kết luận "nice chẳng có tác dụng gì" — cũng sai. Sự thật nằm ở biến ẩn tranh chấp: không tranh chấp thì nice vô hình; có tranh chấp thì nice quyết định tỉ lệ. Đo một tham số mà quên điều kiện nó có nghĩa thì đo ra số đúng nhưng hiểu sai.
Về đến code hằng ngày
Điều đầu tiên: renice một tác vụ đang chạy một mình là vô ích. Nếu máy đang rảnh và job của bạn là thứ duy nhất ngốn CPU, hạ nice của nó xuống -20 cũng không nhanh hơn một giây nào — nó đã có trọn CPU rồi. nice chỉ đáng dùng khi bạn muốn sắp thứ tự ưu tiên giữa các tác vụ đang tranh nhau: cho một job nền (backup, nén, index) chạy ở nice 19 để nó tự động nhường CPU cho công việc tương tác khi cả hai cùng chạy, mà không làm gì khi máy rảnh. Đó là công dụng đúng: một cái van tự điều chỉnh theo tải, không phải cần ga.
Điều thứ hai: thêm luồng/tiến trình không thêm CPU. Vì tổng thông lượng một lõi là cố định, chạy nhiều tác vụ nặng CPU hơn số lõi vật lý không làm xong nhanh hơn — chỉ làm mọi thứ chậm đều và tăng phí chuyển ngữ cảnh. Một pool worker nặng CPU nên cỡ bằng số lõi, không phải càng nhiều càng tốt; quá số lõi là tự chuốc phí lập lịch mà không thêm việc. (Tác vụ chờ I/O thì khác — chúng nhường CPU khi chờ, nên có thể nhiều hơn số lõi.)
Và cả hai hiểu lầm ở đây — "ưu tiên = tốc độ" và "nhiều worker = nhanh hơn" — lặp lại y hệt ở tầng ứng dụng, chỗ nào cũng có phiên bản của nó. Thread.setPriority(MAX) trong Java không làm luồng chạy nhanh hơn khi CPU rảnh, và một ExecutorService cỡ 200 thread cho tác vụ CPU-bound trên máy 8 lõi chỉ chậm đều đi vì tranh chấp và context-switch — chính là cái bảng "tổng không đổi" ở đây, nên Runtime.availableProcessors() mới là cỡ pool đúng. Go đóng khung ngay trong GOMAXPROCS: đặt nó cao hơn số lõi không cho thêm sức tính, chỉ thêm goroutine tranh nhau. Ngay cả CSDL: một connection pool lớn hơn số lõi (hay hơn max_connections hợp lý) không tăng throughput mà làm mọi truy vấn chậm đều vì tranh CPU/khóa — đó là lý do PgBouncer và các hướng dẫn tuning đều khuyên pool nhỏ. Mẫu số chung: một cơ chế "ưu tiên" chỉ phân xử khi có tranh chấp chứ không tạo thêm năng lực, và nhồi thêm đơn vị công việc quá số lõi chỉ chia nhỏ cái bánh cố định — muốn nhanh hơn thật phải giảm tranh chấp hoặc thêm lõi, không phải chỉnh một con số ưu tiên hay tăng số worker. Gói lại bằng số: CFS chia đều một lõi cho các tiến trình cùng ưu tiên (1 cái 118311, 2 cái mỗi 50%, 4 cái mỗi 25%, tổng luôn ~117-118 nghìn không đổi), và nice là trọng số chia phần chỉ có tác dụng khi có tranh chấp (nice 0 vs 5 → 75:25, vs 10 → 90:10) — còn một tiến trình nice 19 chạy một mình vẫn được trọn 100% lõi (118345), y như nice 0 (118215). nice không tăng tốc; nó phân xử.
Muốn tự thấy thì mở htop hoặc top. Chạy một vòng lặp bận rộn: yes > /dev/null & — nó ngốn 100% một lõi. Nhìn cột NI (nice) và %CPU. Giờ chạy thêm một yes > /dev/null & nữa và xem: nếu chúng ở cùng một lõi, mỗi cái tụt về ~50%; máy nhiều lõi thì chúng nhảy sang hai lõi và mỗi cái vẫn ~100% (vì không tranh nhau). Thử renice 19 <pid> một trong hai khi chúng đang tranh cùng một lõi (dùng taskset -cp 0 <pid> ghim cả hai vào lõi 0 trước) và xem %CPU lệch hẳn về cái nice thấp hơn — đúng tỉ lệ bài này đo. Nhớ kill %1 %2 để dọn sau khi thử.