Hình dung một trạm làm việc đổi ca. Đổi người đứng vào trạm thì nhanh — chỉ là ai đứng đó thôi. Nhưng nếu người vào ca dùng bộ đồ nghề khác, cái tốn thời gian thật không phải cú đổi chỗ, mà là lúc họ bày lại dụng cụ, mở lại sổ ghi chú của mình cho quen tay. Và tốn nhiều hay ít tùy đồ nghề của họ khác đến đâu — một phép thử "đổi ca để vặn đúng một con ốc" sẽ chẳng bao giờ lộ ra cái giá đó. Chuyển ngữ cảnh giữa hai tiến trình so với hai luồng hóa ra đúng câu chuyện này, và tôi suýt kết luận sai vì phép đo của mình chỉ vặn một con ốc.

Ở 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 đó.

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 cũ, đổ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. Đúng như trạm làm việc hiện đại dán nhãn từng bộ đồ nghề nên khỏi phải dọn sạch bảng dụng cụ mỗi lần đổi ca. 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ôi trường đo: container gcc:13 trên host ARM AArch64 (có ASID), ping-pong 1 byte qua pipe, ghim 1 lõi, mỗi số lấy sau warmup. Con số đổi theo máy; điều bền vững là chi phí trực tiếp của hai loại chuyển gần bằng nhau nhờ TLB gắn thẻ.)

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

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 trên phần cứng hiện đại.

Nhưng đây là tầng đo hớ thứ hai — của chính người đo — và nếu tôi dừng ở 1,09× tôi sẽ rơi vào niềm tin ngược cũng sai: "vậy tiến trình và luồng gần như nhau, tổng chi phí bằng nhau". 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 cũ; 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 — đúng người vào ca phải bày lại đồ nghề. 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. Đ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ì, đúng tinh thần đo tác động đúng cách.

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

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

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

Chỗ này giải thích nhiều lựa chọn kiến trúc mà "chi phí chuyển" không giải thích nổi. Chrome dùng một tiến trình mỗi tab không phải vì chuyển tab nhanh (nó không nhanh hơn), mà vì cách ly: một tab treo/bị hack không kéo sập cả trình duyệt — đúng lý do chọn tiến trình. Ngược lại, goroutine của Go, virtual thread của Java, async/await nói chung né hẳn chuyển ngữ cảnh của OS bằng cách lập lịch trong user-space (hàng nghìn tác vụ trên vài luồng OS) — vì cái đắt cần tránh không phải một cú chuyển đơn lẻ, mà là có quá nhiều cú chuyển cộng cache nguội. Và khi một server "CPU cao mà chả làm gì", vmstat cho thấy cs (context switch) khổng lồ thường là thủ phạm: quá nhiều luồng tranh quá ít lõi. Cùng một bài học đổi-ca-trạm-làm-việc: cú đổi thì rẻ, cái đắt là warm-up lặp lại quá nhiều lần.

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