Silent Duplication: printf(), fork(), and Stdio Buffering
Learn why printf() output duplicates when redirecting to a file in Linux -- and how stdio
buffering, fork() and exit() interact to cause silent bugs.
The Surprising Behavior
You write a simple C program, run it, and everything works as expected. But one day, you decide
to redirect the output to a file, and suddenly the output is duplicated, maybe even in the wrong
order. No code changed. No logic changed. Just ./prog > out.txt instead of ./prog.
Understanding how stdio buffering works can save a lot of headaches in the future.
How printf() Works
Most programmers think of printf() as "write this text to the terminal". This mental model
is close enough for casual use, but it's wrong in a way that can bite you if you ever deploy
your code to users.
printf doesn't write directly to a file descriptor. It writes into a userspace buffer --
a chunk of memory managed by the C standard library. The actual write() syscall only happens
when that buffer is flushed. When flushing occurs depends on the buffering mode of the stream.
There are three buffering modes:
- Unbuffered -- every write goes to the file descriptor immediately.
stderruses this by default. - Line-Buffered -- the buffer is flushed whenever a newline (
\n) is written, or when the buffer fills up.stdoutuses this when connected to a **terminal (tty) ** - Fully-Buffered -- the buffer is only flushed when it fills up or is explicitly flushed with
fflush().stdoutswitches to this mode when connected to anything that isn't a tty -- including a file.
The last point is key to understanding the problem we described earlier. When you redirect stdout to a filew,
the C library detects that the file descriptor is not a tty (via isatty()) and silently switches to full buffering. Your
printf() calls now accumulate in memory and are written out in batches.
exit() vs. _exit(): Not the Same Thing
Before we look at the bug, you need to understand the difference between exit() and _exit(), the latter
being the raw system call.
exit() is a libc function. Before it hands control to the kernel, it performs cleanup:
- Calls any functions registered with
atexit() - Flushes and closes all open stdio streams (via
fflush()) - Then calls
_exit()
_exit() (and the raw sys_exit syscall) skips all of that. It terminates the process immediately,
leaving any unflushed stdio buffers unwritten to disk.
This distinction is normally invisible -- most programs just call exit() or return from main(), and
everything flushes cleanly. But once you add fork() into the mix, things can get weird.
The Bug: fork() Copies the Buffer
Here is a simple program that demonstrates the bug:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void) {
printf("hello\n");
fork();
exit(0);
}
Run the program normally:
$ ./prog
helloRedirect to a file:
$ ./prog > out.txt
$ cat out.txt
hello
helloSame program, different output. What gives?
When your program runs on a terminal, stdout is line-buffered. The \n at the end of "hello\n"
triggers an immediate flush -- the buffer is empty by the time fork() is called. Both the parent and child
inherit an empty buffer, and exit(0) has nothing to flush. We get the expected output: "hello".
When you redirect to a file, stdout is fully-buffered. The \n does nothing special -- the string
"hello\n" sits in the userspace buffer, unflushed. Then fork() gets called.
fork() creates a complete copy of the calling process, including its entire userspace memory
-- including the stdio buffer. Both the parent and child now hold an independent copy of the buffer containing
"hello\n". Both call exit(0). Both flush their buffers to the file descriptor. The file gets written to twice.
Before fork():
[stdio buffer: "hello\n"]
After fork():
Parent: [stdio buffer: "hello\n"] -> exit(0) -> fflush() -> writes "hello\n"
Child: [stdio buffer: "hello\n"] -> exit(0) -> fflush() -> writes "hello\n"
File contents: "hello\nhello\n"
Note: If you remove the newline, it makes things worse.
printf("hello"); // no \n
fork();
exit(0);Now it duplicates the output in the terminal too, because even line-buffered stdout hasn't flushed yet
by the time fork() is called. The buffer copy problem is the same -- but without a newline to trigger a flush,
now you will see it in the terminal and in the file.
The Solution
There are two standard solutions.
Fix 1: Flush Before Forking
Explicitly drain all stdio buffers before calling fork():
printf("hello\n");
fflush(stdout);
fork();
exit(0);Since the buffer is empty when fork() runs, neither process has anything to flush on exit. One write,
one copy in the file.
Fix 2: Use _exit() in the Child
In most real programs -- daemons, shells, anything that forks to do work -- the correct pattern is to have the child
use _exit() instead of exit(), and have only the parent call exit():
printf("hello\n");
if (fork() == 0) {
/* child does its work */
_exit(0); // no fflush, no atexit handlers
}
/* parent continues */
exit(0); // only the parent flushes_exit() bypasses stdio cleanup entirely. The child exits without flushing the inherited buffer copy, so
only the parent's flush reaches the file. This is actually the recommended approach in any fork()-based program
because it also prevents atexit() handlers from running multiple times, which can cause double-frees, duplicate
log entries, or corrupted state in cleanup code.
Controlling Buffering Mode Manually
If you need explicit control over buffering regardless of whether stdout is tty, use setvbuf():
// Force line buffering on stdout
setvbuf(STDOUT_FILENO, NULL, _IOLBF, 0);
// Force unbuffered
setvbuf(STDOUT_FILENO, NULL, _IONBF, 0);
// Force fully-buffered with custom buffer size
setvbuf(STDOUT_FILENO, NULL, _IOFBF, 4096);Call setvbuf() before any I/O on the stream. This is useful when writing programs that pipe output
to other programs and you want predictable buffering behavior without having fflush() calls everywhere.
Summary
printf()writes to a userspace buffer -- the actualwrite()syscall is deferred.stdoutis line-buffered on a tty, fully-buffered when redirected to a file or pipe.fork()copies the entire process image, including unflushed stdio buffers.exit()flushes stdio'_exit()does not. Both parent and child callingexit()means both flush the inherited buffer copy.- Always call
fflush(stdout)beforefork(), or use_exit()in the child. - This bug only surfaces under file redirection, making it easy to miss.
The deeper lesson here is that C standard library's I/O layer is an abstraction sitting between your code
and the kernel, and fork() brings a lot of the underlying complexity to the surface.
Having a deeper understanding of the C standard library and how it interacts with the kernel can save you a lot of
headaches and unexpected bugs down the road.