skip to content

After os.fork(), which of the parent's threads are still running in the child?

level: juniorimportance: should knowfreq 25%

answer

  1. Memory is copied, execution is not
  2. One thread of control crosses the call
  3. The caller lives, the others vanish
  4. A lock copied while held stays held
  5. threading.active_count() reads 1 in the child

basics

~20 s

Only the thread that called os.fork() exists in the child. Every other thread is gone, but the memory those threads were using is copied into the child intact, including any lock one of them was holding.

solid answer

~50 s

POSIX `fork()` duplicates the address space but only a single thread of control: the caller. In the child, `threading.active_count()` returns 1, and the forking thread becomes the main thread, because CPython runs internal handlers after the call that rebuild the `threading` module's bookkeeping. What it does **not** rebuild is your data. Every object the vanished threads were mutating is present in the child in whatever half-finished state it had, and any `threading.Lock` they held is copied still locked — with no thread left to release it. So the child is a one-thread process holding a heap that was frozen mid-flight by threads that no longer exist. That asymmetry, not the copy itself, is why forking a multi-threaded process is unsafe; since Python 3.12 `os.fork()` raises a `DeprecationWarning` when the process has more than one thread.

code

python · 13 lines
python
import os
import threading
import time

threading.Thread(target=time.sleep, args=(5,), daemon=True).start()
print("parent threads:", threading.active_count(), flush=True)

pid = os.fork()
if pid == 0:
    print("child threads:", threading.active_count(), flush=True)
    os._exit(0)

os.waitpid(pid, 0)

go deeper

for a junior

Recall the one-line fact: os.fork() copies the memory but carries over only the calling thread. Be able to say that the child's other threads do not exist even though their data does.

for a middle

Explain the split between address space and threads of control: the heap is duplicated verbatim while the threading module's bookkeeping is rebuilt for the survivor, so half-finished state and held locks arrive in the child intact.

for a senior

Show that you treat a forked child of a threaded process as untrusted state, naming which subsystems can arrive locked - logging handlers, the import machinery, buffered writers, connection pools - and preferring not to fork there at all.

for a principal

Own the policy question: whether services fork once they run threads, and which start method the organisation standardises on now that Python 3.14 defaults to forkserver on Linux and spawn on macOS.

### What `fork()` duplicates, and what it does not `os.fork()` is a thin wrapper over the POSIX `fork()` system call. The kernel creates a second process whose address space is a copy of the caller's — same objects, same heap, same open file descriptors — and then starts that process running with **exactly one thread of control: the thread that made the call**. Every other thread in the parent is simply not created in the child. Their stacks and their Python objects are still present as bytes in the copied memory, but nothing schedules them, nothing resumes them, and nothing finishes what they were in the middle of doing. That asymmetry — *memory is copied, threads of control are not* — is the entire subject. It is not a CPython quirk; it is what POSIX specifies, and every language that exposes `fork()` inherits it. ### What the child actually reports Immediately after the call, in the child, `threading.active_count()` returns 1 and `threading.enumerate()` lists a single thread. If a non-main thread did the forking, `threading.current_thread() is threading.main_thread()` is nevertheless true in the child. CPython arranges this on purpose: it runs internal handlers in the child right after the system call returns, which rebuild the `threading` module's bookkeeping — discarding the parent's `Thread` objects, promoting the surviving thread to main thread, and reinitialising a handful of interpreter-level locks. Without that repair the child would report threads that can never run again. The crucial point is where the repair stops. CPython fixes *its own bookkeeping*. It does not, and cannot, fix **your data**. Everything your program built is copied verbatim in whatever state it happened to be in at the instant of the call — a list mid-append, a dictionary mid-resize, a half-written buffer, a connection that another thread was in the middle of using. ### The lock case The sharpest instance of that is a lock. Suppose a background thread acquires a `threading.Lock` and a microsecond later another thread calls `os.fork()`. The child gets a copy of that lock object in the **acquired** state. In the parent, the holder eventually releases it and life goes on. In the child, the holder does not exist, so nobody will ever call `release()`. The lock is held by nobody, permanently. The first `acquire()` on it in the child blocks until someone kills the process. You do not need to own a lock yourself to be caught by this. Locks live throughout the runtime and the standard library: a `logging.Handler` serialises writes with one, the import machinery takes per-module locks, buffered writers guard their buffers, connection pools and caches guard their internal structures, and the memory allocator has its own. Which of them is locked at fork time is chosen by the OS scheduler, not by you, so the failure is probabilistic and gets more likely the busier the parent's threads are. POSIX states the resulting contract bluntly: after a fork in a multi-threaded process, the child may call only async-signal-safe functions until it calls one of the `exec` family. Python cannot honour that — even printing allocates memory, and allocating takes a lock. ### The short version - **Copied:** the whole address space, file descriptors, and every Python object, in mid-flight state. - **Not copied:** every thread except the caller. - **Repaired by CPython:** the `threading` module's view of who is running. - **Not repaired:** your locks, your buffers, your half-finished data structures. ### How Python flags it Since **Python 3.12**, `os.fork()` and `os.forkpty()` raise a `DeprecationWarning` when the calling process has more than one running thread. `os.fork()` itself is *not* deprecated — forking a single-threaded process remains an ordinary, supported Unix operation. The warning targets exactly the combination described above. The ecosystem moved with it. On **Python 3.14** `multiprocessing` (and therefore `concurrent.futures.ProcessPoolExecutor`) defaults to the `forkserver` start method on Linux and other Unix platforms; macOS and Windows default to `spawn`; plain `fork` must now be requested explicitly. The practical rule that falls out of all of it is simple: create your child processes before the parent grows threads, or use a start method that does not fork your threaded process at all.

  • If a non-main thread calls os.fork(), what does threading.main_thread() return in the child?
    The forking thread. CPython's post-fork handlers discard the parent's `Thread` objects and reinstall the surviving thread as the main thread, so in the child `threading.main_thread()` and `threading.current_thread()` name the same object. That is bookkeeping repair only — it makes the `threading` module tell the truth about who is running, and changes nothing about the locks and half-finished data the child inherited.
  • Is os.fork() itself deprecated in Python 3.14?
    No. `os.fork()` remains supported. What Python 3.12 added is a `DeprecationWarning` raised only when the calling process is multi-threaded, because that specific combination is the unsafe one; `os.forkpty()` carries the same conditional warning. Forking a single-threaded process is still an ordinary Unix operation with no warning attached.

It is like photographing a busy kitchen and then stepping into the photograph alone: every pot, every half-chopped onion and every locked storeroom is exactly where it was, but the cooks who were holding the keys are not there.

saying these in an interview costs you the question

  • Says every parent thread is duplicated into the child
  • Thinks the child restarts the parent's background threads
  • Believes forking releases any lock that was held
  • Claims only the main thread is allowed to call os.fork()
  • Assumes the child starts with a fresh, empty heap

context