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ớ ra: 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.
Một lần tôi đo hớ: đ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. Bài học đo lường: 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba, 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ỗ. Con số mang theo: sendfile gửi cả file trong 1 syscall thay vì hàng vạn 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 (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 (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.
Thử ba mươi giây
Xem 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.