skip to content

Forking & Processes

Process.fork copies the running interpreter into a child that shares memory pages copy-on-write, giving real parallelism around the GVL. Interviewers probe zombies and preforking servers.

on this pageshow

explore

questions

4

In Ruby, what is a zombie child process, and how do Process.wait, waitpid with WNOHANG and Process.detach each prevent one?

level: middleimportance: must knowfreq 48%

answer

  1. exited but not yet reaped
  2. Process.wait returns the pid, sets $?
  3. WNOHANG: nil while still running
  4. detach starts a reaper thread
  5. exitstatus, not status >> 8

basics

~20 s

A zombie is a child that has exited but whose status the parent never collected. Process.wait(pid) blocks and reaps it, Process.wait(pid, Process::WNOHANG) reaps only if it already exited, and Process.detach(pid) starts a thread that reaps it for you.

solid answer

~40 s

When a child exits, the operating system keeps its exit status in the process table until the parent collects it; until then it is a **zombie** (state `Z` in `ps`). `Process.wait(pid = -1, flags = 0)`, alias `waitpid`, blocks until a child exits, returns its pid and sets `$?` (also `Process.last_status`) to a `Process::Status`. With `Process::WNOHANG` it returns `nil` immediately if no child has exited yet, which suits a supervisor loop. `Process.wait2` returns `[pid, status]`, and `Process.waitall` reaps every child. `Process.detach(pid)` is for children you will never wait for: it returns a `Process::Waiter` thread whose only job is to reap that pid. Waiting when there are no children raises `Errno::ECHILD`. In Ruby 4.0 read the code with `status.exitstatus` or `success?`; `Process::Status#>>` and `#&` were removed.

go deeper

for a junior

Remember that every forked child must be collected with Process.wait or Process.detach, and that $? holds the result after a wait.

for a middle

Explain what a zombie is, the difference between blocking wait, WNOHANG polling and detach, and how to read exitstatus and success? from Process::Status.

for a senior

Write a supervisor that reaps with WNOHANG, replaces dead workers, handles Errno::ECHILD, and does not mix detach with explicit waits.

for a principal

Decide whether your own supervisor or an external process manager should own worker lifecycles, restarts and exit-status reporting.

## What a zombie is When a process exits, the operating system frees its memory but keeps a small entry with its **exit status** so the parent can find out how it ended. Until the parent collects that status (**reaps** the child), the entry stays in the process table and `ps` shows the process in state `Z`. A job runner that forks a worker per job and never reaps them accumulates zombies until the system refuses to create more processes. If the parent itself exits, orphaned children are adopted and reaped by the system's init process, so zombies are a problem of **long-running parents**: supervisors, preforking servers, job runners. ## The reaping methods | Call | Blocks? | Returns | Use when | |---|---|---|---| | `Process.wait(pid)` / `Process.waitpid(pid)` | yes | the pid; sets `$?` | you need the result before continuing | | `Process.wait` / `Process.wait(-1)` | yes | pid of any child that exited | a supervisor handling whichever child ends first | | `Process.wait(pid, Process::WNOHANG)` | no | pid if it exited, otherwise `nil` | polling from a loop that does other work | | `Process.wait2(pid)` | yes | `[pid, Process::Status]` | you want the status without reading `$?` | | `Process.waitall` | yes | `[[pid, status], ...]` for every child | shutting down and collecting everyone | | `Process.detach(pid)` | no | a `Process::Waiter` thread | you will never care about the result | Any wait raises `Errno::ECHILD` when there is no matching child, for example when it has already been reaped. ## Reading the status After a wait, `$?` and `Process.last_status` hold a `Process::Status`: - **`exitstatus`**: the exit code, or `nil` if the child was killed by a signal. - **`success?`**: `true` for exit code 0. - **`signaled?`** and **`termsig`**: whether a signal ended it, and which one. - **`pid`**: which child this status belongs to. Ruby 4.0 removed `Process::Status#&` and `Process::Status#>>`, so the old C-style idiom `$? >> 8` no longer works; `exitstatus` is the replacement. ```ruby pid = fork { exit 3 } Process.wait(pid) $?.exitstatus # => 3 $?.success? # => false ``` ## A supervisor loop for a preforking job runner ```ruby workers = 4.times.to_h { |i| [fork { JobRunner.work_loop }, i] } loop do while (pid = Process.wait(-1, Process::WNOHANG)) slot = workers.delete(pid) workers[fork { JobRunner.work_loop }] = slot end sleep 1 end ``` The inner loop reaps every child that has exited since the last pass and replaces it, then the parent goes back to sleep; no worker is left as a zombie and the parent never blocks on one slow child. ## Common mistakes | Mistake | Consequence | |---|---| | Forking per job without ever waiting | zombies pile up until process creation fails | | Waiting only at shutdown with `Process.waitall` | every child that finished earlier stays a zombie until then | | Treating `exitstatus` as always an Integer | it is `nil` when a signal killed the child; check `signaled?` | | Reading `$?` after other code ran | `$?` is replaced by the next wait, `system` or backtick call in that thread | ## Process.detach `Process.detach(pid)` starts a Ruby thread (an instance of `Process::Waiter`) that waits on that pid and ends when the child does. Its `pid` method returns the child's pid, and joining the thread gives you the `Process::Status` as its value. Two consequences: 1. Use it only for children you will never wait on yourself. Once the waiter thread has reaped the child, your own `Process.wait(pid)` raises `Errno::ECHILD`. 2. It costs one thread per child, which is fine for a handful of fire-and-forget helpers but wasteful for thousands.

  • Your code calls `Process.detach(pid)` and later `Process.wait(pid)` for the same child. What can happen?
    The waiter thread created by `detach` reaps the child as soon as it exits. If that has already happened, your `Process.wait(pid)` raises `Errno::ECHILD` because there is no such child left to wait for. Pick one strategy per child: wait yourself, or detach and read the status from the waiter thread's value.
  • Why does `Process.wait(-1, Process::WNOHANG)` return `nil` in a loop instead of raising?
    With `WNOHANG` the wait does not block: if children exist but none has exited yet, it returns `nil` at once and leaves `$?` cleared. It raises `Errno::ECHILD` only when there are no children at all. That split lets a supervisor loop reap whatever has finished and move on.

saying these in an interview costs you the question

  • Thinks a zombie is a child that is still running and using CPU
  • Reads the exit code with $? >> 8 on Ruby 4.0
  • Calls Process.detach and then Process.wait on the same pid
  • Expects WNOHANG to raise when the child is still running
  • Believes zombies disappear on their own while the parent keeps running
open as a page

In Ruby, what does fork return in the parent and the child, with and without a block, and why does forking bypass the GVL?

level: juniorimportance: should knowfreq 45%

basics

~20 s

fork copies the running Ruby process. Without a block it returns the child's pid in the parent and nil in the child; with a block, only the child runs it and then exits. Each process has its own GVL, so children run Ruby in parallel.

open as a page

A preforking Ruby job runner's workers grow toward the parent's full size soon after fork; why does copy-on-write sharing erode, and how does Process.warmup help?

level: seniorimportance: should knowfreq 40%

basics

~20 s

After fork, pages stay shared only until a process writes to them, and CRuby writes to heap pages constantly: allocating into free slots, promoting survivors to the old generation, filling string caches. Process.warmup does that work once in the parent before forking.

open as a page

In Ruby, how do you run code before and after every fork with Process._fork, and why is overriding Kernel#fork not enough?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Process._fork is the internal method that Kernel#fork, Process.fork and IO.popen("-") all call. Prepend a module to Process.singleton_class that overrides _fork, calls super and branches on the result: 0 in the child, the child's pid in the parent.

open as a page