atexit() feels like an insurance policy: register cleanup once and stop thinking about every return path. It is useful, but the policy has exclusions. It covers normal C-runtime termination—not crashes, unhandled fatal signals, deadlocks, power loss, SIGKILL, or immediate-exit APIs. Treat it as a final convenience hook, never as the only protection for critical data or locks.

What counts as normal termination

  • Returning from the initial call to main.

  • Calling the C library function exit(status).

  • The implementation then runs normal termination processing, including registered handlers and standard stream flushing/closing semantics.

  • atexit handlers are not invoked by _Exit, POSIX _exit, abort, or termination caused by an unhandled signal.

  • The operating system still reclaims process memory and descriptors after abrupt death, but application-level commits/protocol actions may not occur.

1. Register and check a handler

atexit_basic.cc
#include <stdio.h>
#include <stdlib.h>
 
static void report_shutdown(void)
{
    puts("normal shutdown completed");
}
 
int main(void)
{
    if (atexit(report_shutdown) != 0) {
        fputs("could not register shutdown handler\n", stderr);
        return EXIT_FAILURE;
    }
 
    puts("program work runs first");
    return EXIT_SUCCESS;
}

The callback has no context parameter

  • The handler type is exactly void handler(void).

  • atexit stores the function pointer; it does not call the function during registration.

  • Registration failure is checked before the program relies on the callback.

  • Returning from main performs normal termination, so the handler prints after the work message.

  • The handler should return normally so remaining registered handlers can run.

Compile and observe the order

Directory containing atexit_basic.cbash
cc -std=c17 -Wall -Wextra -Wpedantic -O2 -o atexit_basic atexit_basic.c
./atexit_basic
printf "status=%d\n" "$?"
program work runs first
normal shutdown completed
status=0

The shell sees main’s success status

  • Strict warning flags catch signature and portability mistakes.

  • The handler runs before the process fully terminates.

  • $? is the previous command’s exit status and must be captured immediately.

  • Output ordering through redirected/buffered streams can be affected by flushing and concurrent writers.

  • Do not make tests depend on mixed stdout/stderr ordering unless synchronization is explicit.

2. Multiple handlers run last-in, first-out

atexit_order.cc
#include <stdio.h>
#include <stdlib.h>
 
static void close_log(void) { puts("close log"); }
static void release_lock(void) { puts("release lock"); }
static void remove_temp(void) { puts("remove temp file"); }
 
int main(void)
{
    if (atexit(close_log) != 0 ||
        atexit(release_lock) != 0 ||
        atexit(remove_temp) != 0) {
        return EXIT_FAILURE;
    }
 
    puts("work");
    return EXIT_SUCCESS;
}

Registration order encodes dependencies

  • The expected callback order is remove-temp, release-lock, then close-log.

  • Register low-level resources first so higher-level dependents unwind before them.

  • The same function may be registered more than once and is called once per successful registration.

  • POSIX requires at least 32 registrations; implementation limits can be queried where sysconf(_SC_ATEXIT_MAX) is available.

  • Partial registration failure needs an immediate safe plan because earlier registrations remain registered.

3. Pass state through carefully controlled storage

atexit_state.cc
#include <stdio.h>
#include <stdlib.h>
 
static FILE *audit_file;
 
static void close_audit(void)
{
    if (audit_file != NULL) {
        (void)fclose(audit_file);
        audit_file = NULL;
    }
}
 
int main(void)
{
    audit_file = fopen("audit.tmp", "w");
    if (audit_file == NULL) {
        return EXIT_FAILURE;
    }
 
    if (atexit(close_audit) != 0) {
        (void)fclose(audit_file);
        audit_file = NULL;
        return EXIT_FAILURE;
    }
 
    fputs("session opened\n", audit_file);
    return ferror(audit_file) ? EXIT_FAILURE : EXIT_SUCCESS;
}

Global state is the API’s unavoidable tradeoff

  • Because the callback receives no argument, state must be static/global or reachable through another global owner.

  • The handler is idempotent: it checks and clears the pointer after closing.

  • If registration fails, main closes the file immediately rather than leaking it.

  • fclose can fail while flushing data; this handler cannot change the already selected exit status portably.

  • Critical durable writes need explicit error-checked flush/sync/commit before returning—not faith in a final callback.

exit, _Exit, _exit, abort, and quick_exit

  • exit(status) performs normal C termination and invokes atexit handlers.

  • _Exit(status) terminates without invoking atexit handlers or completing normal stdio flushing.

  • POSIX _exit(status) similarly bypasses C exit handlers and is commonly required in a post-fork child on failure before exec.

  • abort() causes abnormal termination and does not provide the atexit guarantee.

  • C11 quick_exit(status) uses the separate at_quick_exit handler list, not the atexit list.

  • An unhandled terminating signal does not run atexit handlers.

Signals are not an atexit delivery mechanism

  • A signal handler can call only async-signal-safe functions; exit, stdio, allocation, and most cleanup code are unsafe there.

  • For graceful service shutdown, arrange a signal-aware event path—such as sigwait, signalfd on Linux, or a self-pipe—and perform normal cleanup in ordinary control flow.

  • SIGKILL and SIGSTOP cannot be caught; crashes can corrupt state before any recovery path.

  • Design files/transactions to survive interruption through atomic replacement, journaling, checksums, or recovery on next start.

  • Supervisors/orchestrators should own external lifecycle guarantees rather than depending on in-process finalizers.

fork and exec change the picture

  • On POSIX/Linux, a child created by fork inherits copies of the parent’s registrations.

  • A successful exec removes the old process image and its registrations.

  • If exec fails in a child of a multithreaded process, use the post-fork-safe failure path and _exit; calling inherited complex handlers through exit can deadlock or duplicate parent cleanup.

  • Both parent and child running inherited handlers can delete the same file, unlock the same external resource, or duplicate buffered output.

  • Make ownership after fork explicit and register child-specific cleanup only after the child reaches a safe new program state.

Threading and shutdown races

  • POSIX/Linux documents atexit registration as thread-safe, but that does not make handler-owned application state race-free.

  • Normal process termination affects the whole process; other threads must not still mutate resources being torn down.

  • Coordinate shutdown, stop workers, join threads, and then return from main or call exit from the designated owner.

  • Avoid locking a mutex in an exit handler when another terminated or stuck thread might own it.

  • Dynamic libraries and platform-specific unload handlers add ordering complexity; do not depend on undocumented cross-library order.

What handlers should avoid

  • Calling exit again: POSIX says repeated exit processing is undefined and some systems may recurse.

  • Using longjmp to escape an exit handler: portable behavior is not defined.

  • Depending on another handler’s resource unless reverse registration order is deliberately owned.

  • Starting threads, doing network retries, waiting indefinitely, or performing large allocations.

  • Calling application components already torn down or accessing non-idempotent shared state.

  • Swallowing errors that should have changed the program’s outcome earlier.

Prefer local structured cleanup for owned resources

explicit_cleanup.cc
#include <stdio.h>
#include <stdlib.h>
 
int run_job(const char *path)
{
    FILE *file = NULL;
    int result = EXIT_FAILURE;
 
    file = fopen(path, "w");
    if (file == NULL) {
        goto done;
    }
 
    if (fputs("complete\n", file) == EOF) {
        goto done;
    }
 
    if (fclose(file) != 0) {
        file = NULL;
        goto done;
    }
    file = NULL;
    result = EXIT_SUCCESS;
 
done:
    if (file != NULL && fclose(file) != 0) {
        result = EXIT_FAILURE;
    }
    return result;
}

One owner can report cleanup failure

  • The resource lifetime is visible inside one function.

  • All failure paths converge on one cleanup block.

  • The pointer is cleared after close so cleanup cannot double-close it.

  • A failed close changes the returned status while the caller can still react.

  • Use atexit for truly process-global fallbacks; use scoped control flow for ordinary local resources.

Test which paths actually invoke handlers

atexit_modes.cc
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
static void final_message(void) { puts("atexit handler"); }
 
int main(int argc, char **argv)
{
    if (atexit(final_message) != 0) return EXIT_FAILURE;
    if (argc < 2 || strcmp(argv[1], "return") == 0) return EXIT_SUCCESS;
    if (strcmp(argv[1], "exit") == 0) exit(EXIT_SUCCESS);
    if (strcmp(argv[1], "_Exit") == 0) _Exit(EXIT_SUCCESS);
    if (strcmp(argv[1], "abort") == 0) abort();
    return EXIT_FAILURE;
}

Expected behavior is mode-specific

  • return and exit should invoke the handler.

  • _Exit should bypass the handler and normal stream flushing.

  • abort terminates abnormally and may create a core dump according to environment limits/policy.

  • Run destructive/crash tests in a disposable development environment, not a service terminal or shared production directory.

  • Use a test harness to assert status/signal and durable side effects, not merely terminal text.

Common surprises

  • Handler never ran: termination used _Exit, _exit, abort, a fatal signal, SIGKILL, crash, or power/process isolation failure.

  • Output is missing: buffered stdio was bypassed or the handler wrote to an invalid/closed stream.

  • Cleanup runs twice: function was registered twice, explicit cleanup did not clear state, or a forked child inherited registrations.

  • Program hangs at exit: handler waits on a lock/thread/network dependency that cannot make progress.

  • Wrong order: handlers run reverse registration order, possibly including libraries you do not control.

  • Data still corrupt after handler: critical commit was delayed too late or interruption occurred before normal termination.

  • Parent resource removed by child: post-fork ownership was not separated and child called normal exit.

  • Registration silently failed: return value was ignored.

Production checklist

  • Every registration checks for nonzero failure.

  • Handlers are short, bounded, idempotent, return normally, and avoid repeated exit/longjmp.

  • Reverse order matches resource dependencies.

  • Critical writes/transactions are explicitly committed with errors handled before termination.

  • Signal shutdown uses safe coordination into normal control flow; crash/power-loss recovery is designed separately.

  • Fork/exec ownership, multithreaded shutdown, stdio buffering, library unload, and repeated cleanup are tested.

  • Local resources use structured cleanup; atexit remains a process-global backstop rather than a universal destructor.

Primary references

  • POSIX atexit defines registration, reverse order, normal termination, limits, and portable constraints.

  • Linux atexit(3) documents Linux/glibc behavior, fork/exec, signals, and handler caveats.

  • POSIX _Exit and _exit documents immediate termination without atexit handlers.

  • C quick termination summarizes the separate C11 quick-exit path and handlers.