funwithlinux blog

Child Process Termination: exit vs return vs _exit in fork() - Which Should You Use?

When working with process creation in Unix-like systems, the fork() system call is a cornerstone, enabling the creation of new child processes. However, managing child process termination is equally critical to avoid resource leaks, zombies, or unexpected behavior. Three common methods for terminating processes are exit(), return, and _exit() (or _Exit()), but they behave differently—especially in the context of fork().

Understanding their nuances is essential for writing robust code. This blog will break down each method, explain their behavior, compare use cases, and highlight best practices to help you choose the right one for your scenario.

2026-01

Table of Contents#

  1. Understanding fork() and Child Processes
  2. Termination Method 1: return
  3. Termination Method 2: exit()
  4. Termination Method 3: _exit()/_Exit()
  5. Comparing exit(), return, and _exit()
  6. Common Pitfalls and Examples
  7. Best Practices
  8. Conclusion
  9. References

Understanding fork() and Child Processes#

Before diving into termination methods, let’s recap how fork() works. When fork() is called, the operating system creates a duplicate of the calling process (the parent), resulting in two identical processes: the parent and the child. Both processes continue executing from the line immediately after the fork() call.

Key characteristics of child processes:

  • They share a copy of the parent’s address space (text, data, heap, and stack).
  • They inherit open file descriptors, signal handlers, and buffered I/O streams.
  • To avoid "zombie" processes (defunct processes that linger in the process table), the parent must "reap" the child using wait() or waitpid().

Proper termination of the child is critical to ensure clean resource release and prevent unintended side effects (e.g., duplicate I/O operations).

Termination Method 1: return#

In C, the return statement exits a function and returns a value to the caller. For the main() function—the entry point of a program—return has special behavior:

How it works: When main() returns, the C runtime calls exit(return_value) implicitly. Thus, return 0; in main() is equivalent to exit(0);.

Behavior in child processes:
If a child process returns from main(), it triggers exit() with the return value, leading to the same cleanup steps as an explicit exit() call (see below). However, return only terminates the process if it exits main(). If the child is executing in a helper function (not main()), return will simply return control to the caller, leaving the child process running.

Example:

#include <stdio.h>
#include <unistd.h>
 
int main() {
    pid_t pid = fork();
    if (pid == 0) { // Child process
        printf("Child: Exiting with return\n");
        return 42; // Equivalent to exit(42) in main()
    } else { // Parent process
        printf("Parent: Child PID = %d\n", pid);
    }
    return 0;
}

Use case: Best suited for simple parent processes or child processes that terminate directly from main(). Avoid using return in child helper functions, as it won’t terminate the process.

Termination Method 2: exit()#

The exit() function (declared in <stdlib.h>) is a standard library function that terminates the process after performing cleanup.

How it works:

  1. Flushes stdio buffers: Any buffered data in standard I/O streams (e.g., printf output) is written to the underlying file descriptor.
  2. Calls atexit() handlers: Functions registered with atexit() (to run on process exit) are executed in reverse order of registration.
  3. Terminates the process: Finally, exit() invokes the system call _exit() (or _Exit()) to terminate the process and return the exit status to the parent.

Behavior in child processes:
If a child calls exit(), it will perform the same cleanup as the parent, including flushing buffers and running atexit() handlers. This can lead to unexpected behavior if the parent and child share resources (e.g., duplicate I/O due to double-buffered flushing).

Example:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
void cleanup() {
    printf("Cleanup: This runs on exit()\n");
}
 
int main() {
    atexit(cleanup); // Register cleanup handler
    pid_t pid = fork();
    if (pid == 0) { // Child
        printf("Child: Calling exit()\n");
        exit(0); // Triggers cleanup() and flushes buffers
    } else { // Parent
        printf("Parent: Waiting for child...\n");
        wait(NULL); // Reap child
        printf("Parent: Exiting\n");
    }
    return 0; // Implicit exit(0) for parent
}

Output:

Parent: Waiting for child...
Child: Calling exit()
Cleanup: This runs on exit()
Parent: Exiting
Cleanup: This runs on exit()

Here, both the child and parent run the cleanup() handler because exit() is called in both (child explicitly, parent via return).

Termination Method 3: _exit()/_Exit()#

_exit() (POSIX) and _Exit() (C99 standard) are low-level system calls that terminate the process immediately without cleanup.

How they work:

  • No buffer flushing: Buffered data in stdio streams (e.g., unwritten printf output) is discarded.
  • No atexit() handlers: Registered cleanup functions are ignored.
  • Immediate termination: The process exits immediately, returning the exit status to the parent via wait().

_exit() is POSIX-specific (defined in <unistd.h>), while _Exit() is part of the C standard (defined in <stdlib.h>) and portable across all C-compliant systems. They behave identically on Unix-like systems.

Behavior in child processes:
_exit() is ideal for child processes after fork(), as it avoids duplicate cleanup (e.g., flushing buffers that the parent might also flush) and ensures minimal overhead.

Example:
Using _exit() in the child from the previous example:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> // For _exit()
 
void cleanup() {
    printf("Cleanup: This runs on exit()\n");
}
 
int main() {
    atexit(cleanup);
    pid_t pid = fork();
    if (pid == 0) { // Child
        printf("Child: Calling _exit()\n");
        _exit(0); // No cleanup, no buffer flushing
    } else { // Parent
        printf("Parent: Waiting for child...\n");
        wait(NULL);
        printf("Parent: Exiting\n");
    }
    return 0;
}

Output:

Parent: Waiting for child...
Child: Calling _exit()
Parent: Exiting
Cleanup: This runs on exit()

Now, only the parent runs cleanup(), as the child used _exit() and skipped cleanup.

Comparing exit(), return, and _exit()#

The table below summarizes their key differences:

Featurereturn (from main())exit()_exit()/_Exit()
Buffer FlushingYes (via exit())YesNo
Runs atexit() HandlersYes (via exit())YesNo
PortabilityC standardC standard_Exit(): C standard; _exit(): POSIX
Use CaseParent processes; child processes exiting main()Parent processes; child processes needing cleanupChild processes after fork(); emergency termination
OverheadLow (via exit())Moderate (cleanup)Minimal (immediate exit)

Common Pitfalls and Examples#

Pitfall 1: Duplicate I/O with exit() in Child Processes#

A classic issue arises when a child uses exit() after fork(), leading to duplicate output. This happens because stdio buffers are copied during fork(), and exit() flushes them in both the child and parent.

Example (Problematic):

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main() {
    printf("Hello, "); // Buffered (not yet printed to terminal)
    pid_t pid = fork();
    if (pid == 0) { // Child
        printf("Child\n");
        exit(0); // Flushes buffer: "Hello, Child\n"
    } else { // Parent
        wait(NULL);
        printf("Parent\n"); // Flushes buffer again: "Hello, Parent\n"
    }
    return 0;
}

Output (if output is block-buffered, e.g., redirected to a file):

Hello, Child
Hello, Parent

Why? The printf("Hello, ") is stored in a buffer. After fork(), both parent and child have copies of this buffer. exit() in the child flushes the buffer, printing "Hello, Child\n". The parent later flushes its copy, printing "Hello, Parent\n".

Fix: Use _exit() in the Child#

Replace exit(0) with _exit(0) in the child to avoid flushing the buffer:

// ... (same as above)
if (pid == 0) {
    printf("Child\n");
    _exit(0); // No buffer flush; discards "Hello, " in child's buffer
}
// ...

Output:

Hello, Parent

Now, only the parent flushes the buffer, printing "Hello, Parent\n" once.

Best Practices#

  1. Use return in main() for parent processes: It’s idiomatic and equivalent to exit().
  2. Use exit() for parent processes needing cleanup: If you need to run atexit() handlers or flush buffers, use exit().
  3. Use _exit()/_Exit() in child processes after fork(): Avoid duplicate cleanup (e.g., buffer flushing, atexit() handlers) that the parent will perform.
  4. Avoid return in child helper functions: return only terminates the process if exiting main(). Use exit() or _exit() to explicitly terminate child processes.

Conclusion#

  • return from main(): Implicitly calls exit(), suitable for parent processes.
  • exit(): Performs cleanup (flushes buffers, runs atexit() handlers), use in parents or children needing cleanup.
  • **_exit()/_Exit(): Terminates immediately without cleanup, ideal for child processes after fork()` to avoid duplication.

By choosing the right termination method, you ensure clean process exit, prevent resource leaks, and avoid subtle bugs like duplicate I/O. Always match the method to the context: cleanup for parents, minimalism for children.

References#