Why should a child created by os.fork() exit with os._exit()?
answer
- One of them is not a system call
- It raises something before it ends anything
- The child inherited the parent's cleanup list
- Shutdown runs atexit, then flushes the streams
- os._exit skips unwinding, atexit and flushing
basics
~10 ssys.exit() only raises SystemExit, so the child unwinds, runs the parent's inherited atexit callbacks and flushes inherited buffers -- cleanup that happens twice. os._exit() ends the process immediately, skipping all of it.
solid answer
~50 s`sys.exit()` is not a system call: it raises `SystemExit`, which unwinds the child's stack through `finally` blocks and context managers, can be swallowed by an enclosing `except`, and if it reaches the top runs the full interpreter shutdown -- `atexit` callbacks first, then flushing the standard streams. The problem is that the child inherited all of that state from the parent, so callbacks registered to release a lock, delete a temp file or close a pooled connection run a second time against a resource only the parent owns, and inherited buffered output is re-emitted. `os._exit(status)` calls `_exit` directly: no unwinding, no `atexit`, no flushing, no shutdown. The idiom is to flush anything the child itself wrote, then `os._exit(0)` -- and wrap the child's body in `try`/`except` so an escaping exception ends in `os._exit(1)` rather than in the normal exit path.
code
python · 12 linesimport atexit, os, sys
def cleanup():
print("cleanup ran in", os.getpid())
atexit.register(cleanup)
if os.fork() == 0:
sys.stdout.flush()
os._exit(0) # skips atexit and the final flush
os.wait()
# one "cleanup ran in ..." line; using sys.exit(0) instead prints twogo deeper
Know the two names apart: sys.exit() raises SystemExit and lets the interpreter shut down normally, os._exit() ends the process on the spot. After os.fork(), the child normally wants the second one.
Explain the three things the normal exit path does in a child -- runs inherited finally blocks, can be swallowed by an enclosing except, and runs the parent's atexit callbacks plus a stream flush during shutdown -- and why each is wrong there.
Demonstrate the full shape in production code: flush before the fork, wrap the child's body so no exception can escape, flush the child's own output, and os._exit(0) or os._exit(1). Say what os._exit() costs and how you pay it.
Take a position on whether raw os.fork() belongs in the codebase at all, given that the higher-level process APIs already guarantee this discipline, and if it stays, on the single reviewed helper every fork site must go through.
### Two different things called "exit" `os._exit(status)` is the `_exit` system call. It terminates the process on the spot with the integer status you give it. Nothing in Python runs afterwards. `sys.exit(status)` is a Python-level convenience that does exactly one thing: it raises `SystemExit`. Whether the process actually ends depends on what happens to that exception. Ordinarily it propagates to the top, the interpreter shuts down, and the status becomes the process's exit status -- which is why the distinction is invisible in a normal single-process script. After `os.fork()` it stops being invisible. ### What the ordinary exit path does in a forked child Raising `SystemExit` starts an ordinary unwind. Three things follow, and in a child every one of them is a hazard: **`finally` blocks and `__exit__` methods run.** The child's stack is a copy of the parent's stack. Every `with` block the parent was inside is also open in the child, so the child releases the parent's lock, closes the parent's connection, deletes the parent's scratch directory -- on the way out. **An enclosing `except` can swallow it.** `SystemExit` derives from `BaseException`, so a bare `except:` or an `except BaseException:` catches it. If any frame between the fork and the exit has one -- an over-broad handler in a worker loop is the usual culprit -- the child does not exit at all. It keeps running as a second full copy of the program, doing the parent's work in parallel. **Interpreter shutdown runs.** If `SystemExit` does reach the top, the child performs a normal shutdown: it runs the callbacks registered with `atexit.register` in last-in-first-out order, then flushes and closes the standard streams. The child inherited the parent's registry at fork time, so callbacks the parent registered to write a summary, unlink a pid file, release a lease or flush a metrics buffer all fire in the child too. Overall they run twice, from two processes, against a resource exactly one of them owns. Exactly the same path is taken when the child's body simply falls off the end of the `if` branch, or dies on an uncaught exception. "Exiting normally" is the hazard; `sys.exit()` is just its most explicit spelling. ### Why `os._exit()` is the right instrument `os._exit()` skips every step above. No unwinding, so no `finally` block or context manager belonging to the parent is executed. No `atexit`, so the parent's cleanup runs once, in the parent. No stream flushing, so inherited pending output is discarded rather than duplicated. The child ends, the parent reaps it, and only work the child was actually meant to do has happened. The price is that `os._exit()` also skips cleanup the child genuinely wanted. Two consequences worth stating in an interview: * **Flush your own output first.** If the child printed anything, `sys.stdout.flush()` must run before `os._exit()`, or the child's own text is thrown away with the buffer. * **The status must be a plain integer.** `os._exit()` takes an int; there is no `sys.exit("message")`-style behaviour of printing an object to stderr and exiting 1. If the child needs to report a message it has to write and flush it itself. ### The shape to write ```python sys.stdout.flush() pid = os.fork() if pid == 0: try: run_child_work() except BaseException: traceback.print_exc() sys.stderr.flush() os._exit(1) sys.stdout.flush() os._exit(0) ``` The `try` matters as much as the `os._exit()`: without it, a bug inside `run_child_work()` leaves through the normal exception path, which is the same interpreter shutdown you were trying to avoid. Catching `BaseException` here is correct rather than sloppy -- this frame is the child's last one, and its job is to guarantee that nothing leaves except through `os._exit()`. ### When `sys.exit()` in a child is fine When the child truly owns everything it would clean up. A child that immediately calls one of the `os.exec*` functions never reaches an exit path at all and does not care. A child that registered its own `atexit` callbacks after the fork -- having first dropped the inherited ones with `atexit.unregister` -- may legitimately want the normal shutdown to run them. The rule is not "`sys.exit()` is wrong"; it is "a forked child must not run the parent's cleanup", and `os._exit()` is the cheapest way to guarantee that. ### Why you may never have hit this Higher-level APIs do it for you. A worker process created through the `multiprocessing` module or a process-pool executor ends through machinery that runs only the finalizers belonging to that child and then hard-exits. `os.fork()` is the raw primitive with no such machinery, which is precisely why the interview question is asked about `os.fork()`. ### Version note The semantics of `os._exit()`, `sys.exit()` and `atexit` are unchanged from Python 3.10 through 3.14. What has moved around it is the ecosystem's default: since 3.14 the `multiprocessing` default start method on Unix other than macOS is `forkserver`, and `fork` must be requested explicitly -- so hand-rolled `os.fork()` code is increasingly the only place these rules are yours to enforce.
- What does os._exit() cost you that you have to compensate for by hand?Everything the normal shutdown would have done for the child itself. Its own buffered output is discarded, so it must call `sys.stdout.flush()` (and `sys.stderr.flush()`) before exiting; its own `finally` blocks and context managers never run, so anything it must release has to be released explicitly first; and the status argument has to be a plain integer, since there is no message-printing form.
- If the child's work raises an unexpected exception, is os._exit() at the end of the branch enough?No -- an escaping exception never reaches that line. It propagates to the top and the child leaves through the same interpreter shutdown you were avoiding, running the parent's inherited `atexit` callbacks on the way. Wrap the child's body in `try`/`except BaseException`, report the traceback, flush stderr, and `os._exit(1)`. That frame is the child's last one, so catching broadly there is the point rather than a smell.
- Is there a case where letting a forked child exit normally is correct?Yes, when the child owns everything the shutdown would touch. A child that immediately calls one of the `os.exec*` functions never reaches an exit path. A child that dropped the parent's callbacks with `atexit.unregister` and registered its own after the fork may want the normal shutdown to run them. The rule is that a child must not run the *parent's* cleanup, not that `sys.exit()` is banned.
sys.exit() is leaving the building the proper way, switching off the lights as you go. os._exit() is stepping straight out of the window. In a forked child the lights belong to the parent, and it is still working by them.
saying these in an interview costs you the question
- Says sys.exit() is a system call that ends the process immediately
- Thinks SystemExit cannot be caught by an except clause
- Believes a fork gives the child an empty atexit registry
- Calls os._exit() but forgets to flush the child's own output
- Claims os._exit() is only about speed, not about cleanup
- Assumes an uncaught exception in the child skips interpreter shutdown