Hình dung một nhân viên ngồi bàn, phải luân phiên làm nhiều việc. Để đổi từ việc này sang việc kia, họ phải cất hết giấy tờ đang bày ra (lưu thanh ghi, con trỏ) rồi lấy hồ sơ của việc kế tiếp — cái động tác dọn-và-bày đó tốn thời gian thật, không bao giờ miễn phí. Sách giáo khoa còn dạy thêm: đổi sang một dự án hoàn toàn khác (một tiến trình khác) thì chậm hơn hẳn, vì phải dọn sạch cả tủ tài liệu tham khảo (xả TLB) và tra lại từ đầu. Nghe hợp lý. Nhưng bàn làm việc hiện đại dán nhãn mỗi cuốn sách thuộc dự án nào — nên đổi dự án chỉ cần đổi cái nhãn "giờ tôi làm dự án B", sách cũ vẫn nằm nguyên chờ. Tôi đo, và đúng như cái bàn dán nhãn đó: đổi tiến trình bằng đổi luồng.
Ở 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.
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 — cất giấy tờ việc cũ, bày giấy tờ việc mới. 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ôi trường đo: container gcc:13 trên host ARM AArch64, ping-pong qua pipe 100.000 vòng có warmup, chia đôi round-trip. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: một lần chuyển tốn hàng µs — không tức thì — và trên phần cứng có TLB gắn nhãn, tiến trình xấp xỉ luồng.)
Hai niềm tin đối nghịch, cùng lệch
Tôi vào đo với hai niềm tin. Niềm tin thứ nhất: "chuyển ngữ cảnh gần như tức thì, chỉ là hoán vài thanh ghi". Sai — 8,5 µs là hàng nghìn chu kỳ, một chi phí thật. Niềm tin thứ hai, kinh điển hơ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 (sách cũ vẫn nằm trên kệ, chỉ đổi cái nhãn dự án). 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.
Hai niềm tin gặp nhau ở một bài học: 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) — gồm cả việc "đi gặp quản lý nhận việc kế tiếp", không chỉ động tác dọn-bày giấy tờ. 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ề đến code hằng ngày
Điều đầ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.
Điều thứ hai, 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à "thrashing lịch": thêm luồng làm chậm đi thay vì nhanh lên.
Chỗ này cái con số 8,5 µs chính là lý do tồn tại của cả một thế hệ mô hình đồng thời hiện đại, và hiểu nó giải thích vì sao Go/async lại nhanh. Chuyển ngữ cảnh của hệ điều hành đắt vì phải vào nhân — nên các runtime né hẳn nó bằng lập lịch trong không gian người dùng: Go chạy hàng nghìn goroutine trên vài luồng OS (mô hình M:N), và khi một goroutine chặn, runtime đổi goroutine ngay trong userspace — chỉ lưu/nạp một mẩu trạng thái nhỏ, không có chuyến vào nhân 8,5 µs. Đó là lý do "một goroutine cho mỗi kết nối" khả thi tới hàng trăm nghìn, khác hẳn "một luồng OS cho mỗi kết nối". Java 21 virtual thread (Loom) làm y hệt: park một luồng ảo mà không chặn luồng nền. Và mọi async/await (Kotlin, Rust, C#, Node event loop) đều là cùng ý tưởng — multiplex nhiều tác vụ trên ít luồng OS để tránh cái giá chuyển ngữ cảnh của nhân. Mẫu số chung: OS context switch ~8,5 µs là con số đắt mà cả ngành thiết kế để tránh, bằng cách chuyển việc lập lịch lên tầng ứng dụng.
Muốn tự thấy thì 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. Vài phút đó 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ô.