Hình dung một cô giáo chia sự chú ý cho N học sinh trong một tiết học. Cô rất công bằng: mỗi em nhận đúng 1/N thời gian, và mỗi khi rảnh cô luôn quay sang em nào được để ý ít nhất từ đầu giờ — nên không em nào bị bỏ quên. Nghe hoàn hảo. Nhưng một em giơ tay cần hỏi ngay vẫn phải chờ vòng xoay quay tới mình — và lớp càng đông, cái chờ đó càng lâu. Em ấy được đủ phần thời gian của mình, chỉ là không phải lúc em cần. Bộ lập lịch CPU của Linux, CFS, hành xử đúng như cô giáo đó — và đây là chỗ "công bằng" có hai nghĩa mà tôi từng gộp làm một.

Ở 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 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ờ. Đúng cô giáo luôn quay sang em được để ý ít nhất. 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ó có 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 — đúng lớp càng đông, em giơ tay chờ càng lâu.

(Môi trường đo: container gcc:13 trên host ARM AArch64, N tiến trình cùng nice ghim chung 1 lõi để buộc cạnh tranh, tỉ lệ CPU suy từ số vòng lặp. Con số tuyệt đối (15 ms) đổi theo cấu hình lập lịch; cái bền vững là: chia đều rất chính xác, và độ trễ tới lượt tăng theo số tiến trình.)

Hai niềm tin đối nghịch, cùng sai

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 ngay khi tin điều đó, tôi rơi vào niềm tin ngược ở tầng thứ hai: "được chia 1/N CPU thì nghĩa là chạy đều đặn ở 1/N tốc độ". Cũng 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. Hai lầm tưởng, một gốc: "công bằng" có nhiều nghĩa, và CFS đảm bảo chia đều tổng phần + không bỏ đói, nhưng không đảm bảo độ trễ thấp khi đông tiến trình — mỗi cái vẫn phải chờ tới lượt, và cái 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, độ trễ tới lượt càng dài.

Về đến code hằng ngày

Điều đầ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%.

Điều 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.

Cái phân biệt throughput-vs-độ-trễ này chính là trục xoay của cả một mảng vận hành. Nó là lý do dân SRE ám ảnh với tail latency (p99, p999) chứ không chỉ CPU trung bình: một service có thể "đủ CPU" mà p99 vẫn tệ vì độ trễ lập lịch dưới tải. Nó là lý do cgroup có cpu.weight/CPU shares (chia throughput theo tỉ lệ, đúng tinh thần CFS) tách khỏi cpu.max quota (chặn trần) — hai công cụ cho hai loại "công bằng". Và bản thân Linux đã thấy giới hạn độ trễ của CFS: từ nhân 6.6 nó được thay bằng EEVDF (Earliest Eligible Virtual Deadline First), một bộ lập lịch vẫn công bằng throughput nhưng thêm khái niệm deadline để phục vụ tác vụ nhạy độ trễ tốt hơn — đúng để vá đúng cái khe hở mà phép đo này phơi ra. Ở tầng ứng dụng, đây cũng là vì sao runtime của Go và các thread pool cố giữ số luồng chạy được quanh số lõi: bớt cạnh tranh là bớt độ trễ tới lượt. Cùng một bài học cô-giáo-chia-chú-ý, lặp lại từ nhân tới cụm server.

Muốn tự thấy thì 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. Vài phút 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.