Hình dung một shipper chuyển một kiện hàng từ kho A sang kho B. Cách vụng: anh ta tới kho A lấy kiện, chở về văn phòng của mình, rồi từ văn phòng chở tiếp sang kho B — hai chặng vòng qua văn phòng cho một kiện mà anh ta thậm chí không mở ra xem. Cách khôn: chuyển thẳng A sang B, không ghé văn phòng. Khi một chương trình chỉ chuyển tiếp dữ liệu (đọc file, gửi ra socket) mà không đụng nội dung, cái "văn phòng" chính là user-space — và read+write bắt dữ liệu ghé qua đó hai lần vô ích. Các lời gọi zero-copy (sendfile, splice) là cách chuyển thẳng, bỏ hẳn khúc ghé văn phòng.

Một web server phục vụ file tĩnh làm một việc cực kỳ đơn giản: đọc file từ đĩa, gửi ra socket. Cách viết tự nhiên là read file vào một buffer rồi write buffer đó ra socket. Nhưng cách đó chép dữ liệu hai lần một cách vô ích — dữ liệu đi vòng qua bộ nhớ user-space dù chương trình chẳng làm gì với nó, chỉ chuyển tiếp. Nhiều người tin zero-copy nghĩa là "nhanh hơn". Tôi đo trong container gcc:13, và kết quả buộc tôi tách bạch cái zero-copy thật sự giúp với cái người ta tưởng nó giúp.

Hai lần chép vô ích

Cách read+write khi gửi file ra socket đi qua bốn bước chép/vượt ranh giới cho mỗi khối:

  1. read(file, buf): nhân chép dữ liệu từ page cache sang buffer user-space của bạn — copy 1, một lần vượt ranh giới.
  2. write(socket, buf): nhân chép từ buffer user-space ngược lại vào bộ đệm socket trong nhân — copy 2, một lần vượt ranh giới nữa.

Vậy dữ liệu đi kernel → user → kernel — hai lần chép, hai lần vượt ranh giới mỗi khối — chỉ để rồi bạn chẳng đụng gì tới nó. Đây đúng là hai chặng ghé-văn-phòng thừa. sendfile(out_fd, in_fd, ...) bỏ hẳn khúc vòng: nó chép dữ liệu từ file sang socket hoàn toàn trong nhân, không bao giờ sang user-space. Một lời gọi thay cho cả vòng lặp read/write, và bỏ được hai lần chép dư. splice làm điều tương tự qua một bộ đệm pipe trong nhân. Tôi đo: tạo file 256 MB, gửi qua một socketpair (có luồng consumer rút ở đầu kia), so read+write (buffer 64 KB) với sendfile.

Đo: throughput gần bằng, nhưng CPU chỉ một nửa

Gửi file 256 MB (đang trong page cache) qua socketpair, consumer rút đầu kia:
  read+write (buffer user 64KB): wall 22,0 ms | CPU 35,4 ms | ~8193 syscall | ~12,2 GB/s
  sendfile   (trong nhân)      : wall 21,0 ms | CPU 17,3 ms | ~1    syscall  | ~12,8 GB/s
  -> throughput ~bằng (0,95×);  sendfile dùng CPU 0,49× (một NỬA);  syscall ít hơn ~8000×

Nhìn cột wall time (thời gian thực): 21,0 ms so với 22,0 ms — gần như bằng nhau. Đây là cái đo hớ: sendfile không làm việc gửi file nhanh hơn ở đây. Vì sao? Vì trong phép đo này, file đang nằm trong page cache và socket là loopback nội bộ — nên toàn bộ là chép bộ nhớ, và workload bị chặn bởi băng thông bộ nhớ (~12 GB/s), không bị chặn bởi số syscall. 8193 lần vượt ranh giới của read+write chỉ tốn ~0,9 ms (8193 × ~115 ns), chưa tới 5% của 22 ms. Bỏ chúng đi không giúp được wall time.

Giờ nhìn cột CPU: 17,3 ms so với 35,4 ms — sendfile dùng một nửa CPU! Đây mới là lợi ích thật. read+write phải chép dữ liệu hai lần (page cache → buffer user, buffer user → socket buffer), và mỗi lần chép 256 MB đốt chu kỳ CPU. sendfile giữ dữ liệu trong nhân, bỏ được hai lần chép đó, nên cùng một lượng dữ liệu mà tốn nửa CPU. Wall time gần bằng nhau (cả hai đều memory-bound), nhưng lượng CPU tiêu thì khác gấp đôi — và trên nhiều tình huống thật, CPU mới là tài nguyên khan hiếm.

(Môi trường đo: container gcc:13 trên host ARM AArch64, g++ -O2, file trong page cache, socketpair loopback, mỗi số lấy MIN sau warmup. Loopback + file nóng khiến workload memory-bound nên không thấy lợi ích throughput; trên mạng thật với DMA thì khác — nêu ở dưới.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với niềm tin sách vở: "sendfile là zero-copy nên nó nhanh hơn read+write". Đo throughput phá tan điều đó: gần như bằng nhau (0,95×), thậm chí lần đầu tôi đo sendfile còn chậm hơn chút. Nhưng đo CPU thì lộ ra sự thật: sendfile dùng nửa CPU. Bài học là phải đo đúng đại lượng: throughput đơn lẻ trên loopback với file nóng trong cache là memory-bound, nên nó không cho thấy lợi ích của zero-copy; chỉ khi đo CPU (hoặc đo ở điều kiện có nút thắt copy) mới thấy.

Lợi ích CPU đó dịch thành hiệu năng thật ở đúng ngữ cảnh: một web server phục vụ hàng nghìn kết nối đồng thời bị chặn bởi CPU, không phải bởi một lần truyền. Nếu mỗi lần truyền tốn nửa CPU, server phục vụ được gấp đôi số kết nối trên cùng phần cứng. Đây chính là lý do nginx, Kafka, Netflix dùng sendfile. Và trên mạng thật (không phải loopback), sendfile còn có thể để card mạng DMA thẳng từ page cache ra NIC — bỏ luôn cả lần chép vào socket buffer, tiết kiệm nhiều hơn nữa (tôi không đo được điều này trong container nên chỉ nêu cơ chế).

Còn một niềm tin ngược về tên gọi, cũng cần chỉnh: "zero-copy nghĩa là không chép gì cả". Sai — sendfile vẫn chép page cache → socket buffer (hoặc DMA → NIC). "Zero-copy" nghĩa là không chép sang user-space (bỏ hai lần chép dư và các lần vượt ranh giới đi kèm), không phải literally không chép byte nào. Nói gọn: zero-copy tối ưu số lần chép và số lần vượt ranh giới (do đó tiết kiệm CPU), không đảm bảo tăng throughput; đo đúng đại lượng (CPU, và ở đúng điều kiện tải) mới thấy lợi ích.

Về đến code hằng ngày

Điều đầu tiên: khi chỉ chuyển tiếp dữ liệu (không xử lý nó), dùng sendfile/splice thay vì read+write. Phục vụ file tĩnh, proxy dữ liệu, đẩy log — bất cứ chỗ nào bạn đọc từ một fd rồi ghi thẳng ra fd khác mà không đụng nội dung, đó là chỗ zero-copy giúp. Nhưng nếu bạn cần xử lý dữ liệu trong user-space (nén, mã hóa, sửa đổi), thì phải mang nó lên user-space — zero-copy không áp dụng.

Điều thứ hai: đo CPU, không chỉ throughput, khi đánh giá zero-copy. Trên một máy đơn, một lần truyền, throughput có thể không đổi (memory-bound) và bạn kết luận nhầm "sendfile vô dụng". Nhưng nửa CPU nghĩa là gấp đôi công suất khi tải nhiều kết nối. Đo ở đúng quy mô (nhiều kết nối đồng thời, hoặc đo %CPU), và nhớ rằng lợi ích lớn nhất ở trên mạng thật (DMA), nơi container không cho bạn thấy.

Chỗ này zero-copy đã là nền của rất nhiều hệ thống bạn dùng mỗi ngày, và các nền tảng cao cấp đều phơi nó ra. Java NIO có FileChannel.transferTo() — chính là sendfile bên dưới — và đó là cách Netty, Kafka đẩy dữ liệu tốc độ cao (Kafka nổi tiếng dùng sendfile để bơm log ra consumer mà không qua JVM heap). Nginx và Apache có chỉ thị sendfile on; để phục vụ file tĩnh; Netflix đẩy xa hơn với zero-copy trên FreeBSD để bơm video ở quy mô hàng trăm Gb/s mỗi máy. Ngay cả io_uring (giao diện I/O bất đồng bộ mới của Linux) cũng hướng tới giảm copy và syscall theo cùng tinh thần. Mẫu số chung: khi việc của bạn là bơm byte qua, chứ không nhìn vào byte, đừng bắt chúng ghé qua văn phòng.

Muốn tự thấy thì nghĩ về một đoạn code "chuyển tiếp" dữ liệu bạn có — đọc từ một file/socket rồi ghi thẳng ra socket/file khác, không đụng gì tới nội dung. Nó có đang read vào một buffer rồi write buffer đó ra không? Nếu có, dữ liệu đang đi vòng qua user-space vô ích, chép hai lần và vượt ranh giới hai lần mỗi khối. Thử thay bằng sendfile (hoặc splice) và đo CPU (không chỉ thời gian) của cả hai — bạn có thể thấy thời gian gần như bằng nhau nhưng CPU giảm một nửa. Rồi tự hỏi: hệ của mình bị chặn bởi gì — thời gian một lần truyền, hay tổng CPU khi phục vụ nhiều kết nối? Vài phút đó cho bạn thấy điều mà nhãn "zero-copy = nhanh" che giấu: nó không phải để một lần truyền nhanh hơn, mà để cùng phần cứng phục vụ được nhiều hơn — và bạn chỉ thấy điều đó nếu đo đúng đại lượng.