Hình dung bạn phải gửi thư. Nếu mỗi lá viết xong bạn lại chạy ra bưu điện gửi ngay, một buổi chiều viết trăm lá là trăm chuyến đi — mệt phờ, và phần lớn thời gian là đi lại chứ không phải viết. Khôn hơn: bỏ thư vào một cái hộp ở nhà, khi hộp đầy mới mang cả nắm ra gửi một chuyến. Trăm lá thư giờ chỉ vài chuyến. Đó chính xác là điều đệm stdio làm khi bạn printf: nó không chạy ra "bưu điện" (nhân) mỗi lần, mà gom vào một bộ đệm rồi gửi cả nắm.
Khi bạn viết printf hay fputc ra một file, có phải mỗi lần gọi là một lời gọi hệ thống không? Trực giác của nhiều người là "có" — và nếu đúng thế thì in nhiều sẽ chậm khủng khiếp, vì mỗi lần vượt ranh giới user/kernel tốn ~100ns. Nhưng thực tế printf ra file lại rất nhanh. Bí mật nằm ở đệm của stdio: thư viện FILE* tự gom nhiều lần ghi vào một bộ đệm trong user-space, rồi mới xả ra bằng một write() duy nhất. Tôi đo hiệu ứng đó, và cả một cái bẫy tinh vi mà cái đệm này giấu: stdout không luôn được đệm như nhau.
stdio đệm giùm bạn
Mỗi FILE* có một bộ đệm. Khi bạn putc('x', f), ký tự đó không đi thẳng ra đĩa — nó vào bộ đệm trong user-space (cái hộp thư ở nhà). Chỉ khi bộ đệm đầy (hoặc khi bạn gọi fflush, hay fclose) thì stdio mới gọi một write() cho cả bộ đệm. Nghĩa là hàng nghìn putc chỉ tốn vài write() — stdio tự amortize số lần vượt ranh giới giùm bạn, đúng bài học gộp việc ở phần 36, nhưng làm sẵn ở tầng thư viện.
Bạn điều khiển hành vi này bằng setvbuf(f, buf, mode, size) (gọi trước thao tác đầu tiên):
_IOFBF— đệm đầy: xả khi buffer đầy. Mặc định cho file thường._IOLBF— đệm dòng: xả mỗi khi gặp\n._IONBF— không đệm: mỗi lần ghi là mộtwrite()ngay (chạy ra bưu điện từng lá).
Tôi đo bằng cách ghi 10 triệu ký tự bằng putc ra một file, với các chế độ khác nhau, trong container gcc:13.
Đo: có đệm nhanh hơn 116 lần
Ghi 10 triệu ký tự bằng putc ra file:
_IONBF (không đệm) : 2,277 s | 4,4 MB/s | ~10.000.000 write() syscall
_IOFBF 4KB (mặc định file): 0,020 s | 509,8 MB/s | ~2.442 write() -> nhanh 116×
_IOFBF 64KB : 0,018 s | 566,6 MB/s | ~153 write() -> nhanh 129×
_IOFBF 1MB : 0,018 s | 571,2 MB/s | ~10 write() -> nhanh 130×
Không đệm (_IONBF), mỗi putc là một write() — 10 triệu lời gọi hệ thống, và mất 2,277 giây cho 10 MB, chỉ 4,4 MB/s. Đây đúng cái thảm họa "ghi từng byte" của phần 36: thời gian bị chi phối bởi số lần vượt ranh giới, không phải bởi việc ghi. Bật đệm đầy 4 KB (mặc định cho file), 10 triệu putc chỉ còn ~2.442 write() (mỗi write xả một buffer đầy 4 KB), và chạy trong 0,020 giây — nhanh 116 lần. Cùng đúng đoạn code putc, chỉ khác việc có một bộ đệm.
Chú ý cột kích thước buffer: từ 4 KB lên 64 KB lên 1 MB, số syscall giảm mạnh (2442 → 153 → 10), nhưng tốc độ gần như không đổi (116× → 129× → 130×). Nghĩa là cú nhảy lớn là từ "không đệm" sang "có đệm bất kỳ"; tăng buffer quá 4 KB chỉ thêm ~12% nữa. Cái 4 KB mặc định đã lấy gần hết lợi ích rồi — bạn hiếm khi cần setvbuf một buffer khổng lồ.
(Môi trường đo: container gcc:13 trên host ARM AArch64, g++ -O2, ghi ra file trong page cache, mỗi số lấy MIN sau warmup. Con số đổi theo máy; cái cần nhìn là bước nhảy 116× từ không-đệm sang có-đệm, và buffer >4KB thêm không đáng kể.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin. Thứ nhất: "mỗi putc/fprintf là một write() syscall, nên in nhiều thì chậm". Con số bác thẳng — stdio đệm trong user-space, nên hàng triệu putc chỉ tốn vài nghìn write() (có đệm nhanh hơn không đệm 116 lần). Cái "chậm khủng khiếp" chỉ xảy ra khi bạn tắt đệm (_IONBF) hoặc dùng write() thô từng byte.
Thứ hai, tinh vi hơn: "stdout luôn được đệm như nhau". Sai, và đây là chỗ hay cắn người ta: mặc định của stdio khác nhau tùy đích. Khi stdout nối tới một file hoặc pipe, nó đệm đầy (_IOFBF) — nhanh, nhưng output chỉ hiện khi buffer đầy hoặc chương trình kết thúc. Khi stdout nối tới một terminal (tty), nó đệm dòng (_IOLBF) — xả mỗi \n, để bạn thấy output ngay từng dòng. Còn stderr thì không đệm (_IONBF) — hiện tức thì, để thông báo lỗi không bị kẹt trong buffer khi chương trình sắp chết.
Hệ quả rất thực tế: cùng một chương trình chạy nhanh hơn hẳn khi bạn ./prog > out.txt (đệm đầy) so với khi in thẳng ra màn hình (đệm dòng, xả mỗi dòng). Và nếu chương trình crash trước khi buffer được xả, output đang nằm trong buffer sẽ mất — đó là lý do bạn thấy log "thiếu dòng cuối" khi một chương trình chết đột ngột, và vì sao đôi khi phải fflush(stdout) (hoặc setvbuf(_IONBF)) để chắc chắn output ra ngay. Nói gọn: stdio tự gom syscall giùm bạn (nên đừng sợ printf), nhưng chính sách đệm mặc định phụ thuộc đích của stdout — thứ quyết định cả tốc độ lẫn việc output có hiện kịp/còn nguyên khi crash hay không.
Về đến code hằng ngày
Điều đầu tiên: đừng sợ printf/fputc, nhưng đừng tắt đệm bừa. stdio đã gom syscall giùm bạn — in nhiều dòng ra file là nhanh. Nếu profiler chỉ vào I/O văn bản chậm, kiểm xem bạn có vô tình đặt _IONBF, hay đang dùng write() thô từng mẩu nhỏ (mỗi lần một syscall) thay vì stdio — đó là chỗ mất 116 lần.
Điều thứ hai: biết stdout của bạn đang đệm kiểu gì, và fflush khi cần thấy output ngay. Một chương trình in tiến độ ra file (đệm đầy) sẽ không thấy gì cho tới khi buffer đầy hoặc kết thúc — nếu bạn tail -f một log mà thấy nó "đứng im rồi nhảy một cục", đó là đệm đầy. Muốn dòng hiện ngay, fflush(stdout) sau mỗi dòng quan trọng, hoặc đặt setvbuf(stdout, NULL, _IOLBF, 0). Và nhớ: output trong buffer mất khi crash — dùng stderr (không đệm) cho thông báo lỗi quan trọng.
Chỗ này gần như mọi ngôn ngữ đều dùng cùng cơ chế, và cái bẫy "đệm theo đích" lặp lại y hệt — biết trước đỡ rất nhiều giờ gỡ lỗi. Python có print() đệm và nổi tiếng với vụ log "mất tích" trong container cho tới khi người ta đặt PYTHONUNBUFFERED=1 hoặc flush=True — đúng cái bẫy stdout-ra-pipe-thì-fully-buffered ở trên (chạy trong Docker, stdout là pipe chứ không phải tty, nên buffer đầy mới xả). Java System.out là PrintStream có cờ autoflush, còn System.err không cần vì ghi ngay; Go bufio.Writer bắt bạn Flush() tường minh và quên Flush là mất output kinh điển. Ngay cả lời khuyên "thêm -u cho python -u" hay "stdbuf -oL" trên shell cũng chính là chỉnh chế độ đệm này. Cùng một hộp-thư-gom-gửi, cùng một cái bẫy quên-gửi, ở mọi runtime.
Muốn tự thấy thì viết một vòng lặp putc một triệu ký tự ra một file, và bấm giờ. Rồi thêm setvbuf(f, NULL, _IONBF, 0) trước vòng lặp và bấm giờ lại — bạn sẽ thấy nó chậm đi cả trăm lần, vì giờ mỗi putc là một write(). Rồi thử một thí nghiệm khác: viết một chương trình in vài dòng rồi sleep, chạy nó hai lần — một lần ra terminal (./prog), một lần vào file (./prog > out.txt rồi cat out.txt sau khi nó xong). Ra terminal bạn thấy từng dòng ngay (đệm dòng); ra file bạn không thấy gì trong out.txt cho tới khi chương trình kết thúc (đệm đầy). Vài phút đó cho bạn thấy hai sự thật mà printf quen thuộc giấu: nó không phải một syscall mỗi lần (stdio đệm giùm), và cách nó đệm — cùng cả việc output hiện kịp hay không — phụ thuộc vào stdout đang nối tới đâu.