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¶
- 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 whatProcess.waitraises once no child is left. - Make a child that ignores
TERM(trap("TERM", "IGNORE")) and sleeps; send itTERM, thenKILL, and print theProcess::Statusfields after each attempt. Which signal can a process refuse, and what doestermsigsay 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.
See also¶
- Ractors share nothing that is not frozen — parallel Ruby inside one process, with copies instead of a fork
- Threads are OS threads that take turns under the GVL — the unit that shares memory, for contrast
- Running other programs —
system, backticks,spawn,Open3andIO.popenin full - ARGV, ENV and the exit status — the other end of
exitstatus - exit, at_exit and SystemExit — what
exit 3does inside the child - The signals you cannot catch ↗ —
TERMagainstKILL, in the Linux library - Processes ↗ — fork, exit statuses, pipes and zombies across six languages in the Concurrency library
Timeout.timeoutinterrupts a thread from outside —Timeout.timeout(0.2) { sleep 5 }raisesTimeout::Errorafter a fifth of a second because a watcher…- A
SizedQueuethat refuses is the 503 behind "queue full" — Passenger answers a Rails app's requests from a fixed pool of processes, 6 by default, through a request…