fork() Meets Buffered stdio: The Log Line That Prints Twice2026-06-05
This program forks a child to handle a task, then waits for it. Three log lines, three events. Run it on a terminal and it looks fine. Pipe it to a file and one line mysteriously duplicates.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
static void log_event(const char *event) {
printf("Event: %s\n", event);
}
int main(void) {
log_event("server starting");
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
log_event("child working");
return 0; // child returns from main
}
waitpid(pid, NULL, 0);
log_event("server done");
return 0;
}
Run it interactively:
$ ./srv
Event: server starting
Event: child working
Event: server done
Redirect to a file and add a tee for good measure:
$ ./srv > out.log
$ cat out.log
Event: server starting
Event: child working
Event: server starting ← ghost!
Event: server done
Where did the second "server starting" come from? You only called log_event on it once, before fork().
C's stdout is buffered, and the mode depends on what it's attached to. When stdout is a terminal, glibc uses line buffering: the \n in "Event: server starting\n" flushes the buffer immediately. When stdout is a pipe or regular file, glibc switches to full (block) buffering: the line sits in a 4–8 KB FILE* buffer until it fills, until fflush, or until the process exits cleanly.
Then fork() happens. fork() duplicates the process's entire address space — including the FILE* buffer holding "Event: server starting\n". Now there are two copies of that pending text, one in each process.
The child writes its own line into its copy of the buffer, then returns from main. Returning from main is equivalent to calling exit(), which runs the stdio cleanup handlers, which flush every FILE*. Out goes "Event: server starting\nEvent: child working\n". The parent eventually does the same, flushing its own copy of the pre-fork buffer plus "Event: server done\n". The pre-fork line gets written by both — hence the duplicate.
On a terminal you never saw the bug because line buffering had already drained the buffer before fork(). The buffering mode changed when you redirected — and so did the program's correctness. The bug was always there.
You have three reasonable options. Pick by intent:
fflush(NULL) (flushes all open streams) immediately before fork(). Nothing pending, nothing to duplicate._exit in the child. _exit(2) skips stdio cleanup, so the child never flushes its inherited buffers. Pair it with explicit fflushes for output the child does want written. This is the canonical pattern after fork().stdout unbuffered or line-buffered at startup with setvbuf(stdout, NULL, _IOLBF, 0). Reasonable for log-shaped programs; costlier for bulk output.log_event("server starting");
fflush(NULL); // drain stdio before fork
pid_t pid = fork();
if (pid == 0) {
log_event("child working");
fflush(stdout);
_exit(0); // skip stdio cleanup in child
}
The "tee output through a pipe" pattern is how this bug almost always shows up in production: it sleeps in dev, wakes up under systemd, logger, or a redirect.
fork() copies your stdio buffers along with everything else — flush before you fork, and _exit (not return) from the child to keep its inherited buffers from speaking on your behalf.
