Chapter 25 — Process Termination

Chapter 25 — Process Termination
Part 5 of 5  |  fork(), stdio Buffers, _exit() + Summary + Exercise
§25.4
fork + buffers
3
Code Examples
7
Interview Q&As

Series Navigation: Part 1: _exit() & exit()| Part 2: Kernel Cleanup Details| Part 3: Exit Handlers – atexit()| Part 4: Exit Handlers – on_exit()| Part 5: fork() + stdio Buffers

What You Will Learn

This part explores one of the most surprising behaviours in Linux multi-process programming: after a fork(), if both parent and child call exit(), output from printf() can appear twice — and write() output appears only once and in a different order. Understanding why this happens and how to prevent it requires understanding where stdio buffers live in memory.

Keywords in this part:

stdio buffer userspace buffer kernel buffer line-buffered block-buffered fork() duplicates buffers write() vs printf() fflush() setvbuf() _exit() in child duplicate output STDOUT_FILENO

§ A — The Puzzle: Why Does printf() Appear Twice?

Consider this simple program (Listing 25-2 from the textbook):

#include "tlpi_hdr.h"

int main(int argc, char *argv[])
{
    printf("Hello world\n");
    write(STDOUT_FILENO, "Ciao\n", 5);

    if (fork() == -1)
        errExit("fork");

    exit(EXIT_SUCCESS);   /* both parent and child call exit() */
}

Run mode Command Actual output
To terminal ./prog Hello world
Ciao (expected, normal)
To file ./prog > a.txt Ciao
Hello world
Hello world ← appears TWICE!

Two strange things happen when redirected to a file:

  • printf() output appears twice
  • write() output appears before printf() output

§ B — Why It Happens: stdio Buffering Modes

The key is understanding where stdio buffers live and how buffering mode changes when output is redirected.

Two levels of buffering in Linux I/O:

Buffer Level Where it lives Affected by fork()? Which functions use it
Userspace stdio buffer Process’s own memory (heap) YES — duplicated by fork() printf(), fprintf(), fwrite(), fputs() …
Kernel buffer cache Kernel memory (not in process) NO — shared, never duplicated write(), read() (system calls)

How buffering mode changes based on destination:

stdout destination Buffering mode When is buffer flushed? printf with \n after fork()?
Terminal (TTY) Line-buffered On each newline ‘\n’ Already written to kernel before fork() → appears once
File / pipe / redirect Block-buffered Only when buffer is full or exit()/fflush() called Still in buffer at fork() time → duplicated → appears TWICE

§ C — Step-by-Step Explanation of the Duplicate Output

1
printf(“Hello world\n”): When stdout is redirected to a file, it is block-buffered. The string is placed in the userspace stdio buffer but NOT yet written to the kernel — even though there is a \n. Block-buffered mode ignores newlines.
2
write(STDOUT_FILENO, “Ciao\n”, 5): This is a direct system call — it bypasses userspace buffers and goes straight to the kernel buffer cache. “Ciao” is already in the kernel and will appear in the file immediately.
3
fork(): The child gets a complete copy of the parent’s address space — including the userspace stdio buffer which still contains the unflushed “Hello world\n”. Now both parent and child have their own copy of that string in their stdio buffers.
4
Both parent and child call exit(): exit() flushes stdio buffers. Both flush their copy of “Hello world\n” to the file. Result: “Hello world” appears twice. The “Ciao” from write() was already in the kernel buffer before fork() — it appears only once and before the printf output (because it was in the kernel first).

§ D — Solutions to Prevent Duplicate Output

There are several ways to prevent this problem:

Solution How to apply Best for
① fflush() before fork() Call fflush(NULL) or fflush(stdout) immediately before fork() to ensure all stdio buffers are empty Most common, simple fix
② Disable buffering setvbuf(stdout, NULL, _IONBF, 0) or setbuf(stdout, NULL) When you want every printf to go directly to kernel (may impact performance)
③ Child uses _exit() Child calls _exit() instead of exit() — skips stdio flush in child General best practice for child processes (also avoids double atexit handlers)
④ Only one process calls exit() Design the program so only parent calls exit(); child calls _exit() Recommended general architecture for fork()-based programs
General principle: In any application that creates child processes, typically only one process (usually the parent) should terminate via exit(). All other processes should terminate via _exit(). This ensures exit handlers and stdio flushing happen exactly once.

§ E — Why write() Output Appears Before printf() Output

When redirected to a file, “Ciao” from write() appears before “Hello world” from printf(), even though printf() was called first in the source code. Here is why:

Function Buffer path When reaches file Appears in output
printf("Hello world\n") → userspace stdio buffer → (waits for flush) → kernel buffer → file Only when exit() flushes the stdio buffer Second (after Ciao)
write(..., "Ciao\n", 5) → directly to kernel buffer → file Immediately — no userspace buffering First
General lesson: Never mix stdio functions (printf, fprintf, fwrite) and system call I/O (write) on the same file descriptor without being aware of buffering interactions. When you must mix them, flush the stdio buffer first with fflush().

§ F — Chapter 25 Summary

Here is a concise summary of all topics covered across the full Chapter 25 series:

  • Abnormal termination happens when a signal with default “terminate” action is delivered to the process.
  • Normal termination uses _exit() (system call, immediate) or exit() (C library: calls atexit handlers, flushes stdio, then calls _exit()).
  • Only the bottom 8 bits (0–255) of the exit status reach the parent via WEXITSTATUS().
  • By convention, 0 = success, non-zero = error. Use EXIT_SUCCESS and EXIT_FAILURE constants.
  • At termination (normal or abnormal), the kernel: closes FDs, releases file locks, detaches SysV shm, applies semadj, sends SIGHUP if controlling process, closes POSIX semaphores/mqueues, sends SIGHUP+SIGCONT to orphaned stopped groups, removes memory locks, unmaps mmap regions.
  • Exit handlers registered with atexit() (portable) or on_exit() (glibc only) are called in reverse registration order by exit(). Not called by _exit() or on signal.
  • fork() duplicates the userspace stdio buffer. If both parent and child call exit(), both flush — causing duplicate output. Fix: call _exit() in the child, or call fflush() before fork().
  • write() goes directly to the kernel — its output is never duplicated by fork(), and it is not affected by stdio buffering mode.

§ G — Exercise 25-1 (from the textbook) with Full Answer
Exercise 25-1:
If a child process makes the call exit(-1), what exit status (as returned by WEXITSTATUS()) will be seen by the parent?

Answer:

The parent will see 255.

Reasoning step by step:

  • exit(-1) passes -1 (an int) as the status argument to _exit().
  • -1 in 32-bit two’s complement binary is: 0xFFFFFFFF
  • Only the bottom 8 bits are made available to the parent: 0xFF
  • 0xFF = 255 (as an unsigned 8-bit value)
  • WEXITSTATUS(status) extracts those 8 bits and returns 255.
/* Verify Exercise 25-1 answer */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid;
    int wstatus;

    pid = fork();
    if (pid == 0) {
        exit(-1);            /* child exits with -1 */
    }
    waitpid(pid, &wstatus, 0);
    if (WIFEXITED(wstatus))
        printf("WEXITSTATUS = %d\n", WEXITSTATUS(wstatus));
    /* Output: WEXITSTATUS = 255 */
    return 0;
}
Answer: 255. Because -1 is 0xFFFFFFFF and only 0xFF (the bottom byte) = 255 reaches the parent.

Coding Examples

Example 1 — Reproducing the Textbook Listing 25-2 Duplicate Output

Run this program redirected to a file and you will see the double printf output. This is the exact Listing 25-2 scenario from the textbook.

/* fork_stdio_buf.c  (Listing 25-2 from TLPI)
 * Compile : gcc -Wall -o fork_stdio_buf fork_stdio_buf.c
 *
 * To terminal:    ./fork_stdio_buf
 *   Output: Hello world
 *           Ciao
 *
 * To file:        ./fork_stdio_buf > a.txt && cat a.txt
 *   Output: Ciao
 *           Hello world
 *           Hello world   ← printf appears TWICE (buffer was duplicated by fork)
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    /*
     * printf() goes to userspace stdio buffer.
     * When stdout is a terminal: buffer flushed immediately (line-buffered).
     * When stdout is a file:     stays in buffer (block-buffered).
     */
    printf("Hello world\n");

    /*
     * write() goes DIRECTLY to kernel buffer — no userspace buffering.
     * Not duplicated by fork(). Always appears first in the file.
     */
    write(STDOUT_FILENO, "Ciao\n", 5);

    /*
     * fork() creates a child that inherits a COPY of the stdio buffer.
     * If printf() output is still in the buffer at this point,
     * BOTH parent and child have a copy of it.
     */
    if (fork() == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    /*
     * Both parent AND child reach here and call exit().
     * exit() flushes stdio buffers in both processes.
     * → printf output is written to the file TWICE.
     */
    exit(EXIT_SUCCESS);
}
Confirm the bug: ./fork_stdio_buf > a.txt && cat a.txt
You will see “Hello world” appear twice. “Ciao” appears first because write() bypassed the userspace buffer.

Example 2 — Fix 1: fflush() Before fork()

The simplest fix: call fflush(NULL) before fork() to drain all userspace stdio buffers. After this, fork() copies nothing in the stdio buffer.

/* fix1_fflush_before_fork.c
 * Compile : gcc -Wall -o fix1 fix1_fflush_before_fork.c
 * Run     : ./fix1 > a.txt && cat a.txt
 *   Output (correct): Ciao
 *                     Hello world    ← appears ONCE
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    printf("Hello world\n");
    write(STDOUT_FILENO, "Ciao\n", 5);

    /*
     * FIX: flush ALL stdio streams before fork().
     * fflush(NULL) flushes every open stdio stream.
     * After this, the stdio buffer is empty — fork() copies nothing.
     */
    fflush(NULL);

    if (fork() == -1) { perror("fork"); exit(EXIT_FAILURE); }

    /* Now both can safely call exit() — nothing left to double-flush */
    exit(EXIT_SUCCESS);
}

Example 3 — Fix 2: Child Uses _exit() (Best Practice)

The architecturally correct fix: child calls _exit(), which skips stdio flushing and atexit handlers. Only the parent flushes.

/* fix2_child_uses__exit.c
 * Compile : gcc -Wall -o fix2 fix2_child_uses__exit.c
 * Run     : ./fix2 > a.txt && cat a.txt
 *   Output (correct): Ciao
 *                     Hello world   ← appears ONCE
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    printf("Hello world\n");
    write(STDOUT_FILENO, "Ciao\n", 5);

    pid_t pid = fork();
    if (pid == -1) { perror("fork"); exit(EXIT_FAILURE); }

    if (pid == 0) {
        /*
         * CHILD: use _exit() — does NOT flush stdio buffers.
         * The child's copy of the stdio buffer is discarded.
         * Only the parent will flush "Hello world" to the file.
         */
        _exit(EXIT_SUCCESS);
    }

    /* PARENT: wait for child, then exit() normally */
    wait(NULL);
    exit(EXIT_SUCCESS);   /* flushes stdio once — correct */
}
General rule: In a parent-child process pair, only the parent calls exit(). Child processes call _exit(). This applies to both the stdio buffer duplication problem and the atexit double-call problem.

Interview Questions & Answers

Q1. Why does printf() output appear twice when a program forks and both processes call exit()?

Answer: stdio buffers are maintained in userspace memory (the process heap). When fork() is called, the child receives a complete copy of the parent’s address space — including the stdio buffer. If printf() output was still in the buffer at fork time (because stdout is block-buffered when redirected to a file), both parent and child have a copy. When both call exit(), both flush their copies, producing duplicate output.

Q2. Why does write() output not get duplicated after fork()?

Answer: write() is a system call that transfers data directly to the kernel buffer cache. The kernel buffer is shared kernel memory — it is not part of the process’s address space and is not duplicated by fork(). Only userspace memory (stdio buffers, heap, stack, global variables) is duplicated.

Q3. When stdout is connected to a terminal, does the printf() duplication problem occur?

Answer: No. When stdout is connected to a terminal, it is line-buffered. This means data is flushed immediately when a newline character is written. So printf("Hello world\n") flushes immediately to the kernel before fork() is ever called — the stdio buffer is empty at fork time, nothing is duplicated. The problem only occurs when stdout is block-buffered (which happens when redirected to a file or pipe).

Q4. What are the three ways to prevent duplicate stdio output after fork()?

Answer: (1) Call fflush(NULL) or fflush(stdout) immediately before fork() to empty all stdio buffers. (2) Disable buffering on the stdio stream with setvbuf(stdout, NULL, _IONBF, 0) or setbuf(stdout, NULL). (3) In the child process, call _exit() instead of exit()_exit() does not flush stdio buffers. Option 3 is also the best architectural practice for fork-based programs.

Q5. Why does write() output appear before printf() output when redirected to a file?

Answer: write() transfers data directly to the kernel buffer cache — it is immediately available in the file. printf() data, when stdout is block-buffered (file redirect), stays in the userspace stdio buffer and only reaches the file when the buffer is flushed by exit() or fflush(). So even though printf() was called first in the source code, its data appears in the file after write() output, because it was buffered longer.

Q6. What is the answer to Exercise 25-1: what does WEXITSTATUS() return when child calls exit(-1)?

Answer: 255. -1 as a 32-bit signed integer is 0xFFFFFFFF. Only the bottom 8 bits (0xFF = 255) are passed to the parent via the kernel’s exit status mechanism. WEXITSTATUS() extracts those 8 bits and returns 255, not -1. This is why you should never pass negative values to exit() — they produce confusing and non-portable exit status values.

Q7. What is the general architectural rule for exit() vs _exit() in fork-based programs?

Answer: In a program that creates child processes, typically only one process (almost always the parent) should call exit(). All child processes should call _exit(). This guarantees that: (1) atexit handlers are called exactly once, (2) stdio buffers are flushed exactly once (no duplicate output), (3) cleanup actions registered in the parent do not accidentally run in child processes that may be in an inconsistent state.

🎉 Chapter 25 Series Complete!

You have covered all sections: _exit() vs exit(), kernel cleanup details, atexit() exit handlers, on_exit() exit handlers, and the fork/stdio buffer interaction. Ready for Chapter 26 — Process Monitoring (wait & waitpid).

Next Chapter → EmbeddedPathashala Home

Leave a Reply

Your email address will not be published. Required fields are marked *