Chuyển một kho gạch sang bãi bên cạnh: bạn có thể cầm từng viên đi qua đi lại cả nghìn lượt, hay chất một xe cút kít mỗi lượt. Quãng đường mỗi lượt như nhau, nhưng số lượt — và do đó tổng thời gian — lệch nhau cả nghìn lần. Cái "quãng đường mỗi lượt" cố định đó chính là phí của một lời gọi hệ thống, và nó quyết định thông lượng pipe nhiều hơn bản thân pipe. Tôi suýt đổ oan cho pipe là "chậm" vì đo lúc đang cầm từng viên gạch. Ống pipe (pipe) là cách hai tiến trình nói chuyện xưa nhất và phổ biến nhất trên Unix — mọi dấu | bạn gõ trong shell đều là một pipe. Tôi dựng một cặp tiến trình cha-con truyền 512MB qua pipe trong container, đo thông lượng — và con số đầu tiên suýt khiến tôi kết luận sai hẳn về bản chất của pipe.
Pipe di chuyển dữ liệu thế nào
Một pipe là một bộ đệm vòng nằm trong nhân, mặc định 64KB. Bên sản xuất gọi write() để đẩy dữ liệu vào bộ đệm đó; bên tiêu thụ gọi read() để lấy ra. Nếu bộ đệm đầy, write() chặn lại chờ bên đọc rút bớt; nếu bộ đệm rỗng, read() chặn chờ có dữ liệu. Đó là toàn bộ cơ chế — đơn giản và đẹp.
Điểm mấu chốt cho hiệu năng nằm ở chỗ mỗi write() và mỗi read() là một lời gọi hệ thống (syscall): mỗi lần bạn phải vượt rào từ không gian người dùng vào nhân, nhân chép dữ liệu giữa buffer của bạn và bộ đệm pipe, rồi trả quyền về. Cú vượt rào đó có chi phí cố định, không phụ thuộc bạn chuyển 1 byte hay 64KB. Nên cỡ mỗi lô ghi/đọc — bạn gọi write() với 1 byte mỗi lần, hay gom thành khối 64KB — quyết định bạn phải trả cái phí cố định đó bao nhiêu lần. Tôi ghim tiến trình vào một lõi bằng taskset rồi đo thông lượng khi thay đổi cỡ lô, giữ tổng số byte truyền như nhau để so công bằng.
Đo: cỡ lô quyết định tất cả
Kết quả rất rõ và rất dốc:
| Cỡ lô mỗi write/read | Thông lượng |
|---|---|
| 1 byte | 3,3 MB/s |
| 64 byte | 203 MB/s |
| 512 byte | 1274 MB/s |
| 4 KB | 3557 MB/s |
| 16 KB | 8457 MB/s |
| 64 KB | 10335 MB/s |
| 256 KB | 9597 MB/s |
| 1 MB | 9746 MB/s |
Từ lô 1 byte tới lô 64KB, thông lượng nhảy từ 3,3 MB/s lên 10335 MB/s — hơn 3000 lần, trên đúng cùng một pipe, cùng một tổng lượng dữ liệu. Rồi từ 64KB trở lên, đường cong chững hẳn: 256KB và 1MB không những không nhanh hơn 64KB mà còn nhỉnh chậm hơn một chút. Có hai điều cần giải thích ở đây, và điều thứ nhất suýt lừa tôi.
"Pipe chậm" hóa ra là đo nhầm đại lượng
Lần chạy thử đầu tiên của tôi dùng lô 1 byte và ra 3,3 MB/s. Phản ứng đầu tiên rất tự nhiên: "pipe chỉ được vài megabyte mỗi giây — đúng là một kênh IPC chậm, muốn nhanh phải dùng bộ nhớ chia sẻ". Tôi gần như đã viết câu đó vào bài.
Nhưng con số 10335 MB/s ở lô 64KB bác bỏ nó hoàn toàn. Chính cái pipe đó đạt hơn 10 GB/s — nhanh gấp ba nghìn lần con số 3,3 MB/s tôi vừa đo. Vậy pipe nhanh hay chậm? Câu hỏi sai. Con số 3,3 MB/s không hề đo băng thông của pipe; nó đo phí syscall.
Bằng chứng nằm ở strace. Truyền 1MB qua pipe:
lô 1 byte : 1.048.576 lệnh write (đúng một syscall cho mỗi byte)
lô 64 KB : 16 lệnh write (1MB / 64KB = 16)
Với lô 1 byte, để chuyển 1MB tôi thực hiện hơn một triệu lệnh write (và ngần ấy lệnh read ở đầu kia). Cái "3,3 MB/s" thực chất đo được tốc độ nhân xử lý được bao nhiêu lời gọi hệ thống mỗi giây, chứ không phải tốc độ nó chép dữ liệu. Với lô 64KB, cùng 1MB đó chỉ tốn 16 lệnh write — phí syscall gần như biến mất, và cái tôi đo lúc này mới thật sự là băng thông chép bộ nhớ của pipe.
Đây đúng là cái bẫy "hai số mâu thuẫn nghĩa là đang đo nhầm đại lượng". 3,3 MB/s và 10335 MB/s cùng mang một cái nhãn — "thông lượng pipe" — nhưng chúng đo hai thứ khác hẳn nhau: tốc độ gọi syscall so với băng thông chép bộ nhớ. Trộn chúng lại rồi kết luận "pipe chậm" là sai về bản chất. Điều rút ra: khi một con số hiệu năng có vẻ tệ bất thường, hãy đọc bằng chứng (strace) để biết mình đang đo cái gì, đừng vội gán cho công cụ cái tội của cách dùng.
Vì sao đỉnh nằm đúng ở 64KB
Điều thứ hai cần giải thích: vì sao thông lượng đạt đỉnh ở lô 64KB rồi chững, chứ không tiếp tục tăng khi lô lớn hơn? Con số này không ngẫu nhiên. Hỏi nhân trực tiếp bằng fcntl(fd, F_GETPIPE_SZ) cho ra 65536 byte — đúng 64KB, đúng bằng cỡ lô cho đỉnh.
Lý do: pipe chỉ chứa tối đa 64KB tại một thời điểm. Khi tôi gọi write() với một khối 1MB, nhân không thể nhét cả 1MB vào bộ đệm 64KB; nó chép được 64KB rồi write() phải chặn lại chờ bên đọc rút bớt mới chép tiếp. Nói cách khác, một lệnh write(1MB) bên trong vẫn bị bẻ thành nhiều nhịp 64KB đúng bằng dung lượng pipe. Vượt qua 64KB, tăng cỡ lô không còn giảm được số nhịp thực tế nữa, nên thông lượng chững. Cái chững nhẹ đi xuống ở 256KB–1MB là do buffer lớn hơn 64KB làm cache CPU kém nóng hơn một chút — một chi tiết nhỏ, nhưng đúng hướng.
Nếu thật sự cần thông lượng cao hơn, Linux cho phép nới dung lượng pipe bằng fcntl(fd, F_SETPIPE_SZ, n) tới trần /proc/sys/fs/pipe-max-size (trên máy này là 1MB). Pipe rộng hơn cho bên ghi chạy trước xa hơn trước khi phải chờ bên đọc — hữu ích khi hai bên chạy giật cục. Nhưng với đa số trường hợp, chọn cỡ lô ghi bằng dung lượng pipe (64KB) đã lấy gần hết phần thông lượng có thể.
Về đến code hằng ngày
Điều đầu tiên, và thực tế nhất: gom dữ liệu thành lô lớn trước khi ghi. Thông lượng nhảy 3000 lần chỉ nhờ đổi từ ghi-từng-byte sang ghi-từng-khối-64KB không phải chuyện của riêng pipe — nó đúng cho mọi thứ đi qua syscall: file, socket, terminal. Đây chính là lý do các thư viện chuẩn có bộ đệm (buffered I/O): fwrite của C, BufferedOutputStream/BufferedWriter của Java, bufio của Go gom nhiều lần ghi nhỏ của bạn thành ít lần write lớn. Một lỗi hiệu năng kinh điển là tắt buffer hoặc flush sau mỗi dòng, biến một chương trình đáng lẽ chạy ở 10 GB/s thành lê ở vài MB/s — mà nhìn code thì không thấy gì sai.
Điều thứ hai: phí syscall là có thật và đo được. Mỗi lời gọi hệ thống tốn cỡ hàng trăm nano giây cho cú vượt rào (đúng như bài chi phí syscall của sê-ri này đã đo). Một pipe 10 GB/s ở lô 64KB nghĩa là mỗi syscall chuyển được 64KB — phí vượt rào chia đều cho 64 nghìn byte thành không đáng kể. Ở lô 1 byte, cùng phí đó đội lên đầu đúng một byte. Cùng một hệ điều hành, cùng một pipe, cùng một dữ liệu — chỉ khác cách bạn chia lô, mà kết quả lệch ba nghìn lần.
Và cái nguyên tắc "gom lô để nuốt phí cố định mỗi lần vượt ranh giới" này là một trong những đòn bẩy hiệu năng phổ quát nhất, đội đủ tên ở mọi tầng. Trong Java, quên bọc BufferedWriter quanh một FileWriter, hay flush() mỗi dòng log, cho ra đúng cái vực 3000 lần này. Trong CSDL nó là batching: 1000 câu INSERT riêng lẻ (mỗi cái một round-trip mạng + một lần commit) chậm hàng trăm lần một INSERT nhiều dòng hay một COPY, và đó là lý do JDBC có addBatch(), ORM có bulk insert. Trong hệ phân tán, Kafka producer gom message theo batch.size/linger.ms, gRPC/HTTP gộp request — cùng một ý: đừng trả phí round-trip cho từng đơn vị. Mẫu số chung: mỗi lần vượt một ranh giới đắt (syscall, round-trip mạng, commit) có một phí cố định không phụ thuộc kích thước; gom nhiều đơn vị nhỏ vào một lần vượt là đòn bẩy thông lượng lớn nhất và rẻ nhất — và "chậm bất thường" thường là quên gom, không phải kênh chậm. Gói lại bằng số: cùng một ống pipe cho 3,3 MB/s khi ghi từng byte (1 triệu lệnh write cho 1MB) và 10335 MB/s khi ghi lô 64KB (16 lệnh write) — gấp ~3000 lần — vì cỡ lô quyết định số syscall, và thông lượng đạt đỉnh đúng ở cỡ bằng dung lượng pipe (F_GETPIPE_SZ = 65536 byte). Pipe không chậm; ghi-từng-byte mới chậm.
Muốn tự thấy thì chạy thử ngay trong shell: yes | head -c 100000000 | pv > /dev/null — pv hiển thị thông lượng của pipe khi truyền 100MB, và bạn sẽ thấy nó cao ngất vì các công cụ này đều ghi/đọc theo lô lớn. Rồi so với một vòng lặp ghi từng byte: viết một chương trình nhỏ for i in range(N): os.write(fd, b'x') đẩy vào một pipe và đo — nó sẽ chậm đến bất ngờ. Muốn thấy nguyên nhân, chạy nó dưới strace -c và nhìn cột đếm write: con số đó chính là số lần bạn trả phí vượt rào, và nó tỉ lệ nghịch với cỡ lô bạn chọn. Đó là toàn bộ câu chuyện thông lượng của pipe, gói trong một cột của strace.