skip to content

At-Fork Reset Hooks

Callbacks that fire before a fork, in the parent after it, and in the child after it, so a library can reset a lock, drop an inherited connection and reseed randomness instead of corrupting both.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

3

What do the three callbacks of `os.register_at_fork` do, and in which process does each run?

level: middleimportance: should knowfreq 28%

answer

  1. Three phases around one syscall
  2. One of them runs in a different process
  3. The parent hook has a matching undo
  4. Multiple registrations have a defined order
  5. before is reversed; the afters are not

basics

~10 s

os.register_at_fork takes three keyword-only callables. before runs in the parent just before the fork() syscall, after_in_parent runs in the parent once it returns, and after_in_child runs inside the brand-new child process.

solid answer

~40 s

`os.register_at_fork(before=..., after_in_parent=..., after_in_child=...)` hooks every `os.fork()` and `os.forkpty()` this interpreter performs. `before` is the parent's last chance to make duplicated state consistent — typically acquiring the lock that guards it. `after_in_parent` undoes that in the parent; `after_in_child` repairs the copy in the child: release the inherited lock, discard handles the child does not really own, reseed a generator. The canonical idiom is `before=lock.acquire` with `release` in both `after` hooks. Ordering is defined: `before` callbacks fire in reverse registration order, the two `after` sets in registration order, so pairs unwind like nested context managers. Two sharp edges: an exception inside a callback is reported through `sys.unraisablehook` and ignored rather than aborting the fork, and there is no unregister — a registration lives for the process and is inherited by children.

code

python · 19 lines
python
import os
import threading

_lock = threading.Lock()

os.register_at_fork(
    before=_lock.acquire,
    after_in_parent=_lock.release,
    after_in_child=_lock.release,
)

if os.fork() == 0:
    with _lock:
        print("child acquired the lock cleanly", flush=True)
    os._exit(0)

os.wait()
with _lock:
    print("parent acquired the lock cleanly")

go deeper

for a junior

Recall the shape: three keyword-only callbacks, and one of them runs in a different process than the other two. Be able to say that a forked child is a copy of the parent, which is why anything needs resetting at all.

for a middle

Explain each phase by the work it does — quiesce, undo, repair — and name the acquire-in-before, release-in-both-after idiom. Know that before fires in reverse registration order while the two after sets fire in order.

for a senior

Demonstrate the operational edges: callbacks cannot be unregistered, exceptions in them are swallowed through the unraisable hook, and the hooks fire only for real forks, so cleanup that must happen under every multiprocessing start method cannot live here alone.

for a principal

Own the policy question of whether a library should register global at-fork hooks at all. They are process-wide, permanent, ordered only by import, and invisible to callers — argue when that implicit coupling beats requiring an explicit per-worker initialization step.

## The API `os.register_at_fork(*, before=None, after_in_parent=None, after_in_child=None)` registers callables that CPython runs around every subsequent `os.fork()` and `os.forkpty()` performed by this interpreter. It has been available since Python 3.7, it exists only where `os.fork` does (Unix), all three arguments are keyword-only, and at least one of them must be supplied. It exists because a fork is not a fresh process: the child is a byte-for-byte copy of the parent's address space with exactly one thread running, so every cached handle, counter, seeded generator and half-updated data structure in that copy is inherited in whatever state the fork caught it in. ## Where each callback runs * `before` runs **in the parent, before the `fork()` syscall**. This is the only moment when you can still make the state that is about to be duplicated consistent: acquire the locks that guard it so no other thread can be mid-update when the snapshot is taken. * `after_in_parent` runs **in the parent, after `fork()` returns there**. Its job is to undo `before` — release what you acquired — so the parent carries on normally. * `after_in_child` runs **in the brand-new child process**. This is the repair phase: release the copy of the lock, drop handles that are not really yours (an inherited connection, a cached parent pid), and reseed a generator whose state is now identical to the parent's. Together they read like one nested block spanning the syscall, which is exactly the idiom the standard library itself uses: ```python import os import threading _lock = threading.Lock() os.register_at_fork( before=_lock.acquire, after_in_parent=_lock.release, after_in_child=_lock.release, ) ``` The `before` hook guarantees the fork cannot land while another thread holds `_lock`; both `after` hooks put each copy of the process back into a usable state. ## Ordering across several registrations Different libraries register independently, so order is defined: `before` callbacks fire in **reverse** registration order, while `after_in_parent` and `after_in_child` fire in registration order. The pair therefore unwinds like nested context managers, with the most recently registered hook on the outside. In practice registration order follows import order, so a library that imports another can rely on being unwound in a consistent sequence rather than an arbitrary one. ```pycon >>> import os >>> for i in range(3): ... os.register_at_fork(before=lambda i=i: print("before", i), ... after_in_child=lambda i=i: print("child", i)) ... >>> if os.fork() == 0: ... os._exit(0) ... before 2 before 1 before 0 child 0 child 1 child 2 ``` ## Failure and lifetime semantics Two properties surprise people. First, an exception raised inside a callback is **not** propagated and does not abort the fork: CPython reports it through `sys.unraisablehook` with the message `Exception ignored in atfork callback`, then runs the remaining callbacks and continues. A hook must therefore be defensive and independent of its neighbours — if it can raise, it will one day be swallowed into a log line nobody reads. Second, there is **no unregister**. A registration lasts for the life of the process and, because the registry lives in the address space, the child inherits it: if that child forks again, every hook runs again. Code that needs to stop participating must make its own callback a no-op behind a flag rather than expect to remove it. ## What actually triggers the hooks Only `os.fork()` and `os.forkpty()` from this interpreter. That covers `multiprocessing` when it uses the **fork** start method, since that is implemented on top of `os.fork()`. It does **not** cover the `spawn` or `forkserver` start methods, whose workers are separate interpreter startups that never saw your runtime registration, and it does not cover `subprocess` or `os.posix_spawn`, which fork-and-exec a different program — the Python-level callbacks are skipped there entirely. That boundary matters more since Python 3.14 changed the default `multiprocessing` start method on Linux to `forkserver` (macOS and Windows already defaulted to `spawn`): cleanup that only lives in an at-fork hook silently stops running when the start method changes. Initialization that must happen in *every* worker belongs in lazy per-worker setup; at-fork hooks are for repairing a copy, not for starting a process. ## Why an interviewer asks it The three-phase shape is the compact test of whether a candidate understands that a fork produces a *duplicate* rather than a *new* program. A candidate who can say which callback runs in which process, name the acquire-in-`before`/release-in-both idiom, and note that hooks are permanent, order-defined and non-fatal on error has demonstrated they have debugged this class of problem rather than read about it.

  • What happens if one of the registered callbacks raises an exception?
    Nothing propagates and the fork is not aborted. CPython reports it through `sys.unraisablehook` as `Exception ignored in atfork callback`, then runs the remaining callbacks and continues. So a hook must be defensive and independent of its neighbours: a failure leaves you with a half-repaired child and only a log line to show for it.
  • Can a callback be unregistered once it has been registered?
    No — there is no removal API. The registration lasts for the life of the process, and because the registry lives in the address space the child inherits it, so a grandchild fork replays every hook. Code that must stop participating makes its own callback a no-op behind a module-level flag.
  • Which operations actually trigger these callbacks?
    Only `os.fork()` and `os.forkpty()` from this interpreter, which includes `multiprocessing` under the fork start method. The `spawn` and `forkserver` start methods launch fresh interpreters that never saw the registration, and `subprocess` or `os.posix_spawn` fork-and-exec a different program, so the Python-level callbacks are skipped there entirely.

It is a nested block wrapped around the syscall: before is the entry, and the two after hooks are the same exit executed twice, once in each of the two processes that come out the other side.

saying these in an interview costs you the question

  • Thinks after_in_child also runs in the parent
  • Believes before callbacks fire in registration order
  • Assumes a registration can later be removed
  • Expects an exception in a callback to abort the fork
  • Claims the hooks fire for spawn or subprocess children
  • Repairs state only in the child, never quiescing in before

context

open as a page

How would you use `os.register_at_fork` to stop forked log-ingest workers from writing into a database connection inherited from the parent?

level: seniorimportance: should knowfreq 32%

basics

~10 s

Register an after_in_child callback that abandons the inherited handle — detach and close the duplicated descriptor, then clear whatever cached it so the child reconnects on first use. Never call the driver's polite close().

open as a page

Why does a forked child repeat a `random.Random(1234)` sequence while `random.random()` differs?

level: juniorimportance: nice to knowfreq 18%

basics

~10 s

Forking copies the whole address space, and a generator's state is just memory, so your own random.Random instance continues the parent's stream. The random module reseeds only its own module-level generator in the child.

open as a page