Phần 5 ta đo chi phí một lần chuyển ngữ cảnh. Nhưng có một câu hỏi tinh tế hơn: chuyển ngữ cảnh xảy ra khi nào? Nhiều người tin nó chỉ xảy ra khi luồng của mình chủ động chờ — chờ I/O, chờ khóa, sleep. Thực ra có một loại thứ hai, âm thầm và tốn kém: chuyển ngữ cảnh bắt buộc, khi bộ lập lịch tước CPU khỏi luồng đang muốn chạy tiếp. Tôi đo cả hai loại bằng cách đếm trực tiếp qua getrusage, và cho thấy một workload CPU thuần — không hề block — vẫn có thể bị tước CPU hàng nghìn lần khi ta tạo quá nhiều luồng.

Chi phí context switch bắt buộc

Hai loại chuyển ngữ cảnh

Tự nguyện (voluntary, đếm bằng ru_nvcsw): luồng tự nhường CPU vì nó không có gì để làm ngay — nó chờ I/O xong, chờ một khóa, hay sleep. Nó chủ động rời CPU. Đây là loại ta ngầm hiểu khi nói "luồng block".

Bắt buộc (involuntary, đếm bằng ru_nivcsw): bộ lập lịch cưỡng bức lấy CPU khỏi luồng dù nó đang muốn chạy tiếp. Chuyện này xảy ra khi luồng đã dùng hết phần thời gian được cấp (timeslice của CFS), hoặc khi một luồng ưu tiên cao hơn cần chạy. Điều kiện để nó xảy ra nhiều: tranh CPU — số luồng sẵn sàng chạy nhiều hơn số lõi. Khi đó hệ điều hành phải luân phiên chia lõi cho chúng, và mỗi lần luân phiên là một chuyển ngữ cảnh bắt buộc.

getrusage(RUSAGE_SELF, ...) cho ta đếm cả hai con số này. Tôi chạy một workload CPU thuần (một vòng lặp tính toán không hề gọi I/O, không khóa, không ngủ), với số luồng T tăng dần quanh và vượt số lõi (nproc=10), mỗi luồng làm một lượng công cố định, trong container gcc:13.

Đo: CPU thuần vẫn bị tước hàng nghìn lần

nproc = 10, mỗi luồng làm công cố định (LCG, không block gì):
  T=1  (≤ lõi) : 0,12s | nivcsw(bắt buộc) =    0   nvcsw(tự nguyện)=1
  T=10 (= lõi) : 0,15s | nivcsw            =  175   nvcsw=4
  T=20 (2× lõi): 0,30s | nivcsw            =  757   nvcsw=5
  T=40 (4× lõi): 0,58s | nivcsw            = 1843   nvcsw=6

Nhìn cột nvcsw (tự nguyện) trước: nó gần như bằng 0 suốt (1 đến 6). Đúng như kỳ vọng — workload này là CPU thuần, không bao giờ block, nên không có lần nào nó tự nguyện nhường CPU. Nếu context switch "chỉ xảy ra khi block", thì workload này lẽ ra gần như không có switch nào.

Nhưng nhìn cột nivcsw (bắt buộc): với một luồng, đúng là 0 — nó độc chiếm một lõi, không ai tranh, không bị tước. Với 10 luồng (bằng số lõi), đã có 175 lần bị tước. Với 20 luồng, 757. Với 40 luồng, 1843. Số lần bị tước CPU tăng vọt theo mức oversubscription — dù luồng nào cũng chỉ muốn chạy cho xong việc của mình. Đây chính là chuyển ngữ cảnh bắt buộc: khi có 40 luồng tranh 10 lõi, hệ điều hành phải liên tục dừng luồng này để cho luồng kia một lượt, cứ mỗi timeslice (~vài ms) một lần.

Và thời gian? T=1 mất 0,12s; T=10 mất 0,15s (10 luồng chạy gần song song trên 10 lõi). Nhưng T=20 mất 0,30s (gấp đôi) và T=40 mất 0,58s (gấp ~4). Vì tổng công là T× phần mỗi luồng, mà chỉ có 10 lõi để chạy — nên vượt 10 luồng, thêm luồng không làm xong nhanh hơn; mỗi luồng chỉ được nửa (rồi một phần tư) thời gian CPU, cộng thêm chi phí của 757–1843 lần chuyển ngữ cảnh bắt buộc và ô nhiễm cache đi kèm.

Còn sched_yield — lời gọi để "nhường lượt" một cách lịch sự? Đo khi chạy một mình: 150 ns/lần — chỉ là một syscall trả về gần như ngay lập tức, vì không có luồng nào khác đang chờ để nhận CPU. Nó không tạo ra song song, không làm gì nhanh hơn.

Một lần tôi đo hớ: hai lầm tưởng về switch và yield

Tôi vào đo với hai niềm tin. Thứ nhất: "context switch chỉ xảy ra khi luồng của mình block". Sai — workload CPU thuần không block lần nào (nvcsw~0) mà vẫn bị chuyển ngữ cảnh bắt buộc tới 1843 lần khi oversubscribe. Bộ lập lịch tước CPU theo timeslice bất kể luồng có muốn chạy tiếp hay không; bạn không kiểm soát được điều đó. Thứ hai: "gọi sched_yield giúp chương trình chạy mượt/nhanh hơn". Sai — khi không ai tranh, yield chỉ là một syscall vô ích (150 ns, không tạo song song); khi tranh, nó chỉ ép một chuyển ngữ cảnh sớm (tốn thêm chi phí), trao CPU cho luồng khác chứ không làm việc của bạn nhanh hơn. Một vòng while(1) sched_yield(); gần như phí phạm như busy-spin — đốt CPU vào việc chuyển qua chuyển lại.

Bài học đo lường: chuyển ngữ cảnh không phải thứ chỉ do bạn chủ động gây ra; phần lớn chi phí lập lịch đến từ bắt buộc — bị tước CPU khi có nhiều luồng chạy được hơn số lõi — và đó là thứ bạn kiểm soát bằng số luồng, không phải bằng yield. nvcswnivcsw trong getrusage cho bạn thấy chính xác loại nào đang chiếm ưu thế. Nếu tôi tin "không block thì không switch", tôi đã không hiểu vì sao một chương trình CPU thuần lại chậm đi khi tôi thêm luồng — cứ tưởng thêm luồng là thêm sức mạnh.

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

Hệ quả đầu tiên: với việc nặng CPU, số luồng tối ưu xấp xỉ số lõi — đừng tạo nhiều hơn. Vượt số lõi, tổng công không xong nhanh hơn (throughput bị chặn bởi số lõi), mà bạn phải trả thêm hàng nghìn chuyển ngữ cảnh bắt buộc cộng ô nhiễm cache. Đây đúng bài học "chọn số worker quanh số lõi" của thread pool: oversubscription làm chậm, không làm nhanh. (Việc nặng I/O — luồng thường block chờ — thì khác: ở đó nhiều luồng hơn lõi có thể tốt, vì luồng chờ không chiếm lõi; nhưng vẫn phải đo.)

Hệ quả thứ hai: đừng dùng sched_yield để "tối ưu" hay để đồng bộ. Nó không tạo song song và không làm việc của bạn nhanh hơn; một vòng lặp yield để "chờ" một điều kiện chỉ đốt CPU và ép switch. Muốn chờ một sự kiện, dùng biến điều kiện / futex (ngủ thật, nhường CPU cho việc khác); muốn giới hạn song song, giới hạn số luồng. yield gần như không bao giờ là câu trả lời đúng.

Hệ quả thứ ba là tinh thần đo lường: phân biệt được chi phí mình gây ra với chi phí hệ áp lên mình, và đo đúng loại. Con số mang theo: có hai loại chuyển ngữ cảnh — tự nguyện (block, ru_nvcsw) và BẮT BUỘC (bị tước CPU dù muốn chạy, ru_nivcsw); workload CPU thuần không block lần nào (nvcsw~0) vẫn bị tước cưỡng bức, và số lần tăng vọt theo oversubscription (nivcsw 0→175→757→1843 khi T=1→10→20→40 trên 10 lõi), kéo thời gian lên gấp 2–4 lần; sched_yield một mình chỉ là một syscall ~150ns vô ích (không tạo song song), có tranh thì chỉ ép một switch. Nên số luồng CPU-bound tối ưu ~= số lõi, và yield không phải công cụ tối ưu. Chuyển ngữ cảnh bắt buộc là cái giá của việc tranh CPU, và bạn trả nó bằng cách tạo quá nhiều luồng.

Thử ba mươi giây

Viết một chương trình chạy một vòng lặp CPU thuần (không I/O, không khóa) trong T luồng, và trước/sau gọi getrusage(RUSAGE_SELF, &r) để in r.ru_nvcsw (tự nguyện) và r.ru_nivcsw (bắt buộc). Chạy với T = 1, = số lõi, = 2× và 4× số lõi. Bạn sẽ thấy nvcsw gần như luôn ~0 (không block gì), còn nivcsw tăng vọt khi T vượt số lõi — đó là bộ lập lịch tước CPU cưỡng bức của các luồng đang tranh nhau. Rồi để ý thời gian chạy: nó không giảm khi T vượt số lõi, mà tăng. Ba mươi giây đó cho bạn thấy một sự thật mà "context switch = block" che giấu: phần lớn chi phí lập lịch của một chương trình đông luồng là bắt buộc, đến từ việc bạn tạo nhiều luồng hơn số lõi — và cách chữa không phải yield, mà là bớt luồng đi.