Loading learning content...
Every process that begins must eventually end. While we often focus on how processes are created and how they execute, understanding how processes terminate is equally crucial for building robust, reliable systems. Normal termination—the graceful, intentional conclusion of a process's execution—is far more sophisticated than simply reaching the end of a main() function.
When a process terminates normally, a carefully orchestrated sequence of events unfolds: resources are released, file descriptors are closed, memory is reclaimed, parent processes are notified, and the kernel updates its internal data structures. This isn't just cleanup—it's the foundation of system stability. A process that terminates incorrectly can leak resources, leave files in corrupted states, or cause other processes to hang indefinitely waiting for signals that will never arrive.
By the end of this page, you will deeply understand: the semantics of the exit() system call and its variants, how exit handlers execute during termination, the role of the C runtime in process shutdown, how the kernel handles process termination internally, and the critical relationship between terminating children and their parent processes.
Normal termination occurs when a process finishes execution voluntarily and in a controlled manner. This contrasts with abnormal termination, where processes end due to unhandled signals, fatal errors, or external intervention. There are several ways a process can terminate normally:
1. Returning from main()
The most common form of normal termination is simply returning from the main() function. The return value becomes the process's exit status:
int main() {
// ... program logic ...
return 0; // Exit status 0 indicates success
}
2. Calling exit()
The exit() function can be called from anywhere in the program to immediately terminate the process:
#include <stdlib.h>
void some_function() {
if (fatal_error_detected) {
exit(1); // Terminate immediately with status 1
}
}
3. Calling _exit() or _Exit()
These provide immediate termination without running cleanup handlers:
#include <unistd.h> // for _exit()
#include <stdlib.h> // for _Exit()
_exit(1); // POSIX: immediate termination
_Exit(1); // C99: immediate termination
The difference between exit() and _exit() is subtle but critical. exit() performs cleanup (flushing buffers, running atexit handlers), while _exit() terminates immediately. Using _exit() incorrectly can leave buffered data unwritten to files or network sockets. We'll explore this distinction in depth shortly.
| Method | Runs atexit Handlers | Flushes stdio Buffers | Calls Destructors (C++) | Use Case |
|---|---|---|---|---|
| return from main() | Yes | Yes | Yes | Standard program completion |
| exit(status) | Yes | Yes | Yes | Terminate from any function |
| _exit(status) | No | No | No | After fork() in child, signal handlers |
| _Exit(status) | No | No | No | C99 equivalent of _exit() |
The exit() function is not actually a direct system call—it's a C library function that performs significant work before invoking the underlying kernel system call. Understanding this distinction is essential for mastering process termination.
The Anatomy of exit()
When you call exit(status), the following sequence occurs:
atexit() are called in reverse order of registrationon_exit() are called (GNU extension, also LIFO order)tmpfile() are removed_exit() system call is invoked to actually terminate the processThis two-phase approach—library cleanup followed by kernel termination—allows programs to perform necessary finalization without explicitly coding cleanup in every execution path.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
#include <stdio.h>#include <stdlib.h> // Exit handler functionsvoid cleanup_database() { printf("Closing database connections...\n"); // Close database connections, commit/rollback transactions} void cleanup_network() { printf("Closing network sockets...\n"); // Close open sockets, send shutdown notifications} void cleanup_files() { printf("Flushing file buffers...\n"); // Ensure all buffered data is written} void final_cleanup() { printf("Performing final cleanup...\n"); // Last-chance cleanup operations} int main() { // Register exit handlers - they execute in REVERSE order // So the order of execution will be: // final_cleanup -> cleanup_files -> cleanup_network -> cleanup_database if (atexit(cleanup_database) != 0) { fprintf(stderr, "Failed to register cleanup_database\n"); return 1; } if (atexit(cleanup_network) != 0) { fprintf(stderr, "Failed to register cleanup_network\n"); return 1; } if (atexit(cleanup_files) != 0) { fprintf(stderr, "Failed to register cleanup_files\n"); return 1; } if (atexit(final_cleanup) != 0) { fprintf(stderr, "Failed to register final_cleanup\n"); return 1; } printf("Program starting...\n"); printf("Doing some work...\n"); // When we return (or call exit()), handlers run in reverse order printf("Program ending normally.\n"); return 0; // This triggers exit handlers} /* Output:Program starting...Doing some work...Program ending normally.Performing final cleanup...Flushing file buffers...Closing network sockets...Closing database connections...*/Register the most critical cleanup handlers last, so they execute first (LIFO order). Keep handlers simple and fast—they run on the termination path and shouldn't block indefinitely. Never call exit() from within an exit handler, as this leads to undefined behavior.
While exit() is the standard way to terminate a process, _exit() (and its C99 equivalent _Exit()) provide immediate kernel-level termination without any library-level cleanup. This is a direct system call that bypasses the C runtime entirely.
When the Kernel Receives _exit():
The kernel performs several critical operations:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/types.h>#include <sys/wait.h> void exit_handler() { printf("Exit handler called!\n");} int main() { // Register an exit handler atexit(exit_handler); pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } else if (pid == 0) { // Child process printf("Child: Using _exit() to terminate\n"); // If we used exit() here, the handler would run twice // (once in child, once in parent) and buffered output // could be duplicated. Using _exit() prevents this. _exit(0); // <-- No handler, no buffer flush // This code is never reached printf("This will never print\n"); } else { // Parent process wait(NULL); // Wait for child printf("Parent: Child has terminated\n"); printf("Parent: Now exiting normally\n"); // exit() is called implicitly by returning from main() return 0; // Handler runs here } return 0;} /* Output:Child: Using _exit() to terminateParent: Child has terminatedParent: Now exiting normallyExit handler called!*/Always use _exit() (not exit()) when terminating a child process that was created with fork() but didn't exec(). Using exit() would run the parent's exit handlers in the child, potentially causing duplicated output, double-freeing resources, or corrupting shared state. This is one of the most common process management bugs.
When the kernel receives an _exit() or exit_group() system call, it initiates a sophisticated cleanup sequence. Understanding this process reveals how the operating system maintains integrity even as processes die.
The Linux do_exit() Function
In Linux, the core termination logic resides in do_exit() within kernel/exit.c. Here's the conceptual flow:
Key Kernel Data Structure Changes
During termination, the kernel modifies several critical data structures:
task_struct Updates:
exit_state is set to EXIT_ZOMBIE or EXIT_DEADexit_code stores the termination statusexit_signal indicates which signal to send to parentMemory Management:
mm_struct is decremented and potentially freedFile System:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Simplified conceptual representation of Linux do_exit()// (Actual kernel code is significantly more complex) void do_exit(long exit_code) { struct task_struct *current = get_current_task(); // 1. Mark process as exiting - prevents new work being assigned current->flags |= PF_EXITING; // 2. Delete any pending timers del_timer_sync(¤t->real_timer); del_timer_sync(¤t->signal->real_timer); // 3. Release memory management structures exit_mm(current); // Handles all VM cleanup // 4. Close all file descriptors exit_files(current); // 5. Release filesystem references (cwd, root) exit_fs(current); // 6. Release namespaces exit_namespace(current); // 7. Release semaphores (SysV IPC) exit_sem(current); // 8. Handle process groups and sessions exit_signals(current); // 9. Set exit code for parent to retrieve current->exit_code = exit_code; // 10. Notify parent and handle orphaned children exit_notify(current); // Sends SIGCHLD, reparents children // 11. Transition to zombie state current->state = TASK_DEAD; current->exit_state = EXIT_ZOMBIE; // 12. Update process accounting acct_update_integrals(current); // 13. Schedule away - we never return from this schedule(); // Context switch to another process // This point is never reached BUG();}Once do_exit() is called, the process can never execute again. The final schedule() call switches context to another runnable process. The terminating process's task_struct remains allocated (in zombie state) only to hold exit status information for the parent. Once the parent retrieves this via wait(), even that final structure is freed.
One of the most critical differences between exit() and _exit() is buffer flushing. The C standard library maintains internal buffers for stdio streams, and calling exit() ensures all pending data is written to the underlying file descriptors.
Understanding stdio Buffering
The C library implements three buffering modes:
Unbuffered (_IONBF): Data is written immediately. Used by default for stderr.
Line-buffered (_IOLBF): Data is written when a newline is encountered or the buffer is full. Used by default for stdout when connected to a terminal.
Fully buffered (_IOFBF): Data is written only when the buffer is full. Used by default for stdout when redirected to a file, and for all file streams opened with fopen().
1234567891011121314151617181920212223242526272829303132333435
#include <stdio.h>#include <stdlib.h>#include <unistd.h> int main() { printf("This message has no newline..."); // stdout is likely line-buffered when connected to terminal, // or fully-buffered when redirected to a file. // If we call _exit() now, the message might be lost! // Try: ./program (may show message - line buffered) // Try: ./program > file (likely loses message - fully buffered) // Uncommenting either of these will lose the buffered data: // _exit(0); // _Exit(0); // This is safe - exit() flushes all stdio buffers: exit(0); return 0;} /* * Demonstration: * * $ ./buffering_danger * This message has no newline... * * But with _exit(): * $ ./buffering_danger > output.txt * $ cat output.txt * (file may be empty!) */The fork() Buffering Pitfall
This buffering behavior creates a notorious pitfall with fork(). When you fork a process, the child inherits copies of all stdio buffers. If both parent and child then call exit(), the buffered data can be written twice:
123456789101112131415161718192021222324252627282930313233
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h> int main() { printf("Message before fork..."); // No newline! This sits in stdout's buffer. pid_t pid = fork(); if (pid == 0) { // Child: The buffer contains "Message before fork..." printf("(from child)\n"); exit(0); // Flushes: "Message before fork...(from child)" } else { wait(NULL); printf("(from parent)\n"); exit(0); // Flushes: "Message before fork...(from parent)" } return 0;} /* * BUGGY OUTPUT (using exit() in both): * Message before fork...(from child) * Message before fork...(from parent) * * The message appears twice! * * CORRECT APPROACH: Use _exit() in child, or fflush(NULL) before fork() */To avoid duplicated output, either call fflush(NULL) before fork() to clear all buffers, or use _exit() in the child process. Professional code typically does both: fflush() before fork() for predictable buffering, and _exit() in children that don't exec() as a safety measure.
12345678910111213141516171819202122232425262728293031
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h> int main() { printf("Message before fork..."); // CORRECT: Flush all buffers before forking fflush(NULL); pid_t pid = fork(); if (pid == 0) { // Child process - buffers are now empty printf("Hello from child\n"); // CORRECT: Use _exit() in child that doesn't exec() _exit(0); } else { wait(NULL); printf("Hello from parent\n"); return 0; // exit() is fine in parent }} /* * CORRECT OUTPUT: * Message before fork...Hello from child * Hello from parent */In C++, process termination has additional complexity due to destructors and the RAII (Resource Acquisition Is Initialization) pattern. The relationship between exit functions and object destruction is nuanced and critical for resource management.
Destruction Behavior With Different Exit Methods:
| Exit Method | Local Object Destructors | Static Object Destructors |
|---|---|---|
return from main() | Yes | Yes |
exit() | No | Yes |
_exit() / _Exit() | No | No |
std::exit() | No | Yes |
std::quick_exit() | No | No (runs at_quick_exit handlers) |
std::abort() | No | No |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
#include <iostream>#include <cstdlib> class Resource { const char* name_;public: Resource(const char* name) : name_(name) { std::cout << "Constructing: " << name_ << std::endl; } ~Resource() { std::cout << "Destroying: " << name_ << std::endl; }}; // Static object - destroyed by exit() but not _exit()static Resource staticResource("static_resource"); void demonstrate_exit() { // Local object - NOT destroyed by exit()! Resource localResource("local_resource"); std::cout << "About to call exit()..." << std::endl; // exit() will: // - Run atexit handlers // - Destroy static objects // - NOT destroy local objects! exit(0); // Never reached std::cout << "After exit()" << std::endl;} int main() { std::cout << "Starting main()" << std::endl; demonstrate_exit(); return 0;} /* * Output: * Constructing: static_resource * Starting main() * Constructing: local_resource * About to call exit()... * Destroying: static_resource * * Notice: local_resource destructor was NEVER called! * This is a resource leak if the destructor had important cleanup. */Calling exit() from a C++ program bypasses destructors for all local objects on the call stack. If these objects manage resources (file handles, database connections, mutexes), those resources will be leaked. In C++, prefer to propagate errors back to main() and return normally, or use exceptions to unwind the stack properly.
Recommended C++ Exit Pattern:
To ensure proper cleanup in C++, structure your program so that termination flows through main():
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
#include <iostream>#include <stdexcept>#include <memory> class DatabaseConnection {public: DatabaseConnection() { std::cout << "DB: Connected\n"; } ~DatabaseConnection() { std::cout << "DB: Disconnected\n"; } void execute(const std::string& query) { // Simulate work if (query == "FAIL") { throw std::runtime_error("Query failed"); } std::cout << "DB: Executed: " << query << "\n"; }}; // Instead of calling exit(), return error codes or throw exceptionsint run_application() { auto db = std::make_unique<DatabaseConnection>(); try { db->execute("SELECT * FROM users"); db->execute("UPDATE users SET active = 1"); return 0; // Success } catch (const std::exception& e) { std::cerr << "Error: " << e.what() << std::endl; return 1; // Failure - but destructor still runs! } // Destructor called here when db goes out of scope} int main() { int result = run_application(); std::cout << "Application finished with code: " << result << "\n"; return result; // Proper termination through main()} /* * Output: * DB: Connected * DB: Executed: SELECT * FROM users * DB: Executed: UPDATE users SET active = 1 * DB: Disconnected <-- Destructor called! * Application finished with code: 0 */The exit status is the integer value passed to exit() or returned from main(). This value communicates the outcome of the process to its parent and is essential for shell scripting, process orchestration, and error handling.
Standard Conventions:
POSIX Definitions:
POSIX defines two symbolic constants in <stdlib.h>:
EXIT_SUCCESS (0)EXIT_FAILURE (typically 1, but implementation-defined)Using these symbols makes code more portable and self-documenting.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
#include <stdio.h>#include <stdlib.h>#include <errno.h>#include <string.h> // Application-specific exit codes#define EXIT_CONFIG_ERROR 2#define EXIT_FILE_NOT_FOUND 3#define EXIT_PERMISSION_DENIED 4#define EXIT_NETWORK_ERROR 5 typedef struct { int code; const char* description;} ExitCode; static const ExitCode exit_codes[] = { {EXIT_SUCCESS, "Success"}, {EXIT_FAILURE, "General error"}, {EXIT_CONFIG_ERROR, "Configuration error"}, {EXIT_FILE_NOT_FOUND, "File not found"}, {EXIT_PERMISSION_DENIED, "Permission denied"}, {EXIT_NETWORK_ERROR, "Network error"},}; void print_usage(const char* program_name) { fprintf(stderr, "Usage: %s <config_file>\n", program_name);} int load_config(const char* filename) { FILE* f = fopen(filename, "r"); if (!f) { fprintf(stderr, "Error opening %s: %s\n", filename, strerror(errno)); if (errno == ENOENT) { return EXIT_FILE_NOT_FOUND; } else if (errno == EACCES) { return EXIT_PERMISSION_DENIED; } return EXIT_FAILURE; } // Parse configuration... fclose(f); return EXIT_SUCCESS;} int main(int argc, char* argv[]) { if (argc != 2) { print_usage(argv[0]); return EXIT_CONFIG_ERROR; // Incorrect usage } int result = load_config(argv[1]); if (result != EXIT_SUCCESS) { // Error already printed by load_config return result; } printf("Configuration loaded successfully\n"); // ... application logic ... return EXIT_SUCCESS;} /* * Usage in shell scripts: * * ./myprogram config.txt * case $? in * 0) echo "Success" ;; * 2) echo "Configuration error" ;; * 3) echo "File not found" ;; * 4) echo "Permission denied" ;; * *) echo "Unknown error: $?" ;; * esac */Document your exit codes in man pages or --help output. Use symbolic constants rather than magic numbers. Avoid codes above 125, as they may conflict with shell conventions. For daemons, follow sysexits.h conventions (EX_OK, EX_USAGE, EX_DATAERR, etc.) when possible.
The sysexits.h Standard:
BSD systems (and many Linux installations) provide <sysexits.h> with standardized exit codes:
| Code | Constant | Meaning |
|---|---|---|
| 64 | EX_USAGE | Command line usage error |
| 65 | EX_DATAERR | Data format error |
| 66 | EX_NOINPUT | Cannot open input |
| 67 | EX_NOUSER | Addressee unknown |
| 68 | EX_NOHOST | Host name unknown |
| 69 | EX_UNAVAILABLE | Service unavailable |
| 70 | EX_SOFTWARE | Internal software error |
| 71 | EX_OSERR | System error |
| 72 | EX_OSFILE | Critical OS file missing |
| 73 | EX_CANTCREAT | Cannot create output file |
| 74 | EX_IOERR | Input/output error |
| 75 | EX_TEMPFAIL | Temporary failure |
| 76 | EX_PROTOCOL | Protocol error |
| 77 | EX_NOPERM | Permission denied |
| 78 | EX_CONFIG | Configuration error |
We've explored the complete landscape of normal process termination—from the high-level exit() function to low-level kernel mechanics. Let's consolidate the essential knowledge:
What's Next:
Normal termination represents the ideal case—processes that complete their work successfully and exit gracefully. However, not all processes are so fortunate. In the next page, we'll explore abnormal termination: what happens when processes are killed by signals, crash due to bugs, or are forcibly terminated by the operating system. Understanding both sides of termination gives you the complete picture of process lifecycle management.
You now have a deep understanding of normal process termination in Unix-like systems. You understand the difference between exit() and _exit(), how exit handlers work, the critical relationship with fork(), and how the kernel manages the final cleanup. This knowledge is essential for writing robust, resource-safe systems software.