Hai người chuyền gạch xây tường: cách nhanh nhất không phải là người thứ nhất khuân cả đống gạch tới chân tường rồi mới đứng nghỉ cho người thứ hai bắt đầu xếp — mà là vừa chuyền vừa xếp, hai tay cùng bận một lúc. Bộ nhớ chia sẻ cho hai tiến trình "cùng chạm một đống gạch" mà không phải khuân qua khuân lại, nghe như đương nhiên thắng; nhưng nếu vẫn để một người đứng chờ người kia làm xong, cái lợi đó tan biến. Đó đúng là cú vấp của tôi. Trong hai bài trước ta thấy pipe và Unix socket truyền dữ liệu giữa hai tiến trình ở mức 10–12 GB/s, nhưng cả hai đều bắt nhân chép dữ liệu từ tiến trình này sang tiến trình kia. Bộ nhớ chia sẻ hứa hẹn bỏ luôn cú chép đó. Tôi đo — và bản đầu tiên của tôi lại chậm hơn cả pipe.
Bộ nhớ chia sẻ hoạt động thế nào
Bình thường mỗi tiến trình có không gian địa chỉ riêng, cô lập hẳn với tiến trình khác. Bộ nhớ chia sẻ phá vỡ sự cô lập đó một cách có kiểm soát: hai tiến trình gọi mmap với cờ MAP_SHARED lên cùng một đối tượng (một vùng MAP_ANONYMOUS thừa kế qua fork, hoặc một file shm_open mở theo tên), và kết quả là cả hai bảng trang cùng trỏ vào một khối trang vật lý. Tiến trình A ghi vào địa chỉ đó, tiến trình B đọc địa chỉ của nó — và thấy ngay dữ liệu A vừa ghi, vì đó là cùng một ô nhớ vật lý.
Điểm mấu chốt: sau khi lập ánh xạ, việc trao đổi dữ liệu là truy cập bộ nhớ thuần túy — không read(), không write(), không một lời gọi hệ thống nào, không một cú chép nào qua nhân. Dữ liệu không di chuyển; chỉ có hai tiến trình cùng nhìn một chỗ. Đó là lý do người ta gọi nó là cách IPC nhanh nhất.
Nhưng cái giá của sự trần trụi đó là: bộ nhớ chia sẻ không cho bạn gì ngoài chỗ nhớ. Không có ranh giới thông điệp, không có EOF, không có cơ chế "đầy thì chặn" như pipe. Muốn hai bên không giẫm chân nhau — đọc trước khi ghi, hay ghi đè khi bên kia chưa đọc xong — bạn phải tự dựng cơ chế đồng bộ. Và chính chỗ này tôi vấp.
Quên chồng lấn thì không-chép cũng vô nghĩa
Tôi tin chắc "không chép nên nhanh nhất", nên viết bản đơn giản nhất: cấp một vùng chung 256MB, tiến trình cha ghi toàn bộ 256MB vào đó, bật một cờ "xong", rồi tiến trình con đọc và cộng toàn bộ 256MB. Ghim cha vào lõi 0, con vào lõi 1. Đo:
shm nối tiếp (ghi hết rồi mới đọc): 2519 MB/s
pipe (chồng lấn, nhân chép 2 lần) : 2755 MB/s
Bộ nhớ chia sẻ thua pipe. Con số này làm tôi khựng lại: sao cái "không chép" lại chậm hơn cái "chép hai lần"?
Lỗi không nằm ở bộ nhớ chia sẻ, mà ở cách tôi dùng nó. Bản của tôi nối tiếp hai bên: con phải ngồi chờ trong khi cha ghi cho xong cả 256MB, rồi cha xong con mới bắt đầu đọc. Tại mỗi thời điểm chỉ một lõi làm việc, lõi kia ngồi không — tôi có hai lõi mà dùng như một. Trong khi đó pipe chồng lấn một cách tự nhiên: cha ghi lô 64KB vào pipe, con đọc lô trước đó ra cùng lúc, hai lõi chạy song song. Cái lợi song song của pipe lớn hơn cái hại phải chép hai lần, nên nó thắng bản shm nối tiếp của tôi.
Đây đúng là cái bẫy "một con số vô lý tố cáo mình đo nhầm". "Không chép" đáng lẽ nhanh hơn, nhưng nó chỉ là một nửa câu chuyện; nửa kia là hai bên có chạy chồng lấn không. Tôi đã tối ưu nửa mình để ý (bỏ cú chép) và bỏ quên nửa quan trọng không kém (song song hóa sản xuất–tiêu thụ).
Sửa: vòng đệm chồng lấn, và không-chép mới tỏa sáng
Cách sửa là làm cho cha và con chạy chồng lấn giống pipe, nhưng vẫn giữ lợi thế không-chép. Tôi thay vùng-256MB-một-cục bằng một vòng đệm SPSC (một-người-ghi–một-người-đọc) gồm 8 ô, mỗi ô 64KB, nằm trong bộ nhớ chung, kèm một cờ full cho mỗi ô. Cha điền một ô rồi bật cờ và sang ô kế; con chờ cờ, cộng dữ liệu, tắt cờ và sang ô kế. Giờ cha ghi ô này trong khi con đọc ô trước — đúng kiểu chồng lấn của pipe, nhưng dữ liệu vẫn nằm nguyên một chỗ, không chép qua nhân. Đo lại:
shm vòng đệm (chồng lấn, 0 chép): 4004 MB/s <- nhanh nhất
pipe (chồng lấn, nhân chép) : 2755 MB/s
Bây giờ bộ nhớ chia sẻ vượt hẳn pipe (4004 so với 2755 MB/s). Cùng một sự chồng lấn, nhưng shm không phải trả hai cú chép qua nhân, nên nó nhanh hơn. Và bằng chứng của "không chép" hiện rõ dưới strace: đếm số lệnh read/write mà mỗi cách dùng để chuyển 256MB:
shm vòng đệm: 0 lệnh read/write ở đường dữ liệu
pipe : ~8192 lệnh (4096 write + 4096 read)
Pipe gọi hơn tám nghìn lời gọi hệ thống để dời 256MB; bộ nhớ chia sẻ gọi không lệnh nào — nó chỉ đọc và ghi bộ nhớ, đồng bộ bằng vài cờ. Đó là bản chất của "không chép, không syscall". Nhưng nhớ: con số 4004 chỉ đạt được sau khi tôi tự tay dựng cái vòng đệm và cờ mà pipe vốn tặng kèm.
Về đến code hằng ngày
Điều đầu tiên: bộ nhớ chia sẻ nhanh nhất, nhưng "nhanh nhất" đi kèm việc tự làm phần khó. Pipe và socket cho bạn ngữ nghĩa hoàn chỉnh miễn phí: đệm, chặn khi đầy/rỗng, đánh thức đúng lúc, báo hết luồng. Bộ nhớ chia sẻ chỉ cho bạn chỗ nhớ; toàn bộ việc đồng bộ — biết khi nào an toàn để đọc, khi nào ghi, đánh thức bên kia — là của bạn. Làm sai một chút là một cuộc đua tranh dữ liệu âm thầm: đọc phải nửa dữ liệu cũ nửa mới, không lỗi, không log, chỉ sai. Với vòng đệm SPSC còn phải để ý thứ tự bộ nhớ giữa hai lõi (cần rào chắn/atomic để cờ full thấy được dữ liệu đã ghi trước nó) — chỗ này tôi giữ đơn giản trong bài đo, nhưng mã thật phải dùng atomic.
Điều thứ hai, bài học tổng quát tôi rút từ cú vấp: bỏ cú chép chỉ là một nửa; nửa kia là chồng lấn sản xuất–tiêu thụ. Một kênh nhanh không tự động cho bạn thông lượng cao nếu hai đầu vẫn tuần tự chờ nhau. Ngược lại, một kênh "chậm hơn" nhưng để hai bên chạy song song có thể thắng. Khi tối ưu một đường ống dữ liệu, hỏi cả hai câu: có chép thừa không, và hai đầu có chạy đồng thời không — bỏ sót câu thứ hai thì tôi đã suýt kết luận ngược hẳn.
Và cả hai bài học — "zero-copy chỉ đáng khi có chồng lấn" và "kênh nhanh trần trụi bắt bạn tự lo đồng bộ" — vọng thẳng vào các thư viện hiệu năng cao mà bạn dùng. Java NIO có MappedByteBuffer (chính là mmap file) và các thư viện như Chronicle Queue / Aeron dùng shared memory + ring buffer SPSC y hệt ở đây để IPC micro giây — và chúng phải dùng VarHandle/memory fence đúng cái "thứ tự bộ nhớ" tôi nhắc. Netty tối ưu zero-copy (FileRegion, CompositeByteBuf) nhưng throughput thật vẫn phụ thuộc pipeline có chồng lấn không. Ngay cả CSDL và Kafka: sendfile zero-copy giúp, nhưng cái làm throughput cao là batching + pipelining (producer gửi lô sau khi lô trước còn đang bay), đúng nguyên tắc chồng lấn. Mẫu số chung: giảm chi phí mỗi đơn vị (bỏ chép, bỏ syscall) và song song hóa hai đầu là hai trục độc lập của throughput — tối ưu một trục mà quên trục kia có thể cho kết quả tệ hơn cả bản chưa tối ưu, nên phải đo cả hai. Gói lại bằng số: bộ nhớ chia sẻ chuyển 256MB với 0 lời gọi hệ thống và đạt 4004 MB/s, vượt pipe (2755, ~8192 syscall) — nhưng chỉ khi sản xuất và tiêu thụ chồng lấn qua vòng đệm 8 ô 64KB; bản nối tiếp "ghi hết rồi mới đọc" của tôi chỉ 2519 MB/s, thua cả pipe. Không-chép là điều kiện cần, chồng lấn là điều kiện đủ.
Muốn tự thấy thì trên máy Linux, gõ ls -l /dev/shm — đó là một hệ thống tệp nằm hoàn toàn trong RAM (tmpfs), và mỗi file bạn thấy ở đó là một vùng bộ nhớ chia sẻ mà một tiến trình nào đó đã shm_open rồi mmap. Nhiều chương trình lớn (PostgreSQL, Chrome, các thư viện tính toán) để dữ liệu chung của chúng ở đây. Tạo thử một cái: dd if=/dev/zero of=/dev/shm/test bs=1M count=100 tạo một vùng 100MB trong RAM tức thì, không chạm đĩa; rm /dev/shm/test để dọn. Nhìn kích thước và tốc độ tạo, bạn sẽ cảm nhận vì sao "chia sẻ thẳng bộ nhớ" là đáy nhanh nhất của IPC — miễn là bạn chịu tự lo phần đồng bộ ở trên.