bài về bộ nhớ chia sẻ ta thấy pipe cho ta sự đồng bộ miễn phí — bên đọc tự chờ khi chưa có dữ liệu. Nhưng phía sau tiện lợi đó là một bộ đệm trong nhân có dung lượng hữu hạn, và chính nó tạo ra hai hành vi quan trọng: khi đầy thì write chặn, khi rỗng thì read chặn. Bài này đo trực tiếp dung lượng của bộ đệm đó và hành vi chặn khi đầy — và phát hiện "dung lượng pipe" không phải một con số duy nhất như tôi tưởng; chính cách tôi đo đã định hình câu trả lời.

Pipe và bộ đệm

Bộ đệm pipe và chuyện chặn

Một pipe không chuyển dữ liệu thẳng từ bên ghi sang bên đọc; nó chứa dữ liệu tạm trong một bộ đệm vòng nằm trong nhân, dung lượng cố định (mặc định trên Linux là 65536 byte = 64 KB, tức 16 trang 4 KB). Bên ghi bỏ dữ liệu vào bộ đệm, bên đọc lấy ra — và chính cái đệm hữu hạn này tạo ra điều tiết luồng (flow control):

  • Khi bộ đệm đầy và bên ghi vẫn write, lời gọi đó bị chặn (block), treo tiến trình ghi lại cho tới khi bên đọc rút bớt chỗ.
  • Khi bộ đệm rỗng và bên đọc read, lời gọi đó bị chặn cho tới khi có dữ liệu.

Đây là phản áp (backpressure) mà nhân cho ta miễn phí: một bên sản xuất nhanh không thể làm ngập bên tiêu thụ chậm, vì nó sẽ tự bị ghì lại khi đệm đầy. Dung lượng đệm xem được bằng fcntl(fd, F_GETPIPE_SZ) và đổi được bằng F_SETPIPE_SZ. Câu hỏi tôi muốn đo: dung lượng thật là bao nhiêu, và hành vi chặn cụ thể ra sao?

Đo: 64 KB, nhưng còn tùy cách ghi

F_GETPIPE_SZ báo 65536 — 64 KB. Để kiểm, tôi đặt đầu ghi thành không chặn (O_NONBLOCK) rồi ghi liên tục, đếm số byte nhét được cho tới khi write trả về EAGAIN (báo đầy). Nhưng kết quả phụ thuộc một thứ tôi không ngờ — cỡ khối mỗi lần ghi:

Cỡ khối ghi Nhét được trước khi chặn
4096 (đúng một trang) 64 KB
8192, 65536 (bội số trang) 64 KB
4097 (lố đúng 1 byte) 44 KB
5000 52 KB
6000 62 KB

Ghi bằng khối tròn trang (4096, 8192...) thì nhét đủ 64 KB như quảng cáo. Nhưng ghi bằng khối 4097 byte — chỉ lố một byte qua ranh giới trang — thì pipe đầy sớm ở 44 KB, mất đứt một phần ba dung lượng. Con số này ổn định qua nhiều lần đo, và các cỡ lẻ khác (5000 → 52 KB, 6000 → 62 KB) cho thấy một quy luật: khối càng lệch trang, dung lượng dùng được càng hụt.

Một lần tôi đo hớ: "dung lượng pipe" không phải một số

Ban đầu tôi định làm một việc tưởng đơn giản: viết một vòng ghi tới khi đầy, đọc ra con số, và tuyên bố "dung lượng pipe là X". Tùy lần chạy đầu tiên tôi chọn cỡ khối nào, tôi đã có thể kết luận "pipe chứa 64 KB" hoặc "pipe chứa 44 KB" — và cả hai đều là con số tôi đo được, nhưng không con số nào là "dung lượng pipe" theo nghĩa độc lập.

không có một dung lượng pipe độc lập với cách ghi. Lý do nằm ở cách nhân quản lý bộ đệm: nó cấp chỗ theo trang 4 KB, và một lời gọi write() mới không nhồi tiếp vào phần trang mà một write() trước đó để dở. Nên khi bạn ghi khối 4097 byte, mỗi khối chiếm trọn phần đầu một trang rồi tràn 1 byte sang trang kế — và trang kế đó, dù còn thừa gần 4 KB, không được khối sau dùng lại. Phần đuôi trang bị bỏ phí, và tổng chỗ dùng được tụt xuống. Ghi tròn trang thì không phí gì; ghi lệch trang thì phí đều đặn.

Điều đáng nói: F_GETPIPE_SZ vẫn kiên định báo 64 KB trong mọi trường hợp — nó cho biết kích thước bộ đệm, không phải lượng dữ liệu bạn thực sự xếp được vào trước khi bị chặn. Hai đại lượng đó khác nhau, và tôi đã suýt lẫn chúng làm một. Bài học đo lường: chính cỡ khối trong vòng đo của tôi là một biến ẩn — cách tôi đo đã định hình kết quả. Khi đo "dung lượng" của một thứ, phải hỏi "dung lượng đo bằng cách nào"; một con số đơn lẻ tách khỏi phương pháp đo dễ thành nửa đúng. Đây là họ hàng với những cái bẫy trước trong sê-ri: vòng đo, trình biên dịch, hay ở đây là cỡ khối ghi — công cụ đo luôn là một phần của phép đo.

Đo tiếp: hành vi chặn khi đầy

Phần thứ hai của bộ đệm là chuyện chặn. Tôi fork một tiến trình con cố tình ngủ 500 mili giây rồi mới rút dữ liệu, trong khi tiến trình cha ghi một mạch 150 KB (lớn hơn dung lượng 64 KB) ở chế độ chặn bình thường:

write 150 KB (chặn): trả về sau 511 ms  (bên đọc ngủ 500ms rồi mới rút)

Con số nói rõ cơ chế: lời write 150 KB không trả về ngay. 64 KB đầu lấp đầy bộ đệm, rồi write treo lại — tiến trình cha ngồi chờ — cho tới khi tiến trình con thức dậy (sau 500 ms) và bắt đầu rút dữ liệu, giải phóng chỗ để phần còn lại được ghi nốt. Tổng cộng lời ghi mất 511 ms, gần đúng bằng 500 ms cha phải chờ con. Đây chính là phản áp trong hành động: bên ghi không thể chạy nhanh hơn bên đọc chịu nhận. Và dung lượng có thể nới: F_SETPIPE_SZ(1 MB) đổi bộ đệm lên 1024 KB — cho phép bên ghi chạy trước xa hơn trước khi bị ghì.

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

Hệ quả đầu tiên là hiểu vì sao chương trình của bạn "treo" ở một lời write. Một tiến trình dường như đứng im tại write vào pipe hay socket rất thường không phải bị lỗi — nó đang bị phản áp: bộ đệm đầy vì bên kia đọc chậm hoặc đã ngừng đọc. Đây là cơ chế lành mạnh (nó ngăn tràn bộ nhớ vô hạn), nhưng nếu bạn không lường trước, nó biến thành một cái "treo" khó hiểu. Khi thấy tiến trình kẹt ở write, hãy nhìn về phía bên đọc: nó có đang đọc đủ nhanh không, hay đã chết mà chưa đóng đầu đọc?

Hệ quả thứ hai là backpressure là tính năng, không phải lỗi — hãy tận dụng nó. Việc write tự chặn khi đầy chính là cách một đường ống (pipeline) tự điều tiết: cmd1 | cmd2cmd2 chậm thì cmd1 tự động bị ghì lại, không ngốn RAM vô hạn để đệm. Khi tự viết producer–consumer, đừng vội "sửa" cái chặn đó bằng đệm khổng lồ hay ghi không chặn rồi tự quản; cái chặn sẵn có thường là điều tiết luồng đúng đắn. Chỉ nới F_SETPIPE_SZ khi bạn đo được rằng đệm nhỏ đang là nút thắt thật (bên ghi bị ghì trong khi bên đọc vẫn rảnh).

Hệ quả thứ ba, về đo lường: đừng tin một con số "dung lượng" mà không hỏi nó được đo thế nào. Con số mang theo: pipe báo dung lượng 64 KB, nhưng lượng thật xếp được trước khi write chặn phụ thuộc cỡ khối ghi — tròn trang thì 64 KB, lệch trang (4097 byte) chỉ 44 KB — và khi đầy, write chặn đúng tới khi bên đọc rút (đo được: ghi 150 KB mất 511 ms vì bên đọc trễ 500 ms); dung lượng nới được bằng F_SETPIPE_SZ. Bộ đệm pipe là một cơ chế điều tiết luồng tinh tế; hiểu nó biến những cái "treo" bí ẩn thành hành vi đọc được, và nhắc ta rằng ngay cả một phép đo đơn giản cũng mang dấu vân tay của cách đo.

Thử ba mươi giây

Xem dung lượng pipe trên máy bạn mà không cần viết code: ulimit -a | grep pipe (thường hiển thị theo đơn vị 512 byte, 8 × 512 = 4 KB mỗi "khối", 16 khối = 64 KB). Muốn thấy phản áp tận mắt, chạy yes | (sleep 3; cat >/dev/null)yes sinh dữ liệu vô hạn, nhưng nó không ngốn hết RAM: sau khi lấp đầy 64 KB bộ đệm pipe, yes tự bị chặn ở write, đứng chờ ba giây cho tới khi cat bắt đầu rút. Nhìn yes "ngoan ngoãn" chờ đợi đó chính là bộ đệm pipe và cơ chế chặn mà bài này đo — flow control có sẵn, không cần bạn viết một dòng nào.