Loading content...
Consider a simple program that writes one million characters to a file, one at a time. With unbuffered I/O, this requires one million system calls—each one crossing the user-kernel boundary, saving registers, executing kernel code, and returning. This might take 15-30 seconds.
With buffered I/O, those million characters are accumulated in a user-space buffer. Only when the buffer fills (typically every 4-8KB) does a syscall occur. Instead of a million syscalls, you execute perhaps 125-250 write calls. The same operation completes in under a second—a 100x performance improvement.
This is the power of buffering—one of libc's most important contributions to application performance. Yet buffering is often misunderstood, leading to confusing bugs: output appearing in the wrong order, data lost on program crashes, or mysterious performance variations between terminal and file output.
This page provides a complete understanding of how libc buffering works, when it helps, when it hurts, and how to control it precisely.
By the end of this page, you will understand the three buffering modes (unbuffered, line-buffered, fully-buffered), how libc selects the default mode for different streams, how to explicitly control buffering with setvbuf(), and how to debug buffering-related issues in real programs.
To understand buffering, we must first understand the cost of system calls. Every syscall is expensive—far more expensive than you might expect.
When you call write(fd, buf, 1) to write a single byte:
syscall (x86-64) or equivalent, switching to kernel modeThe actual "write to device" is often the smallest part. The context switching overhead dominates for small operations.
| Component | Approximate Time | Notes |
|---|---|---|
| Minimal syscall (getpid) | ~100-200 ns | No I/O, just return value |
| write() - 1 byte to /dev/null | ~200-400 ns | Syscall + minimal kernel work |
| write() - 1 byte to file (cached) | ~300-600 ns | Involves VFS layer |
| write() - 1 byte to file (disk) | ~1-10 ms | Actual disk I/O if not cached |
| User-space function call | ~1-5 ns | For comparison: 100x faster |
Let's do the math for writing 1 million bytes:
Without buffering (1 byte at a time):
With 8KB buffer:
This isn't a micro-optimization—it's a fundamental change in program behavior.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <time.h>#include <fcntl.h> #define BYTES_TO_WRITE 1000000 // Measure elapsed time in millisecondsdouble get_time_ms() { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000.0 + ts.tv_nsec / 1000000.0;} int main() { double start, end; // === Approach 1: Unbuffered write() syscalls === int fd = open("/tmp/test_unbuffered.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); start = get_time_ms(); for (int i = 0; i < BYTES_TO_WRITE; i++) { write(fd, "x", 1); // One syscall per byte! } close(fd); end = get_time_ms(); printf("Unbuffered write(): %.2f ms (%.0f syscalls)", end - start, (double)BYTES_TO_WRITE); // === Approach 2: Buffered fputc() with stdio === FILE *fp = fopen("/tmp/test_buffered.txt", "w"); start = get_time_ms(); for (int i = 0; i < BYTES_TO_WRITE; i++) { fputc('x', fp); // Accumulates in stdio buffer } fclose(fp); end = get_time_ms(); printf("Buffered fputc(): %.2f ms (~%.0f syscalls)", end - start, (double)BYTES_TO_WRITE / BUFSIZ); // === Approach 3: Large write() in one call === char *buffer = malloc(BYTES_TO_WRITE); memset(buffer, 'x', BYTES_TO_WRITE); fd = open("/tmp/test_bulk.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); start = get_time_ms(); write(fd, buffer, BYTES_TO_WRITE); // Single syscall close(fd); end = get_time_ms(); printf("Single write(): %.2f ms (1 syscall)", end - start); free(buffer); return 0;} /* * Typical output on a modern Linux system: * * Unbuffered write(): 2847.32 ms (1000000 syscalls) * Buffered fputc(): 32.15 ms (~122 syscalls) * Single write(): 1.23 ms (1 syscall) * * Buffering provides ~90x improvement over unbuffered! * Direct bulk write is another ~25x improvement. */Beyond raw time, unbuffered I/O also causes excessive CPU cache pollution (kernel code evicts your process's cache), increased power consumption (more CPU wake-ups), and contention in multi-process systems. Buffering isn't just faster—it's more efficient across multiple dimensions.
The C standard defines three buffering modes for stdio streams. Understanding these modes is essential for predicting I/O behavior.
Unbuffered streams write data immediately to the underlying file descriptor without any user-space buffering. Every write operation results in a syscall.
// stderr is unbuffered by default
fprintf(stderr, "Error: connection failed"); // Immediately written
Characteristics:
Default assignment: stderr is unbuffered by default on most systems.
Line-buffered streams accumulate data in a buffer until:
) is written// stdout to a terminal is typically line-buffered
printf("Processing..."); // May be buffered
printf("done!
"); // Newline triggers flush; both lines appear
Characteristics:
Default assignment: stdout is line-buffered when connected to a terminal.
Fully-buffered streams accumulate data until:
fflush() is explicitly called// File streams are fully-buffered
FILE *fp = fopen("data.txt", "w");
for (int i = 0; i < 10000; i++) {
fprintf(fp, "Line %d
", i); // Accumulates in buffer
}
// Only ~2-3 write() syscalls for entire loop!
fclose(fp); // Final flush on close
Characteristics:
Default assignment:
stdout when redirected to a file or pipefopen() (except to terminals)| Mode | Constant | Flush Trigger | Typical Buffer Size | Use Case |
|---|---|---|---|---|
| Unbuffered | _IONBF | Every write | 0 | Errors, urgent output |
| Line-buffered | _IOLBF | Newline or buffer full | ~1 KB | Interactive prompts |
| Fully-buffered | _IOFBF | Buffer full or explicit | 4-8 KB (BUFSIZ) | File I/O, bulk data |
One of the most confusing aspects of stdio buffering is that stdout behaves differently depending on what it's connected to. This leads to programs that work correctly in one context but mysteriously fail in another.
When a program starts, libc checks whether stdout is connected to a terminal device (using the isatty() function):
This heuristic makes sense:
123456789101112131415161718192021222324252627282930313233343536373839404142
#include <stdio.h>#include <unistd.h> int main() { // Check if stdout is a terminal if (isatty(STDOUT_FILENO)) { printf("stdout is a TTY - line-buffered by default"); } else { printf("stdout is NOT a TTY - fully-buffered by default"); } // Check stderr (almost always unbuffered) if (isatty(STDERR_FILENO)) { fprintf(stderr, "stderr is a TTY - but still unbuffered"); } else { fprintf(stderr, "stderr is NOT a TTY - still unbuffered"); } return 0;} /* * Running directly: * $ ./buffering_detection * stdout is a TTY - line-buffered by default * stderr is a TTY - but still unbuffered * * Redirecting stdout: * $ ./buffering_detection > output.txt * stderr is NOT a TTY - still unbuffered (to terminal) * $ cat output.txt * stdout is NOT a TTY - fully-buffered by default * * Through a pipe: * $ ./buffering_detection | cat * stdout is NOT a TTY - fully-buffered by default * stderr is a TTY - but still unbuffered */This buffering behavior causes a notorious issue with progress displays:
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Downloading..."); // No newline!
// ... lengthy operation ...
sleep(5); // Simulate download
printf(" done!
");
return 0;
}
Expected behavior: See "Downloading..." immediately, then " done!" after 5 seconds.
Actual behavior when piped or redirected:
The first printf() is buffered, waiting for a newline or buffer fill.
grep or awk may delay output by many seconds because the source is fully-bufferedfclose() was never calledGNU coreutils provides stdbuf to modify buffering of a program without recompilation:
stdbuf -oL ./program — Force stdout to line-buffered
stdbuf -o0 ./program — Force stdout to unbuffered
stdbuf -o4096 ./program — Force stdout to 4KB buffer
This uses LD_PRELOAD to inject a library that calls setvbuf() before main().
The setvbuf() function allows explicit control over stream buffering. It must be called before any I/O operations on the stream.
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
Parameters:
stream: The FILE pointer to modifybuf: User-provided buffer, or NULL to let libc allocatemode: One of _IONBF, _IOLBF, or _IOFBFsize: Buffer size in bytes (ignored for _IONBF)Returns: 0 on success, non-zero on failure
1234567891011121314151617181920212223242526272829303132333435363738394041
#include <stdio.h>#include <stdlib.h> int main() { // ===== Example 1: Make stdout unbuffered ===== // Output appears immediately, no buffering setvbuf(stdout, NULL, _IONBF, 0); printf("This appears immediately, "); printf("even without a newline "); printf("because stdout is now unbuffered."); // All three printfs result in immediate output // ===== Example 2: Make stdout line-buffered (explicit) ===== // Useful when stdout is redirected but you want line behavior char line_buffer[1024]; setvbuf(stdout, line_buffer, _IOLBF, sizeof(line_buffer)); // ===== Example 3: Large buffer for bulk file I/O ===== FILE *fp = fopen("large_file.bin", "wb"); if (fp) { // Use a 64KB buffer for better performance with large files char *big_buffer = malloc(65536); setvbuf(fp, big_buffer, _IOFBF, 65536); // Write lots of data - fewer syscalls with larger buffer for (int i = 0; i < 1000000; i++) { fputc('x', fp); } fclose(fp); free(big_buffer); } // ===== Example 4: Unbuffered stderr (usually default, but explicit) ===== setvbuf(stderr, NULL, _IONBF, 0); fprintf(stderr, "Errors always appear immediately"); return 0;}Simpler interfaces exist for common cases:
// setbuf() - Simple interface (standard C)
void setbuf(FILE *stream, char *buf);
// Equivalent to:
// setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
// If buf is NULL: unbuffered
// If buf is non-NULL: fully-buffered with BUFSIZ buffer
setbuf(stdout, NULL); // Make stdout unbuffered
char mybuf[BUFSIZ];
setbuf(stdout, mybuf); // Make stdout fully-buffered with custom buffer
// setbuffer() - BSD extension, allows size specification
void setbuffer(FILE *stream, char *buf, size_t size);
// setlinebuf() - BSD extension, force line-buffered
void setlinebuf(FILE *stream);
// Equivalent to:
// setvbuf(stream, NULL, _IOLBF, 0);
You must call setvbuf() BEFORE any read or write operation on the stream. Once I/O has occurred, the buffering mode is locked. For stdout/stderr, call setvbuf() as the first thing in main(), before any printf() or similar calls. Violating this requirement leads to undefined behavior.
Flushing forces buffered data to be written to the underlying file descriptor. Understanding when flushing occurs—automatically and manually—is crucial.
int fflush(FILE *stream);
Behavior:
write() syscall)stream is NULL, flushes all open output streamsprintf("Connecting to server...");
fflush(stdout); // Force immediate display
// ... connection code ...
printf(" connected!
");
libc automatically flushes buffers in several situations:
| Trigger | Details |
|---|---|
| Buffer full | Obvious: no room for more data |
| Newline (line-buffered) | Only for _IOLBF mode |
| Reading from stdin | stdout is flushed before blocking read |
| Stream close | fclose() flushes before closing fd |
| Program exit | Normal exit() calls fclose() on all streams |
Explicit fflush() | Manual trigger |
fseek()/fsetpos()/rewind() | Position changes flush buffers |
The stdin-stdout coordination:
printf("Enter your name: "); // No newline, might be buffered
// Before scanf blocks waiting for input, stdout is flushed
char name[100];
scanf("%99s", name); // This causes printf output to appear first
This automatic flush before input is why interactive prompts work without explicit fflush()—but it only applies when stdin and stdout are both line-buffered and connected to a terminal.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <signal.h> // Example: Data loss without fflush on crash volatile sig_atomic_t running = 1; void handle_sigint(int sig) { running = 0;} int main() { signal(SIGINT, handle_sigint); FILE *log = fopen("app.log", "w"); if (!log) { perror("fopen"); return 1; } int counter = 0; printf("Writing to log. Press Ctrl+C to stop."); printf("Watch the difference between 'SAFE' commits and 'UNSAFE' writes. "); while (running) { counter++; // UNSAFE: Written to buffer, may be lost on crash fprintf(log, "Entry %d: UNSAFE - this might be lost", counter); // SAFE: Explicitly flushed, guaranteed written to kernel fprintf(log, "Entry %d: SAFE - flushed to kernel", counter); fflush(log); // This write is committed printf("\rWritten %d entries...", counter); fflush(stdout); usleep(100000); // 100ms delay } printf("Stopped. Check app.log - last UNSAFE entry may be missing."); // Uncomment to simulate crash without proper cleanup: // _exit(1); // Skips stdio cleanup - buffer lost! // Normal exit - this flushes all buffers fclose(log); return 0;} /* * If you kill this program with SIGKILL (kill -9), * the last few UNSAFE entries will be missing from the log, * but all SAFE entries will be present. * * fflush() ensures data reaches the kernel buffer. * For true durability, you'd also need fsync() to ensure * the kernel writes to disk - but that's page 3's topic. */Calling fflush(NULL) flushes ALL open output streams in your process. This is useful before operations that might not return (like exec() or before crashing intentionally for debugging). However, it has performance implications if you have many open files, so use it judiciously.
The interaction between stdio buffering and fork() is a classic source of bugs. When a process forks, the child inherits an exact copy of the parent's memory—including any unflushed stdio buffers.
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Hello"); // Written to buffer, NOT to stdout fd
pid_t pid = fork(); // Child gets copy of buffer with "Hello"
if (pid == 0) {
printf(" from child
"); // Adds to copied buffer
} else {
printf(" from parent
"); // Adds to original buffer
}
return 0; // exit() flushes buffers
}
Expected output:
Hello from parent
Hello from child
Actual output (both buffers flushed at exit):
Hello from parent
Hello from child
This looks correct, but what if the output is fully-buffered (redirected to file)? You might see:
Hello from child
Hello from parent
Or with more buffered data, the "Hello" prefix appears twice—once from each process flushing its copy of the buffer!
123456789101112131415161718192021222324252627282930313233343536373839404142434445
#include <stdio.h>#include <unistd.h>#include <sys/wait.h> int main() { // This output is buffered (no newline) printf("DEBUG: Starting process... "); // No ! // Fork BEFORE flushing pid_t pid = fork(); if (pid == 0) { // Child process printf("CHILD path taken"); // When child exits, its copy of buffer contains: // "DEBUG: Starting process... CHILD path taken" } else { // Parent process wait(NULL); // Wait for child printf("PARENT path taken"); // When parent exits, its buffer contains: // "DEBUG: Starting process... PARENT path taken" } return 0;} /* * Run directly (line-buffered stdout): * Output might look normal because of TTY detection. * * Run redirected (fully-buffered stdout): * $ ./fork_buffer_bug > output.txt * $ cat output.txt * DEBUG: Starting process... CHILD path taken * DEBUG: Starting process... PARENT path taken * * The "DEBUG: Starting process... " appears TWICE! * Both processes had unpushed copies of the buffer. */#include <stdio.h>
#include <unistd.h>
int main() {
printf("Starting... ");
fflush(stdout); // ← CRITICAL: Flush before fork
pid_t pid = fork();
if (pid == 0) {
printf("child
");
} else {
printf("parent
");
}
return 0;
}
Now both processes start with empty buffers. The "Starting... " appears exactly once.
Best Practice: Always call fflush(NULL) immediately before fork() to flush all output streams. This prevents duplicated output across process boundaries.
Buffers are also inherited across fork-exec patterns. If you fork, the child's buffers are copies. If you then exec(), those buffers are lost (new program image replaces memory). Always fflush() before fork() even if you plan to exec() in the child—otherwise the parent might see duplicated output if the exec() fails and the child exits normally.
While output buffering gets more attention, input buffering is equally important. When you call fread() or fgetc(), libc may read more data than requested into an internal buffer, serving subsequent reads from that buffer.
// You request 1 character:
int ch = fgetc(fp);
// Internally, libc might do:
// 1. Check if data exists in input buffer
// 2. If buffer empty: read(fd, buffer, BUFSIZ) - read 4-8KB!
// 3. Return one character from buffer
// 4. Next fgetc() reads from buffer, no syscall needed
This means a file read of 1 million characters might result in only ~125 read() syscalls (at 8KB each), not a million.
Input buffering creates issues when you mix stdio (FILE*) functions with raw read() syscalls on the same file descriptor:
#include <stdio.h>
#include <unistd.h>
int main() {
// stdin is FILE* with buffering
char line[100];
fgets(line, sizeof(line), stdin); // Reads buffered
printf("fgets got: %s", line);
// PROBLEM: Direct read on same fd
char buf[100];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); // Raw read
// This might return nothing because fgets() already read-ahead!
buf[n] = '\0';
printf("read() got: %s
", buf);
return 0;
}
What happens:
fgets() needs to find a newlineread(0, buffer, BUFSIZ) - reads perhaps 1000 bytesread(0, ...) directly on fd 0read() sees different data or blocksRule: Never mix stdio functions and raw I/O on the same underlying file descriptor.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
#include <stdio.h>#include <unistd.h>#include <string.h> int main() { printf("Enter first line: "); fflush(stdout); // Read with stdio - this buffers! char line1[100]; if (fgets(line1, sizeof(line1), stdin)) { line1[strcspn(line1, "")] = '\0'; printf("stdio got: '%s'", line1); } // Check if there's unread data in stdio buffer // (This is a glibc extension) #ifdef __GLIBC__ printf("Bytes in stdin buffer: %zu", stdin->_IO_read_end - stdin->_IO_read_ptr); #endif // To safely get remaining data, use stdio functions! printf("Enter second line: "); fflush(stdout); char line2[100]; if (fgets(line2, sizeof(line2), stdin)) { line2[strcspn(line2, "")] = '\0'; printf("stdio got: '%s'", line2); } return 0;} /* * Key insight: If you paste multiple lines at once, * fgets() may read them all into its buffer on the first call, * then serve subsequent fgets() from buffer without syscalls. * * If you'd used read() directly, you'd have missed the buffered data. */Use fileno(FILE *stream) to get the underlying file descriptor from a FILE*. But remember: if you then use raw I/O, you must be careful about buffered data. It's usually safer to pick either stdio OR raw I/O for a given file, not both.
Buffering is one of libc's most impactful features—providing 100x performance improvements while introducing subtle behavioral complexities. Understanding buffering is essential for debugging I/O issues and writing correct, efficient programs.
What's Next:
Now that we understand buffering, we'll explore the performance considerations that determine when to use library functions versus direct system calls. The next page examines the trade-offs between convenience and raw performance, and how to make informed decisions for different application requirements.
You now understand how libc implements buffering, the three buffering modes and their default assignments, how to control buffering with setvbuf(), and common pitfalls like fork() buffer duplication and mixed I/O. This knowledge is essential for debugging I/O behavior and optimizing application performance.