Ta đã đo hai kênh truyền dữ liệu giữa tiến trình: pipeUnix socket. Cả hai có một điểm chung: dữ liệu phải copy qua nhân — bên gửi write() chép từ vùng nhớ của nó vào bộ đệm trong nhân, bên nhận read() chép từ nhân về vùng nhớ của mình. Có một cách bỏ qua hoàn toàn khâu copy đó: shared memory (bộ nhớ chia sẻ) — hai tiến trình ánh xạ cùng một trang vật lý, ghi bên này thì bên kia thấy ngay, không qua nhân. Ai cũng bảo đây là "IPC nhanh nhất". Tôi đo, và kết quả buộc tôi sửa lại niềm tin đó theo một cách thú vị.

Shared memory giữa tiến trình

Zero-copy: ý tưởng và cái bẫy

Shared memory tạo bằng shm_open + mmap(MAP_SHARED), hoặc gọn hơn là mmap một vùng ẩn danh MAP_SHARED|MAP_ANONYMOUS rồi fork — tiến trình con thừa hưởng cùng ánh xạ. Kết quả: một vùng nhớ mà cả hai tiến trình cùng đọc/ghi trực tiếp. Không write(), không read(), không copy — zero-copy. Nghe như thắng tuyệt đối so với pipe/socket phải chép hai lần.

Nhưng shared memory đưa cho bạn một vùng nhớ trần, không phải một kênh. Nó không có ranh giới thông điệp (bên kia không biết bạn vừa ghi bao nhiêu byte) và không tự đồng bộ (không có gì ngăn hai bên ghi đè lên nhau). Bạn phải tự dựng cơ chế đồng bộ — semaphore, atomic, hàng rào bộ nhớ — đúng những thứ Series 25 dạy. Hai tiến trình ghi cùng lúc mà không đồng bộ là một data race thẳng thừng. Pipe và socket cho bạn khung + đồng bộ miễn phí; shared memory bắt bạn tự lo.

Tôi đo truyền 256 MB giữa hai tiến trình (con sản xuất, cha tiêu thụ), theo từng khối 1 MB, đo cả hai bên chạm hết dữ liệu cho công bằng, trong container gcc:13. Tôi thử bốn cách: pipe, Unix socket, shared memory một-buffer (đồng bộ bằng semaphore), và shared memory dạng ring buffer lock-free.

Đo: zero-copy chưa đủ để thắng

pipe                    :  2,1 GB/s   (buffer mặc định 64KB nhỏ -> nhiều vòng)
shared mem, 1 buffer    : 14   GB/s   (zero-copy NHƯNG lockstep)
unix socket             : 21   GB/s   (copy, nhưng buffer nhân cho chồng lấn)
shared mem, ring lock-free: 24 GB/s   (zero-copy + chồng lấn = nhanh nhất)

Nhìn hai dòng giữa — đây là chỗ tôi phải dừng lại. Shared memory một-buffer đạt 14 GB/s, còn Unix socket đạt 21 GB/s. Cái "IPC nhanh nhất" thua một kênh phải copy hai lần! Vì sao? Vì bản shared memory của tôi dùng một buffer duy nhất, đồng bộ kiểu lockstep: con điền khối, ra hiệu, cha đọc khối, ra hiệu, con điền khối tiếp… Hai tiến trình không bao giờ chạy song song — trong lúc con ghi thì cha ngồi chờ, trong lúc cha đọc thì con ngồi chờ. Cộng thêm hai lần chuyển ngữ cảnh mỗi khối (mỗi lần ra hiệu qua semaphore là một lần ngủ→thức, đúng chi phí phần 5). Trong khi đó, Unix socket có một bộ đệm trong nhân: con cứ ghi tiếp vào đệm trong lúc cha rút ra — hai bên chồng lấn (pipeline). Cái copy của socket đắt, nhưng sự chồng lấn bù lại quá đủ.

Giờ nhìn dòng cuối. Khi tôi làm shared memory đúng cách — một ring buffer 16 ô, đồng bộ lock-free bằng atomic (mô hình SPSC của Series 25) — con và cha chạy song song trên các ô khác nhau, không lockstep, không copy. Lúc này shm đạt 24 GB/s, vượt cả socket. Đây mới là tiềm năng thật của zero-copy: nhưng nó chỉ hiện ra khi bạn cho hai bên chồng lấn.

Và pipe? Chậm nhất, 2,1 GB/s — không phải vì copy đắt hơn socket (chúng copy như nhau), mà vì bộ đệm pipe mặc định chỉ 64 KB. Ghi một khối 1 MB phải chia 16 lần, đầy đệm là chặn, chờ bên kia rút — rất nhiều vòng chặn/đánh thức. Cùng cơ chế copy, chỉ khác kích thước đệm, mà nhanh chậm gấp mười lần.

(Thành thật về phép đo: khối 1 MB nằm gọn trong cache nên đây là băng thông cache chứ không phải RAM; và trên container ảo hoá, bản ring lock-free dao động khá mạnh giữa các lần chạy — 12 đến 24 GB/s — vì busy-spin phụ thuộc việc hai tiến trình có được xếp lịch song song hay không. Con số tuyệt đối tùy môi trường; điều bền vững là thứ tự và lý do.)

Một lần tôi đo hớ: "zero-copy" không tự động thắng

Tôi vào đo với niềm tin sách vở: "shared memory là IPC nhanh nhất vì nó zero-copy". Đo lại thấy niềm tin đó đúng một nửa và sai một nửa. Đúng: bản ring lock-free quả thật nhanh nhất, và pipe (copy + đệm nhỏ) chậm hơn 10 lần cho thấy copy có giá thật. Sai: bản shared memory ngây thơ (một buffer, lockstep) lại thua Unix socket — dù nó zero-copy còn socket phải copy.

Bài học đo lường: zero-copy là điều kiện cần chứ không đủ. Bỏ được khâu copy chỉ là một nửa; nửa còn lại là để hai bên chồng lấn và đồng bộ rẻ. Một shared memory lockstep vứt đi lợi thế zero-copy bằng cách bắt hai bên chờ nhau và trả chuyển ngữ cảnh mỗi khối — y hệt cái thảm họa hàng đợi CAP=1 ta đã đo, nơi producer và consumer khóa bước nhau. Muốn shared memory thắng, bạn phải mang theo toàn bộ bộ công cụ của Series 25: ring buffer để có nhiều ô đệm, atomic/hàng rào thay cho semaphore ngủ, và batching để bớt số lần đồng bộ. Nếu tôi chỉ đo bản một-buffer rồi kết luận "shared memory thua socket", tôi đã sai; nếu tôi tin sách "shm luôn nhanh nhất" mà không đo, tôi cũng sai. Chỉ đo nhiều cài đặt mới ra bức tranh thật.

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

Hệ quả đầu tiên: shared memory không phải nút "nhanh hơn" bấm một cái là xong. Nó cho bạn trần tốc độ cao nhất (zero-copy), nhưng bắt bạn tự dựng đồng bộ đúng để chạm tới trần đó. Nếu bạn chỉ cần truyền dữ liệu vừa phải và muốn đúng, an toàn, ít code, một Unix socket thường nhanh bất ngờ và cho bạn khung + đồng bộ miễn phí — như phép đo cho thấy, nó còn vượt một shared memory làm ẩu.

Hệ quả thứ hai: nếu chọn shared memory, hãy làm cho hai bên chồng lấn. Dùng một ring buffer nhiều ô (không phải một buffer lockstep), đồng bộ bằng atomic/chỉ số lock-free thay vì semaphore ngủ mỗi khối, và gom lô nếu khối nhỏ. Đây đúng là kiến trúc mà các hệ hiệu năng cao (cơ sở dữ liệu, hàng đợi thông điệp như LMAX Disruptor, truyền khung video) dùng: shared memory + ring buffer lock-free. Zero-copy chỉ đáng công khi bạn khai thác được sự song song.

Hệ quả thứ ba là tinh thần đo lường: đừng tin nhãn "nhanh nhất"; đo cài đặt thật của bạn. Con số mang theo: shared memory là zero-copy (không copy qua nhân) nên có TRẦN tốc độ cao nhất, NHƯNG zero-copy là cần chứ chưa đủ — bản một-buffer lockstep chỉ 14 GB/s, THUA Unix socket 21 GB/s (socket pipelines qua buffer nhân, shm lockstep không chồng lấn + chuyển ngữ cảnh mỗi khối); chỉ ring buffer lock-free (Series 25) mới cho shm nhanh nhất ~24 GB/s. pipe chậm nhất (2 GB/s) do đệm mặc định 64KB nhỏ. Và shm không có ranh giới/đồng bộ — phải tự semaphore/atomic/hàng rào, đổi lại pipe/socket cho khung + đồng bộ miễn phí. Nhanh nhất tiềm năng không bằng nhanh nhất khi cài đặt cẩu thả.

Thử ba mươi giây

Nghĩ về một chỗ bạn (hay hệ bạn đọc) dùng shared memory để hai tiến trình trao dữ liệu: nó đồng bộ bằng gì — một cờ/semaphore một-buffer, hay một ring buffer nhiều ô? Nếu là một buffer và hai bên thay phiên chờ nhau, thử hình dung: trong lúc bên A ghi, bên B đang làm gì? Nếu câu trả lời là "ngồi chờ", bạn đang vứt đi phần lớn lợi ích zero-copy — và một Unix socket đơn giản có thể còn nhanh hơn, lại an toàn hơn. Ba mươi giây đó cho bạn thấy điều mà cái nhãn "shared memory = nhanh nhất" giấu đi: tốc độ của IPC không nằm ở chỗ có copy hay không, mà ở chỗ hai bên có chạy song song được không — và với shared memory, sự song song đó là việc bạn phải tự dựng, không phải thứ có sẵn.