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.

Buffered vs unbuffered stdio

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. 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ột write() ngay.

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âynhanh 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ột lần tôi đo hớ: "mỗi printf là một syscall" và "stdout luôn đệm như nhau"

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". Sai — 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. Bài học đo lường: 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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.

Hệ quả thứ ba là tinh thần đo lường: một chi phí per-item "nhỏ" được thư viện gom giùm — nhưng chỉ khi bạn để nó gom. Con số mang theo: stdio đệm trong user-space nên nhiều putc/fprintf chỉ tốn một write() khi buffer đầy — có đệm (mặc định 4KB) nhanh hơn không đệm (_IONBF, mỗi putc một syscall) 116 lần (10 triệu write() vs 2442, 4,4 vs 510 MB/s); buffer lớn hơn 4KB chỉ thêm ~12%, lợi ích lớn nhất là CÓ đệm vs không. Và mặc định đệm KHÁC nhau tùy đích: stdout ra file/pipe fully buffered, ra terminal line buffered, stderr unbuffered — nên cùng code chạy nhanh/chậm khác nhau, và output có thể trễ hoặc mất khi crash nếu chưa fflush. Thư viện gom syscall giùm bạn là món quà; hiểu nó gom kiểu gì cho từng đích mới dùng đúng.

Thử ba mươi giây

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). Ba mươi giây đó 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.