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, và bao lâu? Đó là việc của bộ lập lịch (scheduler). Trên Linux nó tên là CFS — Completely Fair Scheduler, "bộ lập lịch hoàn toàn công bằng". Câu hỏi thực tế: "công bằng" ở đây nghĩa là gì, và lệnh nice mà ai cũng nghe nói thật ra làm gì? 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 suốt bao lâu nay.

Lập lịch CFS

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

Một lần tôi đo hớ: nice không phải nút tốc độ

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 cầ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.

Bài học đo lường: "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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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.)

Hệ quả thứ ba là bài học tư duy bao trùm: một cơ chế "công bằng" hay "ưu tiên" chỉ có nghĩa dưới tranh chấp. Con số mang theo: CFS chia đều một lõi cho các tiến trình cùng ưu tiên (mỗi cái 1/N, tổng công việc không đổi), và nice là trọng số chia phần chỉ có tác dụng khi có tranh chấp — một tiến trình nice 19 chạy một mình vẫn được trọn 100% lõi, y như nice 0. nice không tăng tốc; nó phân xử. Muốn nhanh hơn thật thì phải giảm tranh chấp hoặc thêm lõi, không phải đổi một con số ưu tiên.

Thử ba mươi giây

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