Skip to content
← All articles

Why fork Plus Threads Is Genuinely Dangerous

The failure mode is a hang, not a crash — one task in ten thousand, no traceback, no exit code. Why fork() copies locks without owners, the roster of threads you did not know you had, and why glibc hides it.

The reason this is worth an article rather than a footnote is the shape of the failure. fork plus threads does not crash. It hangs — and it hangs on roughly one task in ten thousand, so it passes CI, passes staging, runs for six weeks, and then wedges a queue at 3 a.m. with no traceback, no exit code and no log line. The process is alive. It is simply never going to make progress again, and py-spy is the only tool that will tell you why.

The mechanism, in one paragraph

fork() duplicates the calling process’s address space. It does not duplicate its threads: only the thread that called fork exists in the child. Every other thread is gone — but everything those threads owned is still there, byte for byte, in the copied memory. If one of them held a mutex at the instant of the fork, the child inherits that mutex locked, with no owner and no thread that will ever release it. The next time the child touches that lock, it blocks forever.

This is not a Python bug and it is not fixable in Python. POSIX permits only async-signal-safe calls in the child between fork() and execve(), and CPython violates that by construction — the docs say so:

The CPython runtime itself has always made API calls that are not safe for use in the child process when threads existed in the parent (such as malloc and free).

The 3.12 note is blunter still:

Even in code that appears to work, it has never been safe to mix threading with os.fork() on POSIX platforms.

Note the phrasing: not “is now unsafe”, not “is deprecated”. Has never been safe. Every codebase that has been forking from a threaded parent for five years has been getting away with it, not doing it correctly.

The roster of threads you did not know you had

The usual objection is “but my program does not use threads”. It almost certainly does. Here is where they come from:

  • BLAS. The first numpy matmul starts an OpenBLAS or MKL or OpenMP thread pool sized to your core count. OpenBLAS registers a pthread_atfork handler that waits on a lock — which is exactly the thing that will not be released.
  • CUDA. The driver starts its own threads. PyTorch does not even try to survive this; it raises “Cannot re-initialize CUDA in forked subprocess” and tells you to use spawn.
  • logging.handlers.QueueListener. A background thread, started the moment you configure async logging.
  • gRPC. Its own event loop threads.
  • urllib3 connection pools, which is to say: requests, which is to say: most HTTP clients.
  • Sentry’s transport, which batches events on a worker thread.
  • A ThreadPoolExecutor somebody created in a module-level constant three years ago.
  • CPython’s own resource_tracker, which has a lock of its own (cpython#96971).

So the honest test is not “do I call threading.Thread“ but “does anything in my import graph”. After a import numpy; numpy.zeros((2,2)) @ numpy.zeros((2,2)), threading.active_count() is not 1.

💡Your data pipeline forks 200 workers per batch. It has run nightly for eight months. You add a Sentry SDK to the parent process for error reporting — a one-line change, no logic touched — and within a week the pipeline starts hanging roughly once every three nights, always at a different stage. What happened, and why is the "always a different stage" detail the tell? click to reveal

Sentry’s transport starts a background thread in the parent. From that moment the parent is multi-threaded at fork time, and every fork has a small probability of catching that thread mid-critical-section — holding, typically, a malloc arena lock or a logging lock.

The child inherits the lock held with no owner. It then hangs at the first operation that needs it, which is whatever the child happens to do next: allocate memory, emit a log line, import a module. That is why the stall appears at a different stage each time — the stage is not the cause, it is just wherever the child first touched the poisoned lock.

The “always a different stage” detail is diagnostic because it rules out the thing everyone tries first. A logic bug in stage 4 hangs in stage 4. A poisoned inherited lock hangs at an arbitrary point, in an arbitrary worker, with a probability rather than a trigger. Randomly-distributed hang locations plus a recent dependency addition plus fork is a near-certain identification, and it will not reproduce under a debugger because attaching changes the timing.

Note also what makes this so hard to catch in review: the diff was one line, in the parent, about error reporting. Nothing in it mentions concurrency.

Why “it works on my machine”

glibc re-initialises the malloc arena locks in a pthread_atfork handler. So on glibc/Linux, the single most common instance of this bug — a child that hangs on its first malloc — is papered over. On musl (Alpine) and on macOS it is not.

This is why the same image hangs in production and not on the developer’s laptop, and why “we switched the base image to Alpine to save 200 MB” occasionally turns into a week of debugging. It is also why the mitigation is not “test more” — the platform where you test is the platform that hides it.

What you can actually do

os.register_at_fork(before=..., after_in_parent=..., after_in_child=...) is the sanctioned tool. Use before to quiesce your own thread pools and take your own locks; use after_in_child to reset any state that the missing threads owned. It is genuinely useful and it has a hard limit: it only runs for os.fork() calls that go through CPython. A C extension calling fork(2) directly bypasses it entirely, and so does anything the CUDA driver does internally.

os.fork() inside a subinterpreter raises RuntimeError and has since 3.8, which is a small preview of the direction the runtime is heading.

But the real answer is the one 3.14 made the default: do not fork from a threaded process. Use forkserver. It costs 1.40 ms per process against fork‘s 0.87 ms — a difference that matters only if you are creating thousands of processes per second, and if you are, process creation is the wrong tool anyway.

The rule to carry

If your parent process has ever touched NumPy, CUDA, a logging queue, an HTTP client or a thread pool — and it has — then fork is a bet you are placing on timing, on every single fork, forever. The 3.14 default change removed that bet from the code you have not written yet. Removing it from the code you already have is a get_context("forkserver") and an audit of what you were relying on inheriting.

That audit is the subject of the next item.