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.
atexithandlers 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
#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).atexitstores the function pointer; it does not call the function during registration.Registration failure is checked before the program relies on the callback.
Returning from
mainperforms 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
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=0The 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
#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
#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,
maincloses the file immediately rather than leaking it.fclosecan 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 invokesatexithandlers._Exit(status)terminates without invokingatexithandlers 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 theatexitguarantee.C11
quick_exit(status)uses the separateat_quick_exithandler list, not theatexitlist.An unhandled terminating signal does not run
atexithandlers.
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
forkinherits copies of the parent’s registrations.A successful
execremoves the old process image and its registrations.If
execfails in a child of a multithreaded process, use the post-fork-safe failure path and_exit; calling inherited complex handlers throughexitcan 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
atexitregistration 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
mainor callexitfrom 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
exitagain: POSIX says repeated exit processing is undefined and some systems may recurse.Using
longjmpto 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
#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
atexitfor truly process-global fallbacks; use scoped control flow for ordinary local resources.
Test which paths actually invoke handlers
#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
returnandexitshould invoke the handler._Exitshould bypass the handler and normal stream flushing.abortterminates 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;
atexitremains 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
atexithandlers.C quick termination summarizes the separate C11 quick-exit path and handlers.
Comments and corrections