Trong hai bài trước ta thấy pipeUnix 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 đó: hai tiến trình cùng nhìn vào một khối bộ nhớ, dữ liệu chẳng phải "đi" đâu cả. Nghe như nó phải là cách IPC nhanh nhất. Tôi đo — và bản đầu tiên của tôi lại chậm hơn cả pipe. Đây là câu chuyện vì sao.

Bộ nhớ chia sẻ

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.

Một lần tôi đo hớ: 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả thứ hai là bài học tổng quát tôi rút từ cú đo hớ: 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.

Hệ quả thứ ba: chọn công cụ theo nhu cầu, không theo bảng xếp hạng tốc độ. Cần chuyển khối dữ liệu lớn, liên tục, giữa các tiến trình cùng máy và sẵn lòng viết mã đồng bộ cẩn thận (cơ sở dữ liệu, engine trò chơi, đường ống tính toán) — bộ nhớ chia sẻ đáng công. Cần một kênh đơn giản, có sẵn khung và EOF, không muốn tự lo đua tranh — pipe hay socket gần như luôn đủ nhanh và ít lỗi hơn nhiều. Con số mang theo: 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) — nhưng chỉ khi sản xuất và tiêu thụ chồng lấn; bản nối tiếp 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 đủ.

Thử ba mươi giây

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.