bài trước ta đo chi phí tạo một luồng. Nhưng một luồng, sau khi tạo, không chạy một mình — CPU liên tục chuyển qua lại giữa các luồng và tiến trình, và mỗi lần chuyển tốn thời gian. Đây là chi phí ẩn nằm sau mọi thứ liên quan tới đồng thời: mỗi lần một luồng bị chặn, một khóa bị tranh chấp, một I/O phải đợi, hệ điều hành chuyển ngữ cảnh sang luồng khác. Câu hỏi: một lần chuyển tốn bao nhiêu, và giữa tiến trình có đắt hơn giữa luồng không? Tôi đo, và cả hai câu trả lời đều lệch với sách.

Đo chi phí chuyển ngữ cảnh

Chuyển ngữ cảnh là gì

Khi bộ lập lịch của hệ điều hành quyết định cho một luồng khác chạy, CPU phải lưu trạng thái của luồng hiện tại (mọi thanh ghi, con trỏ lệnh, con trỏ ngăn xếp) vào bộ nhớ, rồi nạp trạng thái của luồng kế tiếp. Nếu chuyển sang một tiến trình khác, còn phải đổi cả không gian địa chỉ — nạp bảng trang mới, và theo lý thuyết sách giáo khoa là xả bộ nhớ đệm dịch địa chỉ (TLB). Toàn bộ việc này chạy trong nhân, xen giữa hai đoạn code người dùng, nên nó là chi phí thuần bạn không thấy trong code của mình.

Để đo, tôi dựng một ping-pong: hai bên thay phiên đánh thức nhau qua một cặp ống (pipe). Bên A ghi một byte rồi chặn đọc; bên B đọc byte đó, ghi trả lại, rồi chặn đọc. Mỗi vòng khứ hồi (round-trip) buộc hai lần chuyển ngữ cảnh (A→B rồi B→A). Đo thời gian trung bình một round-trip qua 100.000 vòng (có warmup, kiểm mã trả về syscall), rồi chia hai để ra chi phí một lần chuyển. Tôi chạy hai biến thể: ping-pong giữa hai luồng (cùng không gian địa chỉ) và giữa hai tiến trình (khác không gian).

Đo: 8,5µs mỗi lần, và tiến trình bằng luồng

Kết quả trong container gcc:13 (AArch64), ổn định qua nhiều lần chạy:

LUỒNG    (cùng không gian địa chỉ) : round-trip ~17 µs -> ~8,5 µs/lần chuyển
TIẾN TRÌNH (khác không gian)       : round-trip ~17 µs -> ~8,5 µs/lần chuyển
tỉ lệ tiến trình / luồng           : ~1,0 lần (bằng nhau!)

Hai phát hiện, và cả hai đều trái với trực giác. Thứ nhất: mỗi lần chuyển tốn khoảng 8,5 µs — hàng nghìn chu kỳ CPU. Không "tức thì" chút nào. (Con số này gồm cả đường syscall của pipe và bộ lập lịch — nó là chi phí bàn giao thực tế của một lần đánh thức, không phải chỉ phần swap thanh ghi thuần, và đó chính là cái đắt bạn phải trả trong thực tế.) Thứ hai, bất ngờ hơn: chuyển giữa hai tiến trình đo ra bằng chuyển giữa hai luồng, tỉ lệ ~1,0.

Một lần tôi đo hớ: TLB không bị xả sạch

Tôi vào đo với niềm tin sách giáo khoa kinh điển: "chuyển ngữ cảnh giữa tiến trình đắt hơn hẳn giữa luồng, vì đổi không gian địa chỉ buộc xả sạch TLB, và mọi truy cập bộ nhớ sau đó phải dịch lại từ đầu". Theo lý đó, biến thể tiến trình phải chậm hơn rõ rệt. Đo ra chúng bằng nhau.

Vì sao? Vì TLB hiện đại gắn nhãn ASID (Address Space ID — mã định danh không gian địa chỉ). Mỗi mục TLB được đánh dấu thuộc về không gian địa chỉ nào, nên khi đổi sang tiến trình khác, CPU không cần xả sạch TLB — nó chỉ đơn giản dùng ASID mới, và các mục cũ vẫn nằm đó chờ khi quay lại. Cái "phải xả TLB" của sách giáo khoa là chuyện của phần cứng cũ, không gắn nhãn. Trên ARM (và x86 hiện đại với PCID), đổi không gian địa chỉ gần như miễn phí. Thêm nữa, phần lớn 8,5 µs là đường syscall đánh thức (pipe) và bộ lập lịch, những thứ giống hệt nhau ở cả hai biến thể; phần khác biệt duy nhất — nạp bảng trang — chỉ là một mẩu nhỏ bị lấn át.

Bài học đo lường: một "sự thật" trong sách có thể đã lỗi thời vì phần cứng tiến hóa. "Process switch xả TLB nên chậm" từng đúng, giờ không còn, và chỉ đo mới cho thấy điều đó. Nếu tôi tin sách mà không đo, tôi đã viết một con số sai với phần cứng hôm nay.

Con số này đo cái gì, chính xác

Cần thành thật về việc ping-pong đo cái gì, đúng tinh thần đo lường vi mô đúng cách. Con số ~8,5 µs không phải chi phí "thuần túy phần cứng" của việc hoán đổi thanh ghi — cái đó nhỏ hơn nhiều, cỡ vài trăm nano giây. Ping-pong qua pipe đo trọn đường bàn giao: một luồng gọi write (vào nhân), nhân đánh thức luồng kia và gọi bộ lập lịch, luồng kia trở về từ read (ra khỏi nhân). Nghĩa là 8,5 µs gồm hai chuyến vào/ra nhân của syscall pipe cộng công của bộ lập lịch, chứ không chỉ phần "chuyển ngữ cảnh" theo nghĩa hẹp. Đó lại chính là con số hữu ích: trong thực tế, một lần luồng ngủ chờ rồi được đánh thức luôn kéo theo cả đường syscall đó, nên 8,5 µs là chi phí thực tế của một lần bàn giao, không phải một con số lý tưởng hóa trong phòng thí nghiệm. Biết một phép đo bao gồm những gì cũng quan trọng như biết con số của nó.

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

Hệ quả đầu tiên: giảm số lần chuyển ngữ cảnh không cần thiết. Vì mỗi lần chuyển tốn ~8,5 µs, các nguồn gây chuyển liên tục — khóa bị tranh chấp nặng (luồng liên tục ngủ/thức), I/O chặn theo từng byte nhỏ, hay pattern "một luồng làm một chút rồi bàn giao" — cộng dồn thành chi phí lớn. Gom việc (batching), giảm tranh chấp khóa, và dùng I/O theo lô đều là cách cắt số lần chuyển.

Hệ quả thứ hai, và nối thẳng với bài chi phí tạo: đừng tạo quá nhiều luồng. Khi số luồng sẵn sàng chạy vượt xa số lõi, hệ điều hành phải liên tục chuyển qua lại để chia thời gian — và mỗi lần chuyển là 8,5 µs bị đốt cho việc quản lý thay vì làm việc. Đó là hiện tượng "thrashing lịch": thêm luồng làm chậm đi thay vì nhanh lên. Số luồng hợp lý (thường quanh số lõi cho việc nặng CPU) tránh được điều này — một chủ đề ta sẽ đo kỹ ở phần sau.

Hệ quả thứ ba là tinh thần đo lường: kiểm lại những "sự thật" bạn học, vì phần cứng đổi. Con số mang theo: mỗi lần chuyển ngữ cảnh tốn ~8,5 µs (hàng nghìn chu kỳ) — không tức thì; và chuyển giữa tiến trình đo ra BẰNG giữa luồng (~1,0x), KHÔNG đắt hơn, vì TLB gắn nhãn ASID tránh xả sạch và chi phí bị đường syscall + bộ lập lịch lấn át; nhiều lần chuyển (tranh chấp khóa, I/O chặn, quá nhiều luồng) cộng dồn thành chi phí thật. Đừng tin "process switch chậm hơn nhiều" cho tới khi bạn đo trên phần cứng của mình.

Thử ba mươi giây

Trên một máy Linux, chạy vmstat 1 và nhìn cột cs (context switches per second) — bạn sẽ thấy hệ thống nhàn rỗi vẫn chuyển hàng nghìn lần mỗi giây, và một tải nặng có thể lên hàng trăm nghìn. Nhân số đó với ~vài µs mỗi lần, bạn ước lượng được bao nhiêu CPU đang bị đốt cho việc chuyển. Rồi thử viết một ping-pong hai luồng qua pipe, đo round-trip, chia hai — bạn sẽ thấy con số micro giây thật của một lần chuyển. Ba mươi giây đó biến "chuyển ngữ cảnh" từ một khái niệm mơ hồ thành một con số bạn có thể đưa vào quyết định thiết kế: bao nhiêu luồng, gom việc cỡ nào, khóa mịn hay thô.