Sau bộ I/O file, ta sang giao tiếp giữa tiến trình (IPC), và cơ chế cổ điển nhất là pipe — cái | trong shell chính là nó. Câu hỏi: pipe nhanh hay chậm? Câu trả lời phụ thuộc bạn hỏi về chỉ số nào, và đây là chỗ nhiều người lẫn: throughput (bao nhiêu byte mỗi giây) và độ trễ (một lần bàn giao mất bao lâu) là hai thứ khác nhau, và một pipe có thể tuyệt ở cái này mà tệ ở cái kia. Tôi đo cả hai trong container gcc:13 (ARM), và gặp một kết quả về cỡ khối làm tôi phải xem lại "to hơn thì nhanh hơn".

Pipe: throughput và độ trễ

Pipe là gì, và hai chỉ số

Pipe là một ống trong nhân nối hai tiến trình: bên ghi gọi write() đẩy dữ liệu vào, bên đọc gọi read() lấy ra. Dữ liệu đi qua một bộ đệm pipe trong nhân (mặc định 64 KB trên Linux), hoàn toàn trong RAM — không chạm đĩa. Hai chỉ số cần tách bạch:

Throughput — khi bàn giao nhiều dữ liệu (một luồng byte lớn), pipe chuyển được bao nhiêu GB/s. Độ trễ — khi bàn giao một thông điệp nhỏ và chờ phản hồi, một vòng mất bao lâu. Một hệ thống có thể có throughput cao (chuyển hàng loạt nhanh) mà độ trễ cao (mỗi lần trao đổi lẻ chậm) — như một xe tải chở nhiều hàng nhưng đi chậm.

Đo: 9 GB/s khối vừa, 6 MB/s từng byte

Trước hết throughput — đẩy 1 GB qua pipe với các cỡ khối khác nhau:

THROUGHPUT (1GB qua pipe):
  khối 4KB  : 9,17 GB/s
  khối 64KB : 3,01 GB/s
  khối 1B   : 0,006 GB/s  (6 MB/s — mỗi byte một syscall)

Với khối vừa (4 KB), pipe đạt 9,17 GB/s — rất nhanh, vì dữ liệu chỉ đi qua RAM. Nhưng ghi từng byte một thì thảm họa: 6 MB/s, chậm hơn ~1.500 lần. Vì mỗi byte là một system call write riêng (~100 ns vượt ranh giới) chỉ để chuyển đúng một byte — chi phí syscall nuốt hết. Đây là cùng bài học đồng bộ per-item của sê-ri đồng thời: bàn giao vụn giết throughput.

Có một bất ngờ ở đây, và nó phá một niềm tin: khối 4 KB (9,17 GB/s) nhanh hơn khối 64 KB (3,01 GB/s). "To hơn thì nhanh hơn" sai. Vì bộ đệm pipe là 64 KB: khi bạn ghi cả khối 64 KB, nó lấp đầy bộ đệm rồi bên ghi phải chặn chờ bên đọc rút hết — hai bên chạy lockstep, ít chồng lấn. Với khối 4 KB, nhiều lần ghi lọt vào bộ đệm trước khi bên đọc cần chạy, nên bên ghi và bên đọc chồng lấn (pipeline) tốt hơn — ít chặn, ít đánh thức. Có một điểm ngọt quanh cỡ trang, không phải "càng to càng nhanh".

Giờ độ trễ — ping-pong một byte qua lại giữa hai tiến trình:

ĐỘ TRỄ (ping-pong 1 byte):
  round-trip : 17.737 ns (~17,7 µs)
  một chiều  :  ~8.869 ns (~8,9 µs)

Một vòng round-trip tốn 17,7 µs, tức ~8,9 µs mỗi chiều. Con số này cao hơn nhiều so với 579 ns của chuyển ngữ cảnh khi cả hai sẵn sàng — vì ở đây khác về chất: mỗi bàn giao, bên nhận đang ngủ trong nhân chờ dữ liệu (chặn trên read rỗng), và bên gửi phải đánh thức nó. Mỗi lần là một ngủ→thức thật, đúng bằng ~8,5 µs chi phí đánh thức mà Series 25 đo. Đây không phải "pipe chậm" — đây là cái giá của việc đánh thức một tiến trình đang ngủ, và nó áp cho mọi cơ chế IPC chặn.

Một lần tôi đo hớ: "pipe chậm" và "to hơn thì nhanh"

Tôi vào đo với hai niềm tin. Thứ nhất: "pipe chậm — dùng shared memory mới nhanh". Sai — với khối vừa, pipe đạt 9 GB/s, đủ nhanh cho gần như mọi ứng dụng. Nó chỉ chậm khi bạn bàn giao vụn (từng byte, từng thông điệp nhỏ) — và khi đó thủ phạm không phải pipe mà là syscall mỗi lầnđánh thức mỗi lần.

Thứ hai, tinh vi hơn: "khối càng to throughput càng cao". Đo phá ngay — 4 KB nhanh hơn 64 KB gấp 3 lần, vì khối bằng cả bộ đệm pipe làm hai bên chặn nhau. Bài học đo lường: throughput không đơn điệu theo cỡ khối — có một điểm ngọt (thường quanh cỡ trang / một phần bộ đệm), và vượt qua nó có thể chậm lại. Nếu tôi chỉ đo 1 byte và 64 KB rồi kết luận "to hơn tốt hơn", tôi đã bỏ lỡ rằng 4 KB đánh bại cả hai. Đây cũng là lời nhắc quen thuộc của sê-ri: phải quét đường cong, không đo hai điểm rồi ngoại suy — giống hệt kích thước hàng đợi bão hòa đã đo.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: bàn giao khối, đừng bàn giao vụn. Nếu bạn dùng pipe (hay bất kỳ IPC nào) để chuyển dữ liệu, gom thành khối vài KB rồi ghi một lần — đừng ghi từng byte hay từng thông điệp nhỏ. Chênh lệch throughput là ~1.500 lần. Cùng nguyên tắc batching xuyên suốt: giảm số lần bàn giao (mỗi lần một syscall + có thể một đánh thức), không tối ưu từng byte.

Hệ quả thứ hai: phân biệt bạn cần throughput hay độ trễ, và tối ưu đúng cái. Chuyển một luồng dữ liệu lớn giữa hai tiến trình: throughput quan trọng, dùng khối vừa. Trao đổi thông điệp request-response nhỏ và cần phản hồi nhanh: độ trễ quan trọng, và ~8,9 µs mỗi chiều (chi phí đánh thức) là sàn — nếu cần thấp hơn, phải tránh chặn (busy-poll, shared memory với vòng lặp), đổi bằng CPU. Đừng tối ưu throughput khi vấn đề là độ trễ, hay ngược lại.

Hệ quả thứ ba là tinh thần đo lường: một cơ chế có thể tốt ở chỉ số này, tệ ở chỉ số kia — đo đúng cái bạn cần, và quét cả đường cong. Con số mang theo: pipe đạt throughput cao khi bàn giao khối (4KB: 9,17 GB/s) nhưng thảm khi ghi từng byte (6 MB/s, mỗi byte một syscall, chậm ~1500×), và khối quá to (64KB = cả bộ đệm) lại chậm hơn khối vừa (3,01 vs 9,17 GB/s) — có điểm ngọt, không phải càng to càng nhanh; còn độ trễ ping-pong round-trip 17,7µs (một chiều ~8,9µs) cao không vì pipe chậm mà vì mỗi bàn giao hai bên NGỦ/THỨC (chi phí đánh thức ~8,5µs). Pipe không chậm — bàn giao vụn mới chậm.

Thử ba mươi giây

Nếu bạn có hai tiến trình (hay hai chương trình nối bằng |) trao dữ liệu qua pipe và thấy chậm, hỏi: chúng đang ghi khối hay từng dòng/từng byte? Chạy thử yes | head -c 1G | wc -c (pipe khối lớn, nhanh) so với một script đọc/ghi từng ký tự — chênh lệch throughput sẽ lộ ngay. Và nếu độ trễ mới là vấn đề (một vòng request-response chậm), nhớ rằng mỗi lần một bên phải đánh thức bên kia đang ngủ tốn ~vài µs — không tối ưu được bằng cách đổi cỡ khối, mà bằng cách giảm số vòng bàn giao (gộp nhiều yêu cầu). Ba mươi giây phân biệt "throughput hay độ trễ" và "khối hay vụn" đó chỉ đúng nút thắt của một đường ống IPC — và thường cách sửa chỉ là gom dữ liệu thành khối lớn hơn.