Khi nhiều tiến trình cùng đòi một CPU, hệ điều hành không thể chạy hết cùng lúc — nó cho mỗi tiến trình chạy một lát thời gian (time slice) rồi ngắt để nhường lượt cho tiến trình khác, xoay vòng nhanh tới mức ta tưởng tất cả chạy song song. Câu hỏi thực tế: mỗi lượt một tiến trình được chạy bao lâu trước khi bị đẩy ra? Bài này đo trực tiếp lát đó — và vấp hai bất ngờ: lần đầu tôi không thấy chia lượt gì cả, và thứ tôi đang đo hóa ra không phải CFS như tên gọi.

Lát thời gian CFS

Bộ lập lịch chia CPU theo lượt

Bộ lập lịch (scheduler) của nhân quyết định tiến trình nào chạy trên CPU nào, vào lúc nào. Khi số tiến trình sẵn sàng chạy nhiều hơn số CPU, chúng phải chia nhau: nhân cho một tiến trình chạy một lát, rồi một ngắt hẹn giờ (timer) buộc nó nhường chỗ, nhân chọn tiến trình kế tiếp, cứ thế. Lát càng ngắn thì mọi tiến trình càng "mượt" (độ trễ phản hồi thấp) nhưng tốn nhiều chuyển ngữ cảnh hơn; lát càng dài thì ít phí chuyển đổi nhưng phản hồi giật hơn. Chọn độ dài lát là một cân bằng cổ điển.

Nhiều năm qua, Linux dùng bộ lập lịch CFS (Completely Fair Scheduler), tính lát theo công thức: một "độ trễ mục tiêu" sched_latency chia đều cho số tiến trình đang tranh, nên càng nhiều tiến trình thì lát càng co lại — tới một mức sàn min_granularity để không co vô hạn. Đó là lý thuyết tôi mang vào bài, định đọc sched_latency_ns để đoán lát. Nhưng khi đo thật, cả lý thuyết lẫn cách đo đều va vào thực tế.

Đo: mỗi lượt ~3 ms, và chỉ khi cùng một CPU

Tôi viết một chương trình chạy một vòng bận, đọc đồng hồ (CLOCK_MONOTONIC) liên tục. Khi tiến trình đang chạy, hai lần đọc liền kề chỉ cách nhau vài chục nano giây; nhưng khi nó bị đẩy khỏi CPU để nhường lượt, sẽ có một khe hở lớn giữa hai lần đọc — đúng bằng thời gian nó nằm chờ. Đo độ dài các đoạn chạy giữa các khe hở đó chính là đo lát thời gian.

Chạy hai tiến trình như vậy cùng lúc, ghim cả hai vào cùng một CPU (bằng sched_setaffinity) để buộc chúng tranh nhau, rồi tăng dần số tiến trình, tôi đo được (container thấy 10 CPU):

Tình huống Lát (trung vị) Tổng thời gian nằm chờ / 3000 ms
2 tiến trình ghim CPU 0 2,99 ms ~1520 ms (mỗi cái ~½ CPU)
4 tiến trình ghim CPU 0 2,99 ms ~2256 ms (~¾)
8 tiến trình ghim CPU 0 2,99 ms ~2620 ms (~⅞)

Con số nổi bật: lát thời gian là ~3 mili giây, và nó không đổi dù 2, 4 hay 8 tiến trình tranh nhau. Điều thay đổi khi thêm tiến trình không phải độ dài mỗi lát, mà là tần suất một tiến trình được tới lượt: với 2 tiến trình mỗi cái được ~½ thời gian CPU, với 8 tiến trình mỗi cái chỉ còn ~⅛ — nhưng khi đã tới lượt, nó vẫn được nguyên một lát ~3 ms. Đây trái với công thức CFS "lát co lại khi nhiều tiến trình" mà tôi mang vào bài — và có lý do rất cụ thể cho điều đó.

Một lần tôi đo hớ: không ghim thì chẳng thấy chia lượt

Trước khi ra được bảng gọn trên, phép đo đầu tiên của tôi cho một kết quả làm tôi bối rối. Tôi chạy hai tiến trình bận mà quên ghim CPU, chờ thấy chúng chia lượt cho nhau. Nhưng cả hai gần như không hề bị đẩy ra: tổng thời gian nằm chờ chỉ khoảng 30 mili giây trên 3000 ms — tức 1%. Chúng chạy gần như liên tục, như thể mỗi cái độc chiếm một CPU. Tôi suýt kết luận "trên máy này tiến trình hiếm khi bị chia lượt".

Sai, và cái sai lộ ra ngay khi nhớ máy có 10 CPU. Hai tiến trình bận, mười CPU rảnh — bộ lập lịch làm điều hiển nhiên: cho mỗi cái một nhân riêng. Chúng chẳng phải tranh với ai, nên chẳng có lát thời gian nào để đo. Chỉ khi tôi ghim cả hai vào cùng một CPU (taskset -c 0, hay sched_setaffinity), buộc chúng chung một nhân, thì việc chia lượt — và lát ~3 ms — mới hiện ra. Bài học đo lường: số nhân CPU và affinity là một biến ẩn quyết định; đo "chia lượt" mà không kiểm soát chuyện tiến trình có buộc phải chung CPU hay không thì đo phải một hiện tượng khác. Tôi đã suýt kết luận về bộ lập lịch dựa trên một tình huống mà bộ lập lịch còn chẳng cần chia gì.

Có một cái hớ thứ hai đi kèm, cũng đáng nhớ. Tôi định đoán lát bằng cách đọc sched_latency_ns của CFS — nhưng file đó không tồn tại trên máy. Lý do: từ nhân 6.6, Linux đã thay CFS bằng một bộ lập lịch mới tên EEVDF (Earliest Eligible Virtual Deadline First), và các tham số cũ (sched_latency_ns, sched_min_granularity_ns) bị bỏ. Nhân ở đây là 7.0.12 — đúng EEVDF. Lát của nó do một tham số base_slice_ns quyết định, và tôi đọc được nó bằng /sys/kernel/debug/sched/base_slice_ns = 2,8 ms — khớp gần đúng với 2,99 ms tôi đo được. Trong EEVDF, lát cơ sở là một hằng số ~3 ms, còn "công bằng" được bảo đảm bằng thời hạn ảo (virtual deadline) chứ không phải bằng cách co lát theo số tiến trình như CFS cũ. Đó là lý do lát của tôi đứng yên ở 3 ms bất kể 2 hay 8 tiến trình. Bài học: đừng bê trực giác — và tên tham số — của nhân cũ sang nhân mới; hãy đọc thẳng cấu hình đang chạy.

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

Hệ quả đầu tiên là hiểu vì sao chương trình của bạn vẫn "mượt" dù có ít nhân hơn số tiến trình. Lát ~3 ms nghĩa là mỗi tiến trình bị gián đoạn và nối lại hàng trăm lần mỗi giây — nhanh tới mức mắt người và phần lớn ứng dụng không nhận ra. Ảo giác song song này là món quà của bộ lập lịch. Nhưng nó cũng nghĩa là một tiến trình tính toán nặng không "đứng" CPU mãi: cứ 3 ms nó lại nhường chỗ, nên các tiến trình khác (kể cả tiến trình tương tác của bạn) vẫn có lượt. Bạn hiếm khi cần tự chia nhỏ công việc CPU-bound thành lát — nhân đã làm điều đó.

Hệ quả thứ hai là chi phí thật của việc có quá nhiều tiến trình sẵn sàng chạy không nằm ở lát ngắn đi, mà ở lượt thưa đi và chuyển ngữ cảnh nhiều lên. Trên EEVDF, thêm tiến trình không làm mỗi lát ngắn lại, nhưng làm mỗi tiến trình chờ lâu hơn giữa các lượt (độ trễ tăng) và tổng số lần chuyển ngữ cảnh cộng dồn — mỗi lần một cú xả và nạp lại ngữ cảnh CPU tốn kém. Chạy 200 luồng CPU-bound trên 10 nhân không làm việc xong nhanh hơn 10 luồng, chỉ thêm phí chuyển đổi và độ trễ. Số luồng tính toán nên xấp xỉ số nhân, không hơn.

Hệ quả thứ ba là muốn kiểm soát tiến trình chạy ở đâu, hãy dùng affinity một cách có chủ đích — và biết mặc định. Phép đo cho thấy affinity quyết định hoàn toàn việc tiến trình có phải chia CPU hay không. Ghim (pin) các tiến trình nhạy độ trễ vào nhân riêng để chúng không bị tranh; hoặc gom các tiến trình phụ vào một tập nhân để chừa nhân cho việc chính. Nhưng làm vậy phải đo, vì ghim sai còn hại hơn để nhân tự cân bằng. Con số mang theo: lát thời gian trên nhân này (EEVDF, kế nhiệm CFS) là ~3 ms cố định, không co theo số tiến trình như CFS cũ; nhưng lát chỉ xuất hiện khi các tiến trình buộc phải chung một CPU — với dư nhân, mỗi tiến trình được một nhân riêng và không chia lượt gì cả. Bộ lập lịch là thứ vô hình làm nên ảo giác song song; hiểu nó biến những hành vi khó lường thành có thể giải thích.

Thử ba mươi giây

Xem bộ lập lịch chia lượt tận mắt: chạy hai vòng lặp bận vô hạn ghim vào cùng một CPU — taskset -c 0 sh -c 'while :; do :; done' & chạy hai lần — rồi mở top (bấm 1 để xem từng CPU): bạn sẽ thấy CPU 0 đạt 100% và hai tiến trình mỗi cái ~50%, chia đôi đúng như đo. Bỏ taskset -c 0 đi và chạy lại: giờ hai tiến trình nhảy lên hai CPU khác nhau, mỗi cái 100%, không còn chia lượt — đúng cái bẫy bài này vấp. Muốn biết lát cơ sở của nhân mình, đọc cat /sys/kernel/debug/sched/base_slice_ns (nhân EEVDF, 6.6 trở lên); nếu file đó không có mà có sched_latency_ns, máy bạn còn dùng CFS cũ — và lát sẽ co theo số tiến trình, khác hẳn.