phần đầu sê-ri đồng thời ta đo một chuyển ngữ cảnh tốn ~8,5 µs. Giờ hỏi kỹ hơn: chuyển giữa hai tiến trình có đắt hơn chuyển giữa hai luồng không? Trực giác — và nhiều sách — nói "đắt hơn nhiều, vì đổi tiến trình phải đổi không gian địa chỉ, kéo theo flush toàn bộ TLB". Nghe rất hợp lý. Tôi đo trong container gcc:13 (ARM), ghim cả hai bên vào một lõi để buộc chúng thay phiên nhau, và con số làm tôi phải xem lại niềm tin đó.

Chuyển ngữ cảnh: tiến trình vs luồng

Vì sao tiến trình có vẻ phải đắt hơn

Một chuyển ngữ cảnh là khi hệ điều hành cất trạng thái của cái đang chạy và nạp trạng thái của cái kế tiếp. Với hai luồng cùng một tiến trình, việc này gọn: chúng chung không gian địa chỉ, nên chỉ cần đổi tập thanh ghi và con trỏ stack — bảng trang (page table) giữ nguyên.

Với hai tiến trình khác nhau, có thêm một bước: mỗi tiến trình có bảng trang riêng, nên khi chuyển, CPU phải nạp lại thanh ghi trỏ tới bảng trang mới (trên ARM là TTBR, trên x86 là CR3). Lý thuyết cũ: vì các mục TLB (bộ đệm dịch địa chỉ ảo→vật lý) thuộc về bảng trang , đổi bảng trang buộc phải flush sạch TLB — và sau đó mọi truy cập bộ nhớ đều miss TLB, phải đi bộ bảng trang lại. Đó là nguồn của niềm tin "chuyển tiến trình đắt hơn nhiều".

Đo: chênh chỉ 1,09 lần

Tôi dựng một ping-pong: hai bên chuyền một byte qua lại qua pipe, mỗi lần chuyền buộc một chuyển ngữ cảnh. Chạy hai phiên bản — hai tiến trình (qua pipe) và hai luồng (qua pipe, chung không gian địa chỉ) — cùng ghim vào một lõi, đo ns mỗi lần chuyển:

chuyển ngữ cảnh giữa 2 TIẾN TRÌNH : 579 ns
chuyển ngữ cảnh giữa 2 LUỒNG      : 532 ns
-> tiến trình / luồng = 1,09×  (chênh chỉ 47 ns)

Chỉ 1,09 lần — chênh vỏn vẹn 47 ns, không phải "đắt hơn nhiều". Chuyển giữa hai tiến trình gần như bằng chuyển giữa hai luồng. Niềm tin "đổi không gian địa chỉ kéo theo flush TLB đắt đỏ" không hiện ra trong con số.

Vì sao? Vì các CPU hiện đại (ARM64 với ASID, x86-64 với PCID) gắn cho mỗi không gian địa chỉ một định danh riêng, và đánh dấu mỗi mục TLB bằng định danh đó. Nhờ vậy, các mục TLB của cả hai tiến trình cùng tồn tại trong TLB — đổi bảng trang chỉ cần đổi định danh hiện hành, không cần flush. Cái phạt lớn từng làm chuyển tiến trình đắt trên phần cứng cũ (những năm TLB chưa gắn thẻ) phần lớn đã biến mất. 47 ns chênh lệch là công nạp lại thanh ghi bảng trang và ít sổ sách, không phải một lần flush toàn bộ.

Một lần tôi đo hớ: cái phạt lớn là gián tiếp, không trực tiếp

Tôi vào đo với niềm tin sách vở: "chuyển giữa hai tiến trình đắt hơn nhiều vì đổi không gian địa chỉ phải flush TLB". Phép đo bác bỏ phần "nhiều": trực tiếp chỉ chênh 1,09 lần, vì ASID/PCID đã bỏ nhu cầu flush.

Nhưng đây là chỗ phải thành thật, và cũng là tầng đo hớ thứ hai — của chính người đo. Vi benchmark của tôi dùng working set tí xíu (chuyền một byte), nên nó chỉ đo chi phí trực tiếp của chuyển ngữ cảnh: cất/nạp thanh ghi, đổi bảng trang. Nó không đo chi phí gián tiếp — thứ mới là khác biệt thật giữa tiến trình và luồng ngoài đời. Sau khi chuyển sang một tiến trình khác với working set lớn, cache và TLB đầy dữ liệu của tiến trình ; tiến trình mới chạy vào một cache/TLB "nguội", chịu một loạt miss cho tới khi nạp lại dữ liệu của mình. Với hai luồng cùng tiến trình, chúng thường chia sẻ nhiều dữ liệu và cache line hơn, nên cache ít bị "ô nhiễm" hơn. Chi phí gián tiếp đó có thể lớn hơn nhiều lần 47 ns — nhưng nó phụ thuộc working set, không phải một hằng số, nên một microbenchmark byte-đơn không thể bắt được.

Bài học đo lường: đo được "chi phí trực tiếp" bằng nhau không có nghĩa "tổng chi phí" bằng nhau — phải biết microbenchmark của mình không đo cái gì. Nếu tôi dừng ở "1,09×, tiến trình và luồng gần như nhau", tôi đã khái quát sai: đúng cho phần trực tiếp, sai cho bức tranh đầy đủ khi working set lớn. Con số 1,09× là thật và hữu ích — nhưng chỉ khi kèm cảnh báo về những gì nó bỏ qua, đúng tinh thần đo tác động đúng cách mà sê-ri trước đã đo.

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

Hệ quả đầu tiên: đừng chọn luồng thay tiến trình chỉ vì "chuyển ngữ cảnh rẻ hơn". Trên phần cứng hiện đại, chi phí chuyển trực tiếp gần như bằng nhau (1,09×). Lý do thật để chọn luồng là chia sẻ dữ liệu dễ (chung không gian địa chỉ) và tạo rẻ hơn (phần 2 đã đo) — còn lý do chọn tiến trình là cách ly (một tiến trình sập không kéo cái khác). Quyết định theo cách ly vs chia sẻ, không theo một huyền thoại về chi phí chuyển.

Hệ quả thứ hai: chi phí thật của chuyển ngữ cảnh thường là gián tiếp — cache/TLB nguội. Nếu một hệ thống chuyển ngữ cảnh quá thường (quá nhiều luồng/tiến trình tranh một ít lõi), cái giết hiệu năng không phải 500 ns mỗi lần chuyển, mà là hàng nghìn cache miss sau mỗi lần chuyển khi working set không vừa cache. Giảm số lần chuyển (ít luồng hơn, ghim ái lực CPU) thường đáng hơn tối ưu bản thân cú chuyển.

Hệ quả thứ ba là tinh thần đo lường: biết microbenchmark của bạn KHÔNG đo cái gì. Con số mang theo: chuyển ngữ cảnh giữa hai tiến trình chỉ đắt hơn hai luồng 1,09× (579 vs 532 ns, chênh 47 ns) — KHÔNG "đắt hơn nhiều" như sách cũ — vì CPU hiện đại gắn ASID/PCID cho mỗi không gian địa chỉ nên đổi bảng trang không cần flush TLB; nhưng đó chỉ là chi phí TRỰC TIẾP, còn khác biệt thật ngoài đời là GIÁN TIẾP (cache/TLB nguội sau khi đổi sang tiến trình working set lớn), tùy working set và không đo được ở vi benchmark byte-đơn này. Bằng nhau ở phần đo được không có nghĩa bằng nhau ở tổng.

Thử ba mươi giây

Chạy vmstat 1 (hay pidstat -w 1) trên một máy đang bận và nhìn cột cs — số chuyển ngữ cảnh mỗi giây. Trên một hệ thống tải cao, con số này có thể lên hàng trăm nghìn. Nhân với ~500 ns (chi phí trực tiếp bạn vừa thấy) để ước lượng phần CPU thuần cho việc chuyển — rồi nhớ rằng con số thật còn cao hơn vì chi phí gián tiếp (cache nguội) mà cs không cho thấy. Nếu cs cao bất thường, đó là dấu hiệu quá nhiều luồng tranh quá ít lõi, hoặc quá nhiều đánh thức nhỏ lẻ — và cách chữa là giảm số lần chuyển (gộp việc, ít luồng hơn, ghim ái lực), không phải mong mỗi cú chuyển rẻ đi. Ba mươi giây đọc cs đó cho bạn biết hệ thống của mình có đang đốt CPU cho việc chuyển thay vì việc thật hay không.