Ống pipe (pipe) là cách hai tiến trình nói chuyện với nhau 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. Câu hỏi thực tế: một pipe chuyển dữ liệu nhanh cỡ nào? 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.
Một lần tôi đo hớ: "pipe chậm" 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. Bài học đo lường: 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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 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.
Hệ quả 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.
Hệ quả thứ ba là một cách tư duy khi đọc bất kỳ con số hiệu năng nào: hỏi rõ con số đó đo đại lượng gì. "Pipe làm được 3,3 MB/s" và "pipe làm được 10 GB/s" đều đúng và đều là pipe, nhưng một cái đo tốc độ syscall còn cái kia đo băng thông bộ nhớ. Con số mang theo: cùng một ống pipe cho 3,3 MB/s khi ghi từng byte và 10335 MB/s khi ghi lô 64KB — 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 (64KB). Pipe không chậm; ghi-từng-byte mới chậm. Gom lô, và đo cái mình tưởng mình đang đo.
Thử ba mươi giây
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.