Hình dung một gian bếp chung có 10 bếp lò. Nếu có đúng 10 đầu bếp, ai cũng có một lò riêng, nấu một mạch tới xong. Nhưng nhét 40 đầu bếp vào cùng gian bếp đó, và giờ có một cái đồng hồ bấm giờ tàn nhẫn: cứ vài phút nó lại buộc mỗi người bước ra nhường lò cho người khác — dù họ đang giữa chừng món ăn, chẳng ai muốn dừng. Không ai tự nguyện rời lò cả; họ bị ép rời. Đó chính là chuyển ngữ cảnh bắt buộc — cái giá âm thầm bạn trả khi tạo nhiều luồng hơn số lõi, và nó xảy ra kể cả với công việc chẳng bao giờ "chờ" gì.
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: 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.
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. Đúng cái đồng hồ bấm giờ ép đầu bếp bước ra.
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ôi trường đo: container gcc:13 trên host ARM AArch64, nproc=10, workload CPU thuần công cố định mỗi luồng, đếm switch bằng getrusage. Con số tuyệt đối đổi theo máy; cái cần nhìn là hình dạng: nvcsw ~0 suốt, nivcsw tăng vọt khi luồng vượt lõi, và thời gian tăng chứ không giảm.)
Hai niềm tin đối nghịch, cùng sai
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, ngược lại nhưng cũng sai: "gọi sched_yield giúp chương trình chạy mượt/nhanh hơn". Khi không ai tranh, yield chỉ là một syscall vô ích (150 ns, không tạo song song); khi có 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.
Nối hai lầm tưởng lại thì thấy cùng một gốc: chuyển ngữ cảnh không phải thứ chỉ do bạn chủ động gây ra, và cũng không phải thứ bạn điều khiển được bằng yield. 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à thứ bạn thật sự nắm trong tay là số luồng, không phải một lời gọi nhường lượt. Nếu tin "không block thì không switch", bạn sẽ không hiểu vì sao một chương trình CPU thuần lại chậm đi khi thêm luồng — cứ tưởng thêm luồng là thêm sức mạnh.
Về đến code hằng ngày
Điều đầ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.)
Điều 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.
Chỗ này lý giải rất nhiều lựa chọn ở tầng cao. ForkJoinPool mặc định của Java, Executors.newWorkStealingPool, và Go runtime đều cỡ song song theo số lõi chứ không theo số việc — và Thread.yield() của Java mang đúng lời cảnh báo "chỉ là gợi ý, hiếm khi hữu ích" như sched_yield. Đây cũng là lý do sâu xa của mô hình M goroutine trên N luồng OS của Go (và virtual thread của Java 21): runtime tự ghép hàng nghìn tác vụ lên đúng GOMAXPROCS luồng OS, nên bạn viết nghìn goroutine mà không tạo nghìn luồng OS tranh nhau — né chính cái oversubscription bắt buộc đo ở trên. Và ở tầng server, đây là cả cuộc tranh luận "thread-per-request" (mỗi request một luồng OS, dễ oversubscribe) so với "event loop / async" (ít luồng, không block) — cùng một bài học gian-bếp-chung, phóng to lên tầng kiến trúc.
Muốn tự thấy thì 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. Vài phút đó 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.