Hình dung một cái hộp thư chung giữa hai phòng ban: phòng A bỏ thư vào, phòng B tới lấy ra. Cái hộp chứa được tối đa 64 tờ. Có ba cách dùng. Chạy sang bỏ từng tờ một rồi quay về — trăm tờ là trăm chuyến, kiệt sức (ghi từng byte). Nhét đầy cứng cả hộp 64 tờ — nhưng khi hộp đầy, A phải đứng chờ ngoài cửa tới khi B lấy sạch mới bỏ tiếp được, hai người dậm chân chờ nhau (khối bằng cả bộ đệm). Bỏ từng nắm vừa phải — A cứ thêm vào trong khi B cứ lấy ra, cả hai đều bận rộn không ai chờ ai (khối vừa). Nghịch lý là cách giữa — nhét đầy — lại chậm hơn cách bỏ từng nắm vừa. Pipe của Linux hành xử đúng như cái hộp thư đó.

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 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. Đúng cái hộp thư 64 tờ. 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, đúng cái hộp thư đầy cứng. 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ôi trường đo: container gcc:13 trên host ARM AArch64, bộ đệm pipe mặc định 64 KB, mỗi số lấy sau warmup. Con số tuyệt đối đổi theo máy; cái bền vững là hình dạng: có điểm ngọt cỡ khối, ghi vụn thảm hại, và độ trễ bị chi phí đánh thức thống trị.)

Hai niềm tin đối nghịch, cùng sai

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 và đánh thức mỗi lần.

Thứ hai, tinh vi hơn và ngược hướng: "khối càng to throughput càng cao, cứ ghi thật to vào". Đ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. Hai lầm tưởng kẹp hai đầu một sự thật: throughput không đơn điệu theo cỡ khối — quá nhỏ thì chết vì chi phí mỗi-lần (syscall, đánh thức), quá to (bằng cả bộ đệm) thì chết vì lockstep, và có một điểm ngọt ở giữa (thường quanh cỡ trang / một phần bộ đệm). 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Chỗ này cái "điểm ngọt cỡ khối" và "throughput ≠ độ trễ" hiện khắp nơi khi bạn để ý. Đó là lý do io.Copy của Go dùng buffer 32 KB mặc định, BufferedInputStream của Java và bufio của Go tồn tại (gom byte lẻ thành khối trước khi chạm syscall), và dd bs=... cho bạn chỉnh cỡ khối — đặt bs=1 là tái tạo đúng thảm họa 6 MB/s ở trên. Trên shell, cat file | wc nhanh vì cat ghi khối lớn; một script đọc từng ký tự thì bò. Còn cặp throughput-vs-độ-trễ chính là cặp bandwidth-vs-latency của mạng, và là lý do Kafka có batch.size + linger.ms (gom message để throughput cao, đổi lấy chút độ trễ) — bạn chọn điểm trên đường cong tùy cần thông lượng hay phản hồi nhanh. Cùng một bài học cái-hộp-thư, lặp lại từ pipe tới message broker.

Muốn tự thấy thì 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). Vài phút 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.