phần nice ta thấy nice chia CPU khi có cạnh tranh. Vậy khi không ai chỉnh nice — mọi tiến trình cùng ưu tiên — bộ lập lịch chia CPU thế nào? Bộ lập lịch của Linux tên là CFS (Completely Fair Scheduler), và cái tên hứa hẹn "hoàn toàn công bằng". Câu hỏi: nó công bằng tới đâu, và công bằng ấy có cái giá gì? Tôi đo trong container gcc:13 (10 lõi, ARM), buộc cạnh tranh bằng cách ghim nhiều tiến trình vào một lõi — và thấy CFS công bằng chính xác hơn tôi tưởng, nhưng công bằng ấy đánh đổi bằng độ trễ.

CFS chia thời gian CPU

CFS công bằng bằng cách nào

CFS theo dõi cho mỗi tiến trình một con số gọi là virtual runtime — tổng thời gian nó đã thực sự chạy trên CPU. Nguyên tắc đơn giản đến đẹp: mỗi khi cần chọn ai chạy tiếp, CFS luôn chọn tiến trình có virtual runtime nhỏ nhất — tức cái đã chạy ít nhất từ trước tới giờ. Kết quả: mọi tiến trình bị kéo về cùng một lượng thời gian chạy, không ai bị bỏ lại.

Hai hệ quả quan trọng của cơ chế này. Công bằng thật sự: N tiến trình cùng nice sẽ nhận xấp xỉ bằng nhau 1/N CPU mỗi cái — không phải "đại khái đều", mà đều tới mức đo được. Không bỏ đói (no starvation): vì cái chạy ít nhất luôn được ưu tiên kéo lên, không tiến trình nào bị bỏ đói mãi — khác hẳn một hệ ưu tiên cứng (chạy hết cái ưu tiên cao mới tới cái thấp), nơi một tiến trình ưu tiên thấp có thể không bao giờ được chạy.

Đo: chia đều tới dưới 2%, nhưng chờ 15ms

Tôi chạy N tiến trình cùng nice=0, ghim tất cả vào một lõi (buộc cạnh tranh), đếm số vòng lặp mỗi cái làm được — tỉ lệ đó là tỉ lệ CPU. Rồi đo độ trễ lập lịch: khoảng thời gian dài nhất một tiến trình bị "cướp" CPU trước khi được chạy lại.

CHIA CÔNG BẰNG (nice=0, ghim 1 lõi):
  N=2 : mỗi ~50,0% CPU   (lệch max/min 1,00)
  N=4 : mỗi ~25,0% CPU   (lệch 1,01)
  N=8 : mỗi ~12,5% CPU   (lệch 1,02)

ĐỘ TRỄ LẬP LỊCH:
  N=4 : chờ tới lượt lâu nhất ~15 ms

Con số công bằng đẹp một cách đáng ngạc nhiên. Với N tiến trình, mỗi cái nhận chính xác ~1/N CPU (50/25/12,5%), và độ lệch giữa cái được nhiều nhất và ít nhất chỉ dưới 2% — kể cả với 8 tiến trình. CFS không "đại khái" công bằng; nó công bằng tới mức tất cả gần như bằng nhau tuyệt đối. Không có chuyện một tiến trình "may mắn" giành được nhiều hơn.

Nhưng nhìn dòng độ trễ: với 4 tiến trình, một tiến trình có thể bị cướp CPU tới ~15 ms trước khi được chạy lại. Nó nhận đủ 25% CPU của mình — nhưng không phải chạy liên tục ở 1/4 tốc độ, mà chạy thành từng đợt (burst) xen kẽ những khoảng chờ dài. Càng nhiều tiến trình, mỗi lát chạy càng nhỏ và khoảng chờ giữa hai lượt càng dài.

Một lần tôi đo hớ: công bằng CPU không bằng chạy ngay

Tôi vào đo với một ấn tượng mơ hồ: "bộ lập lịch thì đại khái công bằng thôi — ai may mắn được lịch nhiều hơn thì chạy nhanh hơn một chút". Phép đo sửa lại: CFS công bằng chính xác, không hề ngẫu nhiên — lệch dưới 2% qua mọi N. Đó là một tính chất mạnh hơn "đại khái": nó đảm bảo mỗi tiến trình đúng phần của mình và không bỏ đói ai.

Nhưng chỗ đo hớ thật sự là ở tầng thứ hai: tôi tưởng "được chia 1/N CPU" nghĩa là "chạy đều đặn ở 1/N tốc độ". Sai — đo độ trễ cho thấy một tiến trình chờ tới 15 ms giữa các lượt. Công bằng về throughput (tổng CPU) không bằng công bằng về độ trễ (chờ bao lâu tới lượt). Một tiến trình nhận đủ 25% CPU nhưng theo kiểu "chạy một đợt rồi chờ 15 ms" — với một tác vụ tương tác cần phản hồi nhanh, 15 ms chờ đó là thật và quan trọng, dù throughput hoàn toàn công bằng.

Bài học đo lường: "công bằng" có nhiều nghĩa — công bằng về tổng phần khác công bằng về độ trễ, và một hệ có thể đảm bảo cái này mà không cái kia. CFS đảm bảo chia đều tổng CPU (throughput) và không bỏ đói; nhưng nó không đảm bảo độ trễ thấp khi có nhiều tiến trình — mỗi cái vẫn phải chờ tới lượt, và khoảng chờ tăng theo số tiến trình. Đây cũng là lý do khoảng chờ đó gắn với chi phí chuyển ngữ cảnh: càng nhiều tiến trình, lát chạy càng nhỏ, càng nhiều lần chuyển — và độ trễ tới lượt càng dài.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: tin CFS chia CPU công bằng — đừng tự "quản lý" ưu tiên khi không cần. Nếu bạn có nhiều tiến trình/luồng cùng loại đua CPU, CFS đã chia đều tự động và không bỏ đói ai. Bạn không cần cơ chế thủ công để "đảm bảo mỗi cái được chạy"; scheduler lo việc đó tốt hơn, đo được lệch dưới 2%.

Hệ quả thứ hai: công bằng CPU không giải quyết độ trễ — với tác vụ nhạy độ trễ, giảm số tiến trình đua. Nếu một tác vụ tương tác (phục vụ request, UI) chậm giật cục dù CPU "đủ", vấn đề có thể là độ trễ lập lịch: quá nhiều tiến trình đua cùng lõi khiến nó chờ tới lượt. Cách chữa không phải tăng ưu tiên (nice giúp có hạn), mà là giảm số tiến trình cạnh tranh (ít luồng hơn, ghim ái lực CPU để tách khỏi đám đông) — throughput công bằng không tự cho độ trễ thấp.

Hệ quả thứ ba là tinh thần đo lường: "công bằng" là một từ nhiều nghĩa — đo rõ bạn đang nói công bằng về gì. Con số mang theo: CFS chia CPU CHÍNH XÁC công bằng — N tiến trình cùng nice nhận ~1/N mỗi cái, lệch max/min chỉ dưới 2% (N=8: mỗi 12,5%), và KHÔNG bỏ đói (theo virtual runtime, cái chạy ít nhất luôn được kéo lên); nhưng công bằng về throughput KHÔNG bằng độ trễ thấp — với 4 tiến trình mỗi cái chờ tới lượt lâu nhất ~15 ms, chạy thành đợt chứ không liên tục, và càng nhiều tiến trình chờ càng lâu (kèm nhiều chuyển ngữ cảnh). Chia đều CPU không có nghĩa chạy ngay.

Thử ba mươi giây

Mở top (hay htop) trên một máy có nhiều tiến trình nặng CPU và nhìn cột %CPU: nếu chúng cùng ưu tiên và đua trên cùng số lõi, bạn sẽ thấy chúng chia CPU khá đều — đó là CFS công bằng. Rồi hỏi một câu khác: nếu một trong số đó là tác vụ tương tác (cần phản hồi nhanh), nó có bị giật không, dù %CPU của nó "đủ"? Nếu có, thủ phạm không phải thiếu CPU mà là độ trễ lập lịch — nó phải chờ tới lượt giữa đám đông. Ba mươi giây phân biệt "đủ CPU" với "được lịch kịp thời" đó cho bạn một trực giác quan trọng: bộ lập lịch chia tổng CPU rất công bằng, nhưng "công bằng" không tự động nghĩa "phản hồi nhanh" — và với tác vụ nhạy độ trễ, bạn phải giảm cạnh tranh, không chỉ trông cậy vào sự công bằng.