Hai tiến trình muốn trao dữ liệu cho nhau thì làm thế nào? Cách quen thuộc nhất là pipe — bên này write, bên kia read. Nhưng có một cách được đồn là nhanh hơn hẳn: bộ nhớ chia sẻ (shared memory), nơi cả hai tiến trình cùng ánh xạ một vùng RAM và đọc ghi thẳng vào đó, không cần nhân sao chép. Câu hỏi thực tế: shared memory nhanh hơn pipe bao nhiêu, và cái nhanh đó có "miễn phí" không? Bài này đo cả hai bằng cách chuyển 1 GB giữa hai tiến trình — và vấp một con số đẹp tới mức đáng ngờ đã suýt khiến tôi kết luận sai.
Pipe và bộ nhớ chia sẻ khác nhau ở đâu
Khi bạn đẩy dữ liệu qua pipe, nó đi một đường vòng: bên ghi write khiến nhân sao chép dữ liệu từ vùng nhớ người dùng vào một bộ đệm trong nhân; bên đọc read khiến nhân sao chép lần nữa từ bộ đệm đó ra vùng nhớ người dùng của nó. Hai lần sao chép, cộng một cặp lời gọi hệ thống, cho mỗi khối dữ liệu. Đổi lại sự tốn kém đó, pipe cho ta rất nhiều tiện lợi miễn phí: nhân tự đồng bộ (bên đọc tự chặn khi chưa có dữ liệu, bên ghi tự chặn khi bộ đệm đầy) và tự đệm để hai bên có thể chạy chồng lấn.
Bộ nhớ chia sẻ đi thẳng: cả hai tiến trình ánh xạ cùng một vùng khung RAM (qua mmap với cờ MAP_SHARED, hoặc shm_open), rồi bên ghi viết thẳng vào đó và bên đọc đọc thẳng ra — không có cú sao chép nào qua nhân. Về lý thuyết đây là cách IPC nhanh nhất cho dữ liệu lớn. Nhưng nó có một cái bẫy đi kèm: shared memory không tự đồng bộ. Nhân không biết khi nào bên ghi đã viết xong hay bên đọc đã đọc xong; bạn phải tự dựng cơ chế báo hiệu (semaphore, futex, eventfd...) để hai bên không giẫm chân nhau. Cái tự do "không qua nhân" đi kèm trách nhiệm "tự lo đồng bộ" — và chính đó là nơi phép đo trở nên thú vị.
Đo: từ 2,1 tới 9,8 GB/s, tùy cách làm
Tôi viết một chương trình fork hai tiến trình rồi chuyển 1 GB giữa chúng theo khối 64 KB, mỗi đầu tính tổng kiểm để chắc dữ liệu qua đúng. Kết quả (trung vị 3 lần) khác nhau đến bất ngờ tùy cách dựng:
| Cách | Băng thông |
|---|---|
| PIPE (2 lần sao chép qua nhân) | 3,1 GB/s |
| SHM một đệm + đồng bộ | 2,1 GB/s |
| SHM ring 16 đệm (đồng bộ đúng) | 9,8 GB/s |
Hai dòng shared memory kể hai câu chuyện trái ngược. Bản một đệm + đồng bộ — một vùng đệm duy nhất, hai semaphore báo "đầy/trống", bên ghi và bên đọc thay phiên nhau — chỉ đạt 2,1 GB/s, chậm hơn cả pipe. Lý do: với một đệm duy nhất, hai bên buộc phải nối tiếp tăm tắp — bên ghi điền xong thì ngồi chờ bên đọc lấy, bên đọc lấy xong thì ngồi chờ bên ghi điền, không ai chồng lấn với ai, cộng thêm hai thao tác semaphore (mỗi cái là một lời gọi futex) cho mỗi khối 64 KB. Pipe, nhờ nhân đệm sẵn, lại cho hai bên chạy song song một phần, nên nhanh hơn.
Bản ring 16 đệm thì khác hẳn: 16 ô đệm xoay vòng cho phép bên ghi chạy trước tới 16 khối trong khi bên đọc bám theo, hai bên chồng lấn thật sự, và bên đọc tiêu thụ dữ liệu ngay tại chỗ (không sao chép ra). Kết quả 9,8 GB/s — nhanh gấp khoảng 3 lần pipe. Đây mới là lợi thế thật của shared memory: né được hai cú sao chép qua nhân, với điều kiện đồng bộ được thiết kế cho hai bên chạy song song.
Một lần tôi đo hớ: con số 15 GB/s và cái checksum dối
Trước khi ra được bảng gọn ở trên, tôi vấp một con số làm mình suýt viết cả một kết luận sai. Khi thử bản shared memory bỏ đồng bộ (không semaphore, bên ghi cứ điền, bên đọc cứ đọc), tôi đo được 15 GB/s — và tổng kiểm vẫn báo "KHỚP". Phản xạ đầu tiên: "tuyệt, bộ nhớ chia sẻ nhanh gấp 5 lần pipe, đây là con số cho bài viết".
May là có hai dấu hiệu cãi lại, và tôi đã học được rằng phải nghe chúng. Thứ nhất, 15 GB/s cao tới mức khó tin cho một cuộc trao dữ liệu giữa hai tiến trình — nó gần bằng băng thông RAM thô của một lõi. Thứ hai, bản shared memory có đồng bộ (một đệm) lại chậm hơn pipe — nếu shared memory thực sự nhanh gấp 5 lần thì sao bản đồng bộ của nó lại thua pipe? Hai con số mâu thuẫn nhau, và như đã tự nhắc suốt sê-ri, hai số mâu thuẫn nghĩa là tôi đang đo nhầm đại lượng.
Đào ra thì lỗi có hai tầng, cả hai đều là công cụ đo của chính tôi nói dối. Tầng một: 15 GB/s không phải băng thông IPC. Không có đồng bộ, bên ghi chỉ đơn thuần memcpy khối nguồn vào vùng đệm 16384 lần nhanh hết mức nó có thể — đó là băng thông memcpy của một tiến trình, chẳng có cuộc bắt tay chéo nào giữa hai tiến trình cả. Tôi tưởng mình đo tốc độ truyền dữ liệu, thực ra chỉ đo tốc độ một tiến trình ghi vào RAM. Tầng hai: cái "KHỚP" là một checksum dối. Trong phép thử đó mọi khối 64 KB đều giống hệt nhau, nên tổng của bên đọc luôn ra đúng bất kể đồng bộ có chạy hay không, bất kể bên đọc đọc trúng khối nào — phép kiểm không hề kiểm được điều nó phải kiểm (dữ liệu qua đúng thứ tự, đủ và không trùng). Một phép xác minh vô dụng cho tôi cảm giác an toàn giả.
Cách chữa lộ ra ngay khi hiểu hai lỗi: cho mỗi khối một nội dung khác nhau (nhét số thứ tự khối vào byte đầu) để checksum thật sự bắt được lỗi thứ tự/thiếu/trùng, và dựng một ring nhiều đệm để hai tiến trình chồng lấn thật thay vì một tiến trình chạy một mình. Làm xong, con số 15 GB/s biến mất và sự thật hiện ra: shared memory đúng cách nhanh hơn pipe khoảng 3 lần (9,8 so với 3,1) — không phải 5 lần thần kỳ, và tuyệt nhiên không phải "cứ dùng shared memory là nhanh". Bài học đo lường: một con số đẹp tới mức đáng ngờ, kèm một phép kiểm "pass" quá dễ dàng, gần như luôn là dấu hiệu công cụ đo đang nói dối — hãy nghi ngờ nó trước khi ăn mừng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là shared memory không phải nút "nhanh hơn" bấm là xong — cái khó nằm ở đồng bộ. Bản thân việc chia sẻ khung RAM là phần dễ; phần quyết định hiệu năng (và cả tính đúng) là cơ chế báo hiệu giữa hai bên. Làm sai — một đệm nối tiếp — thì còn chậm hơn pipe. Làm đúng — ring nhiều đệm cho hai bên chồng lấn — mới lấy được lợi thế zero-copy. Nên nếu bạn định chọn shared memory chỉ vì nghe nói nó nhanh, hãy nhớ bạn vừa nhận thêm việc: tự viết đúng phần đồng bộ mà pipe vốn cho không.
Hệ quả thứ hai là chọn IPC theo dạng dữ liệu, không theo danh tiếng. Với các thông điệp nhỏ, thưa, hoặc khi bạn cần đồng bộ và ranh giới thông điệp có sẵn, pipe (hay socket) thường là lựa chọn đúng: đơn giản, an toàn, nhân lo hết, và cái giá sao chép chẳng đáng kể ở lưu lượng thấp. Shared memory chỉ thật sự tỏa sáng khi bạn chuyển khối lượng lớn, liên tục giữa các tiến trình trên cùng một máy (khung hình video, mảng số học lớn, hàng đợi thông lượng cao), nơi việc né hai cú sao chép nhân lên đủ nhiều để đáng công viết phần đồng bộ. Sai dạng thì shared memory chỉ thêm phức tạp và bug mà không nhanh hơn.
Hệ quả thứ ba là một bài học đo lường chung: luôn để dữ liệu thử đa dạng và phép kiểm thật sự khắt khe. Cái checksum dối của tôi pass chỉ vì mọi khối giống nhau — một biến ẩn tôi tự tạo ra. Dữ liệu thử đồng nhất là bạn của kết quả sai: nó giấu lỗi thứ tự, lỗi đua tranh, lỗi thiếu đồng bộ. Con số mang theo: chuyển 1 GB giữa hai tiến trình, pipe đạt 3,1 GB/s, bộ nhớ chia sẻ ring nhiều đệm đạt 9,8 GB/s (~3 lần) nhờ né sao chép qua nhân — nhưng bộ nhớ chia sẻ một đệm lại chậm hơn pipe vì bắt tay nối tiếp, và một phép đo bỏ đồng bộ cho 15 GB/s là con số dối do đo nhầm băng thông một tiến trình. Shared memory là con dao sắc nhất trong hộp IPC, nhưng chỉ cắt tốt khi bạn cầm đúng cách.
Thử ba mươi giây
Nếu bạn đang dùng pipe hay socket để chuyển khối lượng lớn giữa các tiến trình trên cùng một máy và nghi nó là nút thắt, thử ước lượng: dd if=/dev/zero bs=64k count=16384 | cat > /dev/null cho bạn cảm giác thô về thông lượng pipe trên máy mình. Rồi tự hỏi ba câu trước khi nhảy sang shared memory: dữ liệu có đủ lớn và liên tục để việc né sao chép đáng công không? Bạn có sẵn sàng tự viết đúng phần đồng bộ (ring nhiều đệm, semaphore/futex) không? Và bạn sẽ kiểm dữ liệu qua bằng checksum trên dữ liệu đa dạng chứ không phải một mẫu lặp lại chứ? Nếu cả ba câu đều "có", shared memory sẽ thưởng cho bạn cỡ 3 lần thông lượng — đúng như bài này đo; nếu không, pipe vẫn là bạn.