I/O, Lưu trữ & Tài nguyên/Zero-copy — cắt bớt bản sao trên đường đi
15/26
Bài 15 / 26~13 phútĐường đi I/O & GPUMiễn phí lượt xem

Zero-copy — cắt bớt bản sao trên đường đi

read() cộng write() chép bốn lần; sendfile và splice cắt còn hai. Cơ chế bên dưới, vì sao Kafka với nginx nhanh, và khi nào zero-copy chẳng giúp được gì.

TL;DR: Gửi một file ra socket theo cách thông thường (read() rồi write()) làm dữ liệu bị chép bốn lần và chuyển ngữ cảnh bốn lượt, dù chương trình của bạn chẳng nhìn vào nội dung ấy lấy một byte. sendfile() cắt xuống còn hai bản sao và không lần nào đi qua user space; với card mạng hỗ trợ scatter-gather DMA thì không còn bản sao nào do CPU thực hiện. Đó là lý do nginx và Kafka phục vụ được lưu lượng lớn với CPU thấp. Nhưng zero-copy chỉ ăn tiền khi dữ liệu đi qua nguyên si: bật TLS là mất sạch, vì mã hoá buộc dữ liệu phải quay lại user space.

Một file server đọc file rồi đẩy ra socket. Code chỉ hai dòng, và chương trình không hề đụng tới nội dung — nó chỉ chuyển tiếp. Vậy mà top báo CPU cao, còn perf chỉ thẳng vào memcpy trong kernel.

Bài trước đếm được hai bản sao trên đường đọc. Bài này đếm nốt đường ghi, rồi xem có thể cắt bao nhiêu trong số đó.

1. Analogy — bốc hàng qua kho trung gian

Một xe tải chở hàng tới cảng để lên tàu. Cách làm vụng về: bốc hàng từ xe xuống kho, rồi từ kho lên xe nâng, từ xe nâng xuống bãi, cuối cùng từ bãi lên tàu. Bốn lần bốc dỡ, mà kiện hàng thì không hề được mở ra kiểm.

Cách làm gọn: kéo hẳn xe tải cập mạn, cần cẩu nhấc thẳng container từ xe lên tàu. Hàng vẫn tới đúng chỗ, chỉ có điều bạn tiết kiệm được ba lần bốc dỡ — và tiết kiệm đó chỉ khả thi vì không ai cần mở kiện hàng ra xem.

Ở cảngTrên máy
Kiện hàngNội dung file cần gửi đi
Bốc xuống kho rồi lại bốc lênChép giữa page cache, buffer user và buffer socket
Cẩu thẳng từ xe lên tàusendfile() — dữ liệu không vào user space
Phải mở kiện ra kiểmỨng dụng cần đọc hoặc sửa nội dung
Mở kiểm thì hết đường cẩu thẳngBật TLS là mất zero-copy

2. Đếm cho đúng — một lần chuyển tiếp tốn bao nhiêu bản sao?

Đoạn code cổ điển để gửi file ra socket:

/* The classic four-copy path: the payload passes through user space
   even though this program never inspects a single byte of it. */
ssize_t n = read(file_fd, buf, sizeof buf);
write(sock_fd, buf, n);

Lần theo từng byte, có bốn bản sao:

#Từ đâu tới đâuAi chép
1Đĩa vào page cache trong kernelDMA
2Page cache sang buf của ứng dụngCPU
3buf sang buffer socket trong kernelCPU
4Buffer socket ra card mạngDMA

Cộng thêm bốn lượt chuyển ngữ cảnh: vào kernel rồi ra cho read(), vào rồi ra cho write().

Bon ban sao tren duong chuyen tiep mot file, hai trong so do do CPU lam va la thua

Điều chối tai nằm ở bản sao số 2 và số 3. Chúng do CPU thực hiện, và chúng tồn tại chỉ để dữ liệu ghé qua không gian địa chỉ của ứng dụng — trong khi ứng dụng không đọc, không sửa, không làm gì với nội dung đó cả. Với một file server phục vụ vài gigabyte mỗi giây, đây là hàng gigabyte memcpy thuần tuý mỗi giây, đổi lấy đúng con số không.

3. sendfile và splice — cắt phần thừa đi

sendfile() bảo kernel làm trọn việc chuyển tiếp: đọc từ file descriptor này, đẩy sang file descriptor kia, không đi qua user space lần nào.

/* One syscall, no user-space round trip. */
off_t off = 0;
sendfile(sock_fd, file_fd, &off, count);

Bây giờ chỉ còn hai bản sao, cả hai đều do DMA: đĩa vào page cache, rồi page cache ra card mạng. Hai bản sao do CPU biến mất, và số lượt chuyển ngữ cảnh giảm từ bốn xuống hai.

Còn cắt được nữa: nếu card mạng hỗ trợ scatter-gather DMA, nó tự gom dữ liệu từ nhiều vùng nhớ rời rạc, nên kernel chỉ cần đưa cho nó danh sách địa chỉ thay vì gom sẵn vào một buffer liền mạch. Lúc đó không còn bản sao nào do CPU thực hiện — đúng nghĩa của cái tên zero-copy. DMA vẫn chuyển dữ liệu, nhưng đó là engine riêng, không ăn chu kỳ CPU.

sendfile() có ràng buộc mà man page ghi rõ: mỗi lời gọi chuyển tối đa khoảng 2,1 GB, file lớn hơn thì phải gọi nhiều lượt. Còn ràng buộc "đầu ra bắt buộc là socket" mà nhiều tài liệu cũ vẫn nhắc thì chỉ đúng trước Linux 2.6.33; từ bản đó trở đi đầu ra có thể là file bất kỳ.

splice() linh hoạt hơn: nó chuyển dữ liệu giữa hai file descriptor mà một trong hai phải là pipe. Ràng buộc nghe kỳ quặc ấy chính là cơ chế của nó — pipe đóng vai một bộ đệm trong kernel, và thứ được chuyển không phải nội dung mà là con trỏ tới trang nhớ có đếm tham chiếu. Vì vậy mới có mẹo quen thuộc: dựng một pipe làm trung gian để nối hai fd bất kỳ mà không lần nào đưa dữ liệu lên user space.

4. Khi nào zero-copy chẳng giúp được gì?

Câu trả lời gọn: khi dữ liệu không đi qua nguyên si.

Trước khi đọc tiếp, hãy tự đếm. Một broker gửi log ra client và có bật TLS. Điền số bản sao và lý do cho từng ô:

Tình huốngSố bản sao do CPU
Gửi file ra socket, không TLS, dùng sendfile/* TODO 1 */
Gửi file ra socket, có TLS/* TODO 2 */
Tự điền trước khi xem đáp án

Câu hỏi định hướng, không phải đáp án: mã hoá TLS là một phép biến đổi trên từng byte. Ai làm phép biến đổi ấy, và vì thế dữ liệu buộc phải nằm ở đâu? Rồi từ đó suy ra: đường đi lúc này khác gì so với hàng 1?

Đáp án. Ô 1: không có bản sao nào do CPU nếu card mạng hỗ trợ scatter-gather DMA, còn nếu không thì đúng một lần CPU gom dữ liệu vào buffer socket. Ô 2: quay lại bốn bản sao như đường read cộng write cũ, vì thư viện TLS phải đọc dữ liệu thô ra user space, mã hoá, rồi ghi bản mã ngược vào kernel. Kafka là ví dụ quen thuộc của đúng đường này: broker dựa vào FileChannel.transferTo cho đường truyền thường, nhưng khi bật TLS thì đường tắt ấy hết tác dụng và broker phải tự chép.

Đây là điểm cần nhớ kỹ, vì nó phá một kỳ vọng phổ biến: người ta bật TLS rồi vẫn cho rằng "hệ thống này dùng zero-copy nên vẫn nhanh". Zero-copy không mất một phần, nó mất hẳn. Và ngoài TLS, mọi thứ đòi biến đổi nội dung đều làm mất nó — nén tại chỗ, thêm watermark, sửa header từng bản ghi.

5. Pitfall của riêng concept này

Nhầm 1 — kỳ vọng zero-copy trong khi vẫn bật TLS.

✅ Mã hoá buộc dữ liệu ra user space, nên đường đi quay về đủ bốn bản sao. Muốn giữ zero-copy thì phải đẩy mã hoá xuống chỗ khác, chẳng hạn kernel TLS hoặc card mạng có offload; nếu không thì hãy tính CPU theo đúng đường bốn bản sao.

Nhầm 2 — dùng sendfile cho dữ liệu ứng dụng cần xử lý.

✅ Zero-copy chỉ hợp với chuyển tiếp nguyên si. Cần parse, lọc hay sửa nội dung thì dữ liệu bắt buộc phải vào user space, và ép dùng sendfile chỉ khiến kiến trúc méo mó chứ không tiết kiệm gì.

Nhầm 3 — tưởng zero-copy làm giảm độ trễ.

✅ Thứ nó cắt là chu kỳ CPU và băng thông bộ nhớ. Đĩa vẫn mất chừng ấy mili-giây, mạng vẫn mất chừng ấy vòng. Cái nó mua là thông lượng: cùng một máy phục vụ được nhiều kết nối hơn với cùng lượng CPU.

6. 📚 Đào sâu (tuỳ chọn)

📚 Nguồn kỹ thuật
  • sendfile(2) — man7.org — chuyển dữ liệu giữa hai fd trong kernel, giới hạn khoảng 2,1 GB mỗi lời gọi, và mốc Linux 2.6.33 nới đầu ra từ socket thành file bất kỳ.
  • splice(2) — man7.org — cơ chế con trỏ trang có đếm tham chiếu, và vì sao một đầu bắt buộc phải là pipe.
  • Kafka — Network Layer — nguồn chính chủ cho việc broker dùng transferTo thay cho ghi qua buffer trong tiến trình.

7. Liên hệ các bài khác

  • Hành trình một lệnh read() — nơi hai bản sao đầu tiên được đếm ra; bài này đếm nốt phần còn lại và cắt đi.
  • Page cache và buffered I/O — chỗ dữ liệu nằm khi sendfile chuyển nó thẳng ra card mạng.
  • Chờ I/O thế nào — trục tối ưu song song: một bên cắt bản sao, một bên cắt chi phí chờ; server hiệu năng cao dùng cả hai.
  • Host và device — cùng bài toán "bản sao thừa tốn tiền", chuyển sang đường host-GPU.

8. Tóm tắt

  • Chuyển tiếp một file bằng read() cộng write() tốn bốn bản sao và bốn lượt chuyển ngữ cảnh, trong đó hai bản sao do CPU làm hoàn toàn vô ích.
  • sendfile() giữ dữ liệu trong kernel nên còn hai bản sao; scatter-gather DMA trên NIC xoá nốt phần việc của CPU, kernel chỉ đưa danh sách địa chỉ.
  • splice() đòi một đầu là pipe vì nó chuyển con trỏ trang có đếm tham chiếu chứ không chuyển nội dung.
  • Bật TLS là mất sạch zero-copy: mã hoá kéo dữ liệu ngược lên user space, và đường đi quay về đủ bốn bản sao.
  • Zero-copy mua thông lượng và CPU, không mua độ trễ.

9. Tự kiểm tra

Tự kiểm tra
Q1
Liệt kê bốn bản sao trong đường read() cộng write() khi gửi file ra socket, và chỉ rõ bản sao nào do CPU thực hiện. Vì sao hai bản sao đó bị coi là lãng phí?

Thứ tự là: đĩa vào page cache (DMA), page cache sang buffer của ứng dụng (CPU), buffer ứng dụng sang buffer socket trong kernel (CPU), rồi buffer socket ra card mạng (DMA). Hai bản sao giữa do CPU làm.

Chúng lãng phí vì tồn tại chỉ để dữ liệu ghé qua không gian địa chỉ của ứng dụng, trong khi ứng dụng chuyển tiếp mà không đọc hay sửa byte nào. Với máy chủ đẩy vài gigabyte mỗi giây, đó là hàng gigabyte memcpy mỗi giây đổi lấy đúng con số không, cộng thêm băng thông bộ nhớ bị chiếm.

Q2
Một team bật TLS cho service đang dùng sendfile rồi thấy CPU tăng vọt dù lưu lượng không đổi. Giải thích cơ chế, và vì sao nói zero-copy mất hẳn chứ không mất một phần.

Mã hoá TLS là phép biến đổi trên từng byte và nó chạy trong thư viện TLS của ứng dụng, nên dữ liệu thô bắt buộc phải được đọc ra user space, mã hoá, rồi ghi bản mã ngược vào kernel. Đường đi vì thế quay về đúng bốn bản sao của read cộng write.

Mất hẳn vì sendfile chỉ có một chế độ hoạt động: giữ dữ liệu trong kernel từ đầu tới cuối. Chỉ cần một khâu đòi dữ liệu ở user space là cả đường tắt sụp, không có phương án nửa vời. Broker Kafka bật TLS rơi vào đúng tình cảnh đó.

Q3
Vì sao splice() bắt buộc một trong hai đầu phải là pipe? Ràng buộc nghe tuỳ tiện này thực ra phản ánh cơ chế gì?

Vì thứ splice chuyển không phải nội dung mà là con trỏ tới trang nhớ trong kernel, kèm đếm tham chiếu. Nó cần một chỗ trong kernel để giữ tập con trỏ đó, và pipe chính là cấu trúc sẵn có đóng vai bộ đệm ấy.

Hiểu vậy rồi thì mẹo quen thuộc trở nên hiển nhiên: dựng một pipe trung gian rồi splice hai lần để nối hai fd bất kỳ, mà không lần nào dữ liệu phải lên user space.

Q4
Một API trả JSON được sinh động từ database. Đồng nghiệp đề xuất dùng zero-copy để tăng tốc. Vì sao đề xuất này không áp dụng được?

Zero-copy chỉ dùng được khi dữ liệu đi qua nguyên si từ một fd sang một fd khác. Ở đây nội dung phải được sinh ra: truy vấn database, dựng cấu trúc, tuần tự hoá thành JSON. Không có file nào sẵn để cẩu thẳng ra socket.

Nút thắt của loại API này cũng thường không nằm ở memcpy mà ở truy vấn và tuần tự hoá. Zero-copy đáng dùng cho việc phục vụ file tĩnh, đẩy log, proxy dữ liệu — những đường mà ứng dụng chỉ là ống dẫn.

Q5
Sau khi chuyển sang sendfile, một team đo lại thấy độ trễ mỗi request gần như không đổi và cho rằng thay đổi vô ích. Họ đo sai chỉ số ở chỗ nào?

Zero-copy cắt chu kỳ CPU và băng thông bộ nhớ, không cắt thời gian chờ thiết bị. Đĩa vẫn mất chừng ấy mili-giây và mạng vẫn mất chừng ấy vòng, nên độ trễ của một request lẻ gần như không đổi — đúng như họ đo.

Chỉ số đáng nhìn là CPU tiêu tốn trên mỗi gigabyte phục vụ, và thông lượng tối đa trước khi máy bão hoà. Cùng một máy giờ phục vụ được nhiều kết nối hơn với cùng lượng CPU; lợi ích chỉ lộ ra dưới tải cao, còn một phép đo đơn lẻ lúc máy rảnh sẽ không thấy gì.

Bài tiếp theo: GPU về mặt kiến trúc — SIMT, băng thông, throughput

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

GPU về mặt kiến trúc — SIMT, băng thông, throughput