Skip to content

fork copies the process; wait collects its status

Level: 301 · anyone who has run another program from Ruby and needed to know how it ended

One line: fork makes a second process that is a copy of this one — the call returns nil in the child and the child's pid in the parent, and nothing the child does to its copy of a variable reaches the parent — while Process.wait and wait2 collect the child's Process::Status, whose exitstatus is nil when a signal killed it; spawn, system and backticks are the same wait with the fork hidden inside.

fork is the Unix system call, and Ruby exposes it in two shapes. With a block, the child runs the block and exits, and the parent gets the child's pid back (row 1). Without a block, the same call returns twice: nil in the child, an Integer in the parent, and the program branches on that (rows 2 and 3 — row 2 is printed by the child process, row 3 by the parent, and the parent waited before printing). The child is a copy: it has the same variables with the same values, and a change it makes stays in its copy (row 4, where the child reports 1 through a pipe and the parent's counter is still 0). Process.wait(pid) blocks until that child ends and sets $? to a Process::Status; Process.wait2 returns the pid and the status as a pair (row 5). A normal exit has an exitstatus and success? is true only for zero; a child killed by a signal has no exit status — exitstatus is nil, signaled? is true, and termsig names the signal (row 9). Waiting when there is no child left raises Errno::ECHILD (row 10).

The higher-level calls are fork-and-wait with the fork hidden. spawn starts another program and returns its pid at once; you Process.wait for it like any child (row 6). system does both and reduces the status to true for exit 0, false for any other exit, and nil when the command could not be run at all — with $?.exitstatus 127 in that last case (row 7). Backticks run the command, capture its stdout as a String, and set $? (row 8). exec is the one with no wait: it replaces the current process with the program and never returns, which is what a shell does after it forks (Not machine-checked here.). Two details keep this page's rows in order: every child is waited for before the next row prints, and Ruby flushes $stdout before forking, so a child never inherits a half-written buffer — no row here is printed twice.

Verified output of processes_fork_and_wait_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. fork { exit 3 }; Process.wait; exitstatus / success? 3 / false
 2. fork returned nil: this row is printed by the child  child
 3. fork returned an Integer: printed by the parent      parent (pid > 0: true)
 4. child did counter += 1 and wrote it / parent's counter 1 / 0
 5. Process.wait2(fork { exit 7 }) -> [pid, status]      Process::Status, exitstatus 7
 6. spawn(ruby, "-e", "exit 5") + Process.wait           $?.exitstatus 5
 7. system: exit 0 / exit 2 / a missing command          true / false / nil ($?.exitstatus 127)
 8. backticks capture the child's stdout; $?.success?    "42" / true
 9. killed by TERM: exitstatus / signaled? / termsig     nil / true / TERM
10. Process.wait with no children left                   Errno::ECHILD

Reading the output

Row 1: the child exited with 3, so exitstatus is 3 and success? is false. Rows 2 and 3 are two processes each printing one line: the child's fork returned nil, the parent's returned a positive Integer, and because the parent called Process.wait before printing, the child's row always comes first. Row 4 is the copy in one line: 1 / 0. Row 5 shows wait2 returning a Process::Status whose exitstatus is 7, and row 6 that a spawned Ruby that ran exit 5 is collected the same way.

Row 7 is system's three answers, true / false / nil, and the 127 that a shell reports for a command it could not find. Row 8 is the captured "42" with success? true. Row 9 is the signal case: the child was sleeping, Process.kill("TERM", pid) ended it, and the status says nil / true / TERM — no exit status, signalled, by TERM. Row 10 is Errno::ECHILD, the error for waiting when every child has been collected.

Compared with Python

Python's os.fork() returns 0 in the child and the pid in the parent (rows 2 and 3), and the child's copy of counter is just as private (row 4); os.waitpid(pid, 0) returns the pid and a raw integer status, which os.waitstatus_to_exitcode turns into an exit code and os.WEXITSTATUS, os.WIFSIGNALED and os.WTERMSIG pick apart (rows 1, 5 and 9). A signal shows up as a negative exit code, -15 for SIGTERM, where Ruby says exitstatus nil, termsig 15 (row 9). Two things Python leaves to you. First, the child must leave with os._exit(), not sys.exit(), or it runs the parent's atexit handlers and flushes the parent's buffers a second time; Ruby's block form of fork calls exit for you, and exit! is the Ruby spelling of os._exit (Not machine-checked here.). Second, Python does not flush sys.stdout before forking — the child inherits whatever was buffered and would print it again — so the twin's fork_flushed() calls sys.stdout.flush() first (Not machine-checked here.).

subprocess covers spawn, system and backticks in one API: Popen(...).wait() is spawn + Process.wait (row 6), run(...).returncode is system — except that a missing command is a FileNotFoundError, not None (row 7) — and run(..., capture_output=True).stdout is the backtick string (row 8). Where Ruby has $? after every call, Python returns the status from the call itself. os.wait() with no children raises ChildProcessError (row 10), the same ECHILD under a Python name. multiprocessing — processes with queues and pools — is the tool for parallel work, and it is the twin of the Ractor page rather than this one.

Verified output of processes_fork_and_wait_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. os.fork(); child os._exit(3); waitpid; exit code / == 0 3 / False
 2. fork returned 0: this row is printed by the child    child
 3. fork returned a positive int: printed by the parent  parent (pid > 0: True)
 4. child did counter += 1 and wrote it / parent's counter 1 / 0
 5. os.waitpid(pid, 0) -> (pid, status); WEXITSTATUS     int, exit code 7
 6. Popen([python, -c, "raise SystemExit(5)"]).wait()    returncode 5
 7. run(...).returncode: exit 0 / exit 2 / a missing command 0 / 2 / FileNotFoundError
 8. run(capture_output=True).stdout; returncode == 0     '42' / True
 9. killed by SIGTERM: exit code / WIFSIGNALED / signal  -15 / True / SIGTERM
10. os.wait() with no children left                      ChildProcessError

Try it

  1. Fork three children that each exit with their own index. Collect all three with Process.wait2 (with no pid, so it takes whichever child finishes first), print the exit statuses sorted, and print what Process.wait raises once no child is left.
  2. Make a child that ignores TERM (trap("TERM", "IGNORE")) and sleeps; send it TERM, then KILL, and print the Process::Status fields after each attempt. Which signal can a process refuse, and what does termsig say in the end?
Solution to 1

processes_fork_and_wait_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: three children, each exiting with its own index. The parent collects
# all three with Process.wait2 and prints the statuses sorted, because the
# order in which children finish is not something to record.
pids = (1..3).map { |i| fork { exit i } }
statuses = pids.map { Process.wait2[1] }

statuses.map(&:exitstatus).sort.each do |code|
  puts "a child exited with status #{code}"
end
puts "all three exited normally: #{statuses.all?(&:exited?)}"
puts "waiting again raises: #{(Process.wait rescue $!.class)}"

Verified output of processes_fork_and_wait_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

a child exited with status 1
a child exited with status 2
a child exited with status 3
all three exited normally: true
waiting again raises: Errno::ECHILD

See also