fork() in Multithreaded Programs: The Child Gets the State, Not the Threads

I keep hearing the same one-liner in design reviews and interview answers:
fork()creates a copy of the process.
True, and misleading in exactly one way: it makes you wonder whether the threads come along too. If the parent has eight threads, does the child get eight?
No. After fork() in a multithreaded process, the child contains only the thread that called fork(). The other threads do not continue in the child.
The trouble is that the state they touched does come along. A mutex may still be locked. A stdio buffer may be half flushed. The heap allocator and third-party libraries may be frozen mid-operation. File descriptors still point at the same kernel objects as the parent’s.
The child receives a snapshot of a running process without receiving all of the executors that kept that snapshot consistent. That asymmetry is the entire risk of fork() in multithreaded programs.
A child that deadlocks on a lock nobody holds
The program below has two threads. A worker takes a mutex, then notifies the main thread through a pipe, so there is no sleep(1) guessing about who ran first. The main thread confirms the lock is held and calls fork(). The child then tries to take the same lock.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int ready[2];
static void *worker(void *arg) {
(void)arg;
pthread_mutex_lock(&lock);
char byte = 'x';
(void)write(ready[1], &byte, 1);
sleep(10);
pthread_mutex_unlock(&lock);
return NULL;
}
int main(void) {
pthread_t tid;
char byte;
if (pipe(ready) == -1) {
perror("pipe");
exit(1);
}
if (pthread_create(&tid, NULL, worker, NULL) != 0) {
fputs("pthread_create failed\n", stderr);
exit(1);
}
if (read(ready[0], &byte, 1) != 1) {
fputs("read failed\n", stderr);
exit(1);
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
if (pid == 0) {
static const char msg[] = "child: trying to lock\n";
(void)write(STDOUT_FILENO, msg, sizeof(msg) - 1);
/* Deliberately demonstrates the risk of touching an inherited lock. */
pthread_mutex_lock(&lock);
_exit(0);
}
waitpid(pid, NULL, 0);
pthread_join(tid, NULL);
return 0;
}On a typical Linux/glibc system you can observe it like this:
cc -O2 -Wall -Wextra -pthread fork-lock.c -o fork-lock
timeout 3s ./fork-lockThe program prints:
child: trying to lockand then hangs until timeout kills it. The worker unlocks after ten seconds, but that only changes the parent’s mutex. The child’s copy stays exactly as fork() captured it, and the thread that owned it does not exist in the child.
Two standards-level caveats belong here.
Under POSIX.1-2024, any operation by the child on a lock that was held at fork() time and is not process-shared is undefined behavior. A deadlock is the common symptom on Linux/glibc; POSIX does not promise it will always look like a deadlock. The demo shows what a typical implementation does, not portable correct usage.
The demo also explains an older rule: after fork() in a multithreaded process, the child may only perform async-signal-safe operations until a successful exec. printf(), malloc(), and pthread_mutex_lock() are not on that list. That is why the child prints with write(): it must not first trip over stdio’s own internal lock.
The engineering takeaway is blunt. In the child, run exec() quickly and do nothing clever in between.
Why fork copies only the calling thread
In the Linux kernel, a thread is a scheduling entity. Each thread has its own register state, kernel stack, scheduling state, and thread ID. What we call “one process with several threads” is a group of tasks that share an address space, a file descriptor table, and signal dispositions; NPTL builds this on the resource-sharing machinery of clone().
fork() uses different semantics. It creates a new process identity, copies the caller’s view of the address space, and resumes execution at the return from fork(). The child has exactly one flow of execution: the thread that called fork().
Why not copy the other threads? Because they may be stopped anywhere: blocked inside a system call, halfway through an update, about to write to the network. Copying every execution flow would force answers to ugly questions. Do in-flight system calls continue? Does one external write become two? How do parent and child untangle file offsets and mappings they used to share?
POSIX discussed forking all threads and declined. Copying only the caller is easy to define, and its price is explicit: the memory state crosses over, the threads that maintained it do not.
What the child actually inherits
“Copy the process” is too coarse. Split it into three categories.
The first is ordinary private user-space memory. Parent and child see virtual address spaces with identical contents, usually implemented with copy-on-write: physical pages are shared until one side writes. (MAP_SHARED mappings exist to be shared and do not split this way; Linux also has exceptions such as MADV_DONTFORK and MADV_WIPEONFORK.)
The heap, globals, mutexes, condition variables, stdio buffers, and much library-internal state live in this memory. All of it appears in the child exactly as it was at fork(), and afterwards the two processes diverge independently.
The second category is the kernel objects behind file descriptors. The child gets its own descriptor table entries, but those entries refer to the same open file descriptions as the parent’s. File offsets and file status flags are therefore shared: when one process moves an offset, the other sees the move. Sockets and pipes remain connected to the same kernel objects. Flags like FD_CLOEXEC, by contrast, belong to each descriptor table entry; the child inherits a copy and can change it independently.
The third category is threads. The calling thread appears in the child. The others do not, and their stacks and thread-local storage are not valid child-process resources.
Only when you put the three categories together does the dangerous asymmetry appear: user-space state arrives as a snapshot, kernel objects may still be shared, and the executors are down to one.
Suppose thread A calls fork() while:
- thread B holds the allocator’s internal lock;
- thread C is halfway through a log buffer;
- thread D is updating a connection pool.
The child contains only thread A. If it calls into the allocator, the logging library, or the pool code, it meets state frozen mid-operation. The production symptoms are hangs, duplicated output, and bizarre exits, varying with the captured state and the library implementation.
stdio deserves its own paragraph
Because descriptors are shared, offsets move together across fork(). But stdio buffers live in user-space memory.
If stdout holds bytes that have not reached the kernel when fork() happens, both processes own a copy of those bytes. In a single-threaded program, if both later call exit(), the library may flush each copy, and the same output appears twice.
In the child of a multithreaded fork(), the problem is more direct: exit() itself runs library cleanup and exit handlers, none of which belong in the dangerous window. Failure paths should use _exit(), skipping the inherited user-space cleanup logic.
What pthread_atfork() can and cannot fix
pthread_atfork() registers three kinds of handlers:
prepare, run beforefork()begins;parent, run in the parent afterfork()completes;child, run in the child afterfork()completes.
The original design idea was that prepare acquires the relevant locks so fork() sees a consistent state, and the parent and child handlers put things back in order afterwards.
POSIX itself is pessimistic about the scheme. A real process contains the C library, a logging library, a crypto library, a database client, and a runtime. For the scheme to work, every component must expose and coordinate its locks, and everyone must agree on one lock order. Miss a single lock and the problem remains; get the order wrong and the prepare phase deadlocks.
Current POSIX also requires the child of a multithreaded fork() to perform only async-signal-safe operations before exec, and pthread mutex functions are not on that list. POSIX.1-2024 states the inherited-lock case even more plainly: operating on a held, non-process-shared lock in the child is undefined behavior.
So pthread_atfork() can help a library that knows its own implementation coordinate a limited amount of state. It cannot repair a whole process. It is not a permit for business threads to fork() whenever they like.
Patterns that stay safe in production
The cleanest answer is to fork() before creating any threads. Traditional prefork servers work exactly this way: the master process creates its children first, and each child then builds its own event loop or thread pool. At fork() time there is one thread, so no user-space lock can be held by a thread that will not exist in the child.
If the goal is only to start another program, check whether posix_spawn() is sufficient. It bundles process creation, limited file descriptor operations, and attribute setup into one interface, so the caller never runs its own complex code between fork() and exec(). Implementations vary by platform; modern glibc does not simply inline a hand-written fork() plus exec() path either.
If you must call fork() yourself, the child should execve() as soon as possible. In between, do only what is necessary and async-signal-safe: close() and dup2() to arrange descriptors, write() to report an error, then _exit().
One detail is easy to miss. exec() replaces the user-space address space, so inherited heap contents, mutexes, and library state vanish with it, but exec() does not close every file descriptor. Descriptors the new program does not need should carry FD_CLOEXEC, or be closed explicitly through posix_spawn() file actions.
Large systems can also delegate child creation to a dedicated helper process. The point is not to wrap fork() in a function; it is to keep fork() away from a scene that has already become a complex multithreaded one.
The hardest pattern to maintain is the opposite: a service that runs for a while, then some business thread suddenly fork()s, and the child keeps using the parent’s logging, allocator, connection pools, and runtime. It may behave for months in test environments, and fail only when load and timing shift.
Rethinking fork()
In a single-threaded program, “the program splits into two paths here” is a fine mental model.
In a multithreaded program, add one sentence: only the calling thread splits.
Much of the address space state stays behind in the child, and file descriptors may still point at the same kernel objects, but the other threads are gone. Understand that, and you understand why POSIX restricts the window between fork() and exec() so harshly.
If a multithreaded program must fork(), let the child exec() quickly. If you only need to start another program, prefer posix_spawn(). Keep the descriptors the new program needs; arrange for everything else to close.
References
- Linux man-pages,
fork(2): https://man7.org/linux/man-pages/man2/fork.2.html - Linux man-pages,
clone(2): https://man7.org/linux/man-pages/man2/clone.2.html - Linux man-pages,
pthread_atfork(3): https://man7.org/linux/man-pages/man3/pthread_atfork.3.html - Linux man-pages,
posix_spawn(3): https://man7.org/linux/man-pages/man3/posix_spawn.3.html - Linux man-pages,
signal-safety(7): https://man7.org/linux/man-pages/man7/signal-safety.7.html - The Open Group Base Specifications,
fork(): https://pubs.opengroup.org/onlinepubs/9799919799/functions/fork.html - The Open Group Base Specifications,
pthread_atfork(): https://pubs.opengroup.org/onlinepubs/9799919799/functions/pthread_atfork.html
