Gửi một file qua socket là việc mọi web server làm hàng tỉ lần: đọc dữ liệu từ đĩa, đẩy ra kết nối mạng. Cách kinh điển là một vòng lặp read từ file rồi write ra socket. Nhưng Linux có một lời gọi đặc biệt hứa làm chuyện đó "zero-copy": sendfile — chuyển thẳng dữ liệu trong nhân, không qua bộ nhớ chương trình. Bài này đo sendfile so với vòng read+write — và phát hiện lợi ích thật của nó không nằm ở chỗ tôi đo đầu tiên, khiến tôi suýt kết luận sai.

sendfile và vòng read+write

Cách truyền thống gửi file qua socket là:

while ((n = read(file, buf, size)) > 0)
    write(socket, buf, n);

Dữ liệu đi một hành trình dài: từ đĩa vào page cache của nhân, rồi read sao chép nó sang bộ đệm trong bộ nhớ chương trình (buf), rồi write sao chép từ buf vào bộ đệm socket trong nhân. Dữ liệu vượt ranh giới nhân/người dùng hai lần, và mỗi khối cần một cặp lời gọi hệ thống.

sendfile(out_fd, in_fd, offset, count) cắt đường vòng đó: nhân sao chép dữ liệu thẳng từ page cache của file sang bộ đệm socket, không hề đi qua bộ nhớ chương trình. Không có buf, không có hai lần vượt ranh giới, và một lời gọi có thể gửi cả file. Đây là lý do nó được gọi là zero-copy — dù cái tên hơi quá lời: nó vẫn sao chép (page cache sang socket), chỉ là bỏ được vòng qua userspace. Tôi muốn đo sendfile nhanh hơn read+write bao nhiêu.

Đo: thông lượng ngang nhau, nhưng syscall khác một trời vực

Tôi gửi một file 512 MB (đã nằm ấm trong page cache) qua một cặp socket, một tiến trình con đọc và vứt đầu kia, rồi đo thông lượng của read+write (với vài cỡ đệm) so với sendfile:

Cách gửi Thông lượng
read+write đệm 4K 3.000 MB/s
read+write đệm 64K 10.400 MB/s
read+write đệm 1M 6.000 MB/s
sendfile ~9.000 MB/s

Con số làm tôi ngồi thẳng dậy: sendfile (~9.000 MB/s) không phải nhanh nhất — read+write với đệm 64K còn nhỉnh hơn (10.400 MB/s). Và cỡ đệm read+write ảnh hưởng khủng khiếp: 4K quá nhỏ nên tốn quá nhiều syscall (3.000 MB/s), 64K vừa khít cache L2 nên nhanh nhất, 1M lại vỡ L2 nên chậm lại (6.000). Nhìn bảng này, tôi suýt viết "sendfile chẳng hơn gì read+write, thậm chí thua".

Nhưng rồi tôi đếm số lời gọi hệ thống để gửi hết 512 MB, và bức tranh lật ngược:

read+write đệm 64K : 8.192 read + 8.192 write = 16.384 syscall
sendfile           : 1 syscall

Read+write chia file thành 8.192 khối 64K, mỗi khối một read và một write — tổng 16.384 lời gọi hệ thống. sendfile gửi cả 512 MB trong đúng 1 lời gọi. Chênh lệch ~16.000 lần. Và điều này lộ ra khi tôi chạy dưới strace (đặt chi phí lên mỗi syscall): read+write tụt xuống còn 661 MB/s, còn sendfile vẫn 4.231 MB/s — ít bị ảnh hưởng gấp 6 lần, đúng vì nó gọi hệ thống ít hơn hàng vạn lần.

Tôi đã đo nhầm đại lượng

Sai lầm của tôi rất rõ khi nhìn lại: tôi đo thông lượng trước, thấy sendfile không thắng, và suýt kết luận nó vô dụng. Nhưng thông lượng là đại lượng sai để đánh giá sendfile trong tình huống này.

Trên một đường loopback với page cache ấm, thông lượng bị chặn bởi băng thông bộ nhớ, không phải bởi cách gọi. Cả read+write lẫn sendfile đều phải di chuyển chừng ấy byte qua bộ nhớ, nên tốc độ thô của chúng xấp xỉ nhau — thắng thua chỉ do những chi tiết như cỡ đệm có vừa cache L2 không. Ở phép đo warm-cache này, ưu thế "bỏ một lần sao chép qua userspace" của sendfile bị nhòa đi vì phần sao chép còn lại (page cache sang socket, rồi phía nhận đọc ra) mới là phần lớn.

Lợi ích thật của sendfile nằm ở số syscall và CPU, không phải megabyte mỗi giây trên loopback. Nó gửi cả file trong một lời gọi thay vì hàng vạn, và bỏ hẳn vòng sao chép qua bộ nhớ chương trình — nghĩa là ít chuyển ngữ cảnh nhân/người dùng hơn, ít CPU hơn cho mỗi byte. Cái đó thật sự quan trọng ở quy mô thật: một server phục vụ hàng nghìn kết nối, nơi CPU (chứ không phải băng thông bộ nhớ) là nút thắt, hay khi dữ liệu chưa ấm trong cache. Điều rút ra: tôi đã đo đại lượng dễ nghĩ tới nhất (thông lượng) trong khi lợi ích của công cụ nằm ở một đại lượng khác (hiệu quả CPU/syscall). Cái tên "zero-copy" càng đẩy tôi vào bẫy — nó gợi ý "ít sao chép = nhanh hơn", trong khi thứ đáng đo là rẻ hơn về CPU. Đo đúng đại lượng mới thấy đúng lợi ích; và cỡ đệm read+write là một biến ẩn có thể thổi phồng ưu thế sendfile (khi so với 4K) hay dìm nó đi (khi so với 64K).

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

Điều đầu tiên là dùng sendfile để phục vụ file tĩnh ở quy mô lớn — vì CPU, không vì tốc độ một kết nối. Khi một server (nginx, Apache, CDN) phục vụ hàng nghìn client tải file cùng lúc, sendfile cắt phần lớn chi phí CPU cho mỗi byte: ít syscall, không sao chép qua userspace. Với một kết nối lẻ trên máy rảnh, bạn khó thấy khác biệt; nhưng khi CPU là nút thắt (nhiều kết nối), sendfile để CPU đó phục vụ thêm nhiều client hơn. Hầu hết web server đã dùng nó dưới nắp (nginx có sendfile on;), nên bạn hưởng lợi mà không phải gọi trực tiếp.

Điều thứ hai là chọn công cụ theo nút thắt thật, và đừng bỏ qua cỡ đệm. Nếu bạn vẫn dùng read+write (ví dụ cần biến đổi dữ liệu giữa đường, sendfile không làm được), thì cỡ đệm là tham số đáng chỉnh nhất: quá nhỏ tốn syscall, quá lớn vỡ cache — phép đo cho thấy 64K là điểm ngọt ở đây, và con số tối ưu tùy máy. Đừng mặc định 4K hay chọn thật to; đo mà chọn.

Điều cuối, về đo lường: hỏi "công cụ này thắng ở đại lượng nào" trước khi đo, kẻo đo nhầm chỗ. Gói lại bằng số: sendfile gửi cả file trong 1 syscall thay vì 16.384 read+write và bỏ vòng sao chép qua userspace — nhưng trên loopback cache ấm, thông lượng thô của nó không hơn read+write đệm khéo (~9.000 so với 10.400 MB/s ở 64K), vì tốc độ bị chặn bởi băng thông bộ nhớ; lợi ích thật của sendfile là CPU/syscall (dưới strace nó giữ 4.231 MB/s còn read+write tụt còn 661), quan trọng khi phục vụ nghìn kết nối, không phải megabyte mỗi giây, và "zero-copy" thực ra vẫn còn một lần sao chép. Một tối ưu có thể đúng đắn mà không hề nhanh hơn ở phép đo bạn nghĩ tới đầu tiên.

Cái bẫy "một tối ưu thắng ở đại lượng này nhưng bạn lỡ đo đại lượng kia" phổ biến đến mức nó là lý do nhiều người bỏ qua đúng công cụ họ cần. Java từng chỉ có luồng đọc-ghi thường, rồi thêm FileChannel.transferTo() — chính là sendfile dưới nắp; đo nó bằng thông lượng một file trên máy rảnh sẽ thấy "chẳng hơn", nhưng ở một file server nhiều kết nối thì nó cứu CPU, đúng như đây, và đó cũng là nền của zero-copy trong Netty/Kafka (Kafka nổi tiếng nhờ dùng sendfile để đẩy log tới consumer gần như không tốn CPU userspace). Go có io.Copy tự động dùng sendfile khi nguồn là file và đích là socket. Cơ sở dữ liệu cũng đầy loại tối ưu "đúng nhưng đo sai chỗ": bật EXPLAIN thấy index scan không nhanh hơn seq scan trên bảng nhỏ nằm gọn trong cache, rồi vội kết luận index vô dụng — trong khi lợi ích của index là số trang phải đọc (I/O) ở bảng lớn, không phải mili-giây trên bảng bé. Mẫu số chung, đúng như bài này: trước khi benchmark một tối ưu, hỏi "nó được thiết kế để tiết kiệm cái gì — CPU, I/O, syscall, bộ nhớ?" rồi đo đúng thứ đó ở đúng quy mô nó phát huy; đo nhầm đại lượng, hay đo ở quy mô quá nhỏ, sẽ khiến một công cụ tốt trông như vô dụng.

Muốn tự xem thì một server có đang dùng sendfile không: strace -f -e trace=sendfile,read,write -p <pid> trong lúc nó phục vụ file — nếu thấy sendfile với khối lớn, nó đang truyền hiệu quả; nếu thấy hàng loạt cặp read/write nhỏ, đó là chi phí syscall có thể cắt được. Trong cấu hình nginx, kiểm sendfile on;. Muốn cảm nhận khác biệt syscall, so hai lệnh gửi cùng một file: dd if=file bs=4k | nc ... (nhiều syscall nhỏ) với một công cụ dùng sendfile — rồi strace -c cả hai và nhìn cột calls. Con số đó, chứ không phải MB/s trên máy rảnh, mới là chỗ sendfile thắng, đúng cái bài này đo.