Overview
Ruby's process management centers around the Process
module and the fork
method, which create child processes that run concurrently with the parent process. The fork
method creates an exact copy of the current Ruby process, including memory state, open file descriptors, and variable values. The child process receives a process ID (PID) of 0, while the parent receives the child's actual PID.
The Process
module provides methods for process creation, signal handling, resource management, and process termination. Ruby's implementation wraps Unix system calls like fork()
, exec()
, wait()
, and kill()
with Ruby-specific error handling and return value formatting.
Process management operates differently from threading because each process runs in its own memory space. Changes made in child processes do not affect the parent process memory, and inter-process communication requires explicit mechanisms like pipes, signals, or shared files.
# Basic fork creates child process
pid = fork do
puts "Child process: #{Process.pid}"
exit 42
end
puts "Parent process: #{Process.pid}, child PID: #{pid}"
Process.wait(pid)
# => Parent process: 1234, child PID: 1235
# => Child process: 1235
The Process.spawn
method provides another approach to process creation with more control over environment variables, file descriptors, and process groups. Unlike fork
, spawn
immediately executes a new program rather than duplicating the current process.
# Process.spawn executes external programs
pid = Process.spawn("ls", "-la", "/tmp")
Process.wait(pid)
Basic Usage
The fork
method creates child processes by duplicating the current process. When fork
succeeds, it returns twice: once in the parent process with the child's PID, and once in the child process with nil
. The block passed to fork
executes only in the child process.
# Fork with block execution
result = fork do
puts "Inside child process"
42 + 8
end
if result
puts "Parent: child PID is #{result}"
Process.wait(result)
else
puts "This code runs in child"
exit
end
The Process.wait
method blocks the parent process until the specified child process terminates. Without Process.wait
, terminated child processes become zombies that consume system resources. The method returns the PID of the terminated process and sets $?
to a Process::Status
object containing exit information.
# Multiple children with wait
children = []
3.times do |i|
pid = fork do
sleep i + 1
puts "Child #{i} finished"
exit i * 10
end
children << pid
end
children.each do |pid|
Process.wait(pid)
puts "Child #{pid} exited with status #{$?.exitstatus}"
end
The Process.detach
method creates a separate thread to wait for a child process, preventing zombie processes without blocking the parent. This approach works well for fire-and-forget child processes where the parent doesn't need to handle the exit status immediately.
# Detached processes
5.times do |i|
pid = fork do
sleep rand(3)
puts "Background task #{i} complete"
end
Process.detach(pid)
end
puts "All background tasks started"
sleep 4 # Let tasks complete
The Process.spawn
method provides fine-grained control over process creation. It accepts command arguments, environment variables, and options for redirecting standard input/output streams. The spawned process replaces itself with the specified command rather than running Ruby code.
# Process.spawn with options
pid = Process.spawn(
{"CUSTOM_VAR" => "value"}, # Environment
"ruby", "-e", "puts ENV['CUSTOM_VAR']", # Command
:out => "/tmp/output.log", # Redirect stdout
:err => "/tmp/error.log" # Redirect stderr
)
Process.wait(pid)
Process groups organize related processes for collective signal handling. The Process.setpgrp
method creates a new process group, while Process.spawn
accepts a :pgroup
option to specify the process group for new processes.
# Process groups
pid = Process.spawn("sleep", "10", :pgroup => true)
pgid = Process.getpgid(pid)
puts "Process group ID: #{pgid}"
# Kill entire process group
Process.kill("TERM", -pgid) # Negative PID targets group
Process.wait(pid)
Thread Safety & Concurrency
Fork creates completely separate memory spaces, making it immune to the traditional thread safety concerns of shared mutable state. However, forking from multi-threaded Ruby programs creates complex scenarios because only the thread that called fork
continues execution in the child process. Other threads disappear, potentially leaving shared resources in inconsistent states.
File descriptors present the primary shared resource between parent and child processes. Both processes initially share the same file descriptor table, meaning writes from either process affect the same underlying files. Close file descriptors in child processes to prevent interference, especially for sockets and pipes.
# File descriptor inheritance
file = File.open("/tmp/shared.log", "w")
pid = fork do
# Child inherits file descriptor
file.puts "Child writing"
file.close # Close in child to avoid conflicts
exit
end
file.puts "Parent writing"
file.close
Process.wait(pid)
Signal handling becomes complex with multiple processes because signals can target individual processes or entire process groups. The Signal.trap
method registers signal handlers that execute when signals arrive, but child processes inherit signal handlers from the parent at fork time.
# Signal handling across processes
Signal.trap("USR1") do
puts "#{Process.pid}: Received USR1"
end
pid = fork do
puts "Child #{Process.pid} waiting for signal"
sleep 10
exit
end
sleep 1
Process.kill("USR1", pid) # Send signal to child
Process.kill("USR1", Process.pid) # Send to parent
Process.wait(pid)
Mutexes and other Ruby threading primitives do not work across process boundaries because each process operates in its own memory space. Inter-process synchronization requires system-level primitives like file locking, semaphores, or message queues.
# File-based process synchronization
require 'fcntl'
def with_file_lock(filename)
File.open(filename, File::RDWR | File::CREAT) do |file|
file.flock(File::LOCK_EX) # Exclusive lock
yield file
ensure
file.flock(File::LOCK_UN) # Release lock
end
end
# Multiple processes using shared resource
3.times do |i|
fork do
with_file_lock("/tmp/counter.txt") do |file|
count = file.read.to_i
file.rewind
file.write((count + 1).to_s)
file.truncate(file.pos)
puts "Process #{Process.pid}: incremented to #{count + 1}"
end
exit
end
end
Process.waitall
Child processes inherit the parent's memory state at fork time but immediately begin copy-on-write behavior. Memory pages remain shared until either process modifies them, at which point the operating system creates separate copies. This behavior affects memory usage patterns and garbage collection timing.
Database connections and network sockets require special attention because child processes inherit these connections but should not share them. Close inherited connections in child processes and establish new connections to prevent conflicts and connection pool exhaustion.
# Database connection handling
require 'sqlite3'
db = SQLite3::Database.new("/tmp/test.db")
db.execute("CREATE TABLE IF NOT EXISTS logs (pid INTEGER, message TEXT)")
pid = fork do
db.close # Close inherited connection
child_db = SQLite3::Database.new("/tmp/test.db")
child_db.execute("INSERT INTO logs VALUES (?, ?)", [Process.pid, "Child entry"])
child_db.close
exit
end
db.execute("INSERT INTO logs VALUES (?, ?)", [Process.pid, "Parent entry"])
Process.wait(pid)
db.close
Error Handling & Debugging
Process creation can fail due to system resource limits, permission restrictions, or memory constraints. The fork
method returns nil
if it fails, while Process.spawn
raises Errno::ENOENT
for missing commands or Errno::EACCES
for permission errors. Always check return values and handle these exceptions appropriately.
# Handling fork failures
begin
pid = fork do
exec("nonexistent_command")
end
if pid.nil?
puts "Fork failed - system resources exhausted"
exit 1
end
Process.wait(pid)
rescue Errno::ENOENT => e
puts "Command not found: #{e.message}"
rescue Errno::EACCES => e
puts "Permission denied: #{e.message}"
end
Child process exit status provides crucial debugging information through the Process::Status
object returned by Process.wait
. The status indicates whether the process exited normally, was terminated by a signal, or stopped by job control. Access specific information through methods like exitstatus
, termsig
, and stopsig
.
# Comprehensive exit status handling
def analyze_exit_status(pid)
Process.wait(pid)
status = $?
if status.success?
puts "Process #{pid} succeeded"
elsif status.exited?
puts "Process #{pid} exited with code #{status.exitstatus}"
elsif status.signaled?
puts "Process #{pid} killed by signal #{status.termsig}"
elsif status.stopped?
puts "Process #{pid} stopped by signal #{status.stopsig}"
end
status
end
# Test different exit scenarios
normal_pid = fork { exit 0 }
error_pid = fork { exit 1 }
killed_pid = fork { Process.kill("KILL", Process.pid) }
[normal_pid, error_pid, killed_pid].each { |pid| analyze_exit_status(pid) }
Zombie processes occur when parent processes fail to call Process.wait
for terminated children. These zombie processes consume system resources and can eventually exhaust the process table. Use Process.waitall
to wait for all child processes or Process.detach
for fire-and-forget scenarios.
# Preventing zombie processes
signal_received = false
Signal.trap("CHLD") do
# Reap all available children
begin
while pid = Process.wait(-1, Process::WNOHANG)
puts "Reaped child #{pid}"
end
rescue Errno::ECHILD
# No more children to reap
end
signal_received = true
end
# Start background processes
5.times do
fork do
sleep rand(5)
exit rand(3)
end
end
# Wait for signal handler to reap children
sleep 6
puts "Signal received: #{signal_received}"
Signal handling introduces timing complexities because signals can arrive at any point during program execution. The Signal.trap
method registers handlers that execute asynchronously, potentially interrupting other operations. Use Process::WNOHANG
with Process.wait
to check for completed children without blocking.
# Non-blocking child process monitoring
children = {}
3.times do |i|
pid = fork do
sleep i + 2
puts "Worker #{i} completed"
exit i
end
children[pid] = "Worker #{i}"
end
# Monitor children without blocking
until children.empty?
children.keys.each do |pid|
begin
finished_pid = Process.wait(pid, Process::WNOHANG)
if finished_pid
puts "#{children[pid]} finished with status #{$?.exitstatus}"
children.delete(pid)
end
rescue Errno::ECHILD
children.delete(pid)
end
end
sleep 0.5
end
Debugging forked processes requires different approaches than single-threaded programs because debuggers typically follow only one process. Use logging to trace execution across processes, and consider process-specific log files to avoid output conflicts.
# Process-specific logging
require 'logger'
def create_logger(prefix)
logger = Logger.new("/tmp/#{prefix}_#{Process.pid}.log")
logger.level = Logger::DEBUG
logger
end
parent_logger = create_logger("parent")
parent_logger.info("Starting parent process")
3.times do |i|
pid = fork do
child_logger = create_logger("child")
child_logger.info("Child #{i} starting")
begin
# Simulate work with potential errors
result = 10 / (i == 1 ? 0 : 1) # Cause error in second child
child_logger.info("Child #{i} result: #{result}")
rescue => e
child_logger.error("Child #{i} error: #{e.message}")
exit 1
end
child_logger.info("Child #{i} completing")
exit 0
end
parent_logger.info("Started child #{pid}")
end
Process.waitall.each do |pid, status|
parent_logger.info("Child #{pid} finished: #{status}")
end
Performance & Memory
Process creation through fork
involves copying the parent's memory space, making it more expensive than thread creation but providing complete isolation. Ruby's copy-on-write behavior means memory pages remain shared until modified, reducing initial memory overhead but potentially causing memory fragmentation as processes diverge.
The memory overhead of forking depends on the parent process size and subsequent memory modifications in child processes. Large Ruby applications with significant memory footprints create correspondingly large child processes. Monitor memory usage patterns to understand the true cost of forking in production applications.
# Memory usage monitoring
def memory_usage
`ps -o pid,rss -p #{Process.pid}`.split("\n").last.split.last.to_i
end
puts "Initial memory: #{memory_usage} KB"
# Create memory pressure
large_array = Array.new(100000) { rand(1000) }
puts "After allocation: #{memory_usage} KB"
pid = fork do
puts "Child initial memory: #{memory_usage} KB"
# Modify shared data (triggers copy-on-write)
large_array[0] = 999999
puts "Child after modification: #{memory_usage} KB"
# Allocate new memory
child_array = Array.new(50000) { rand(1000) }
puts "Child after new allocation: #{memory_usage} KB"
exit
end
puts "Parent after fork: #{memory_usage} KB"
Process.wait(pid)
puts "Parent final memory: #{memory_usage} KB"
Process creation time varies based on system load, available memory, and parent process complexity. Measure fork performance in realistic conditions to understand its impact on application responsiveness. Consider process pools or pre-forking patterns for applications that create many short-lived processes.
require 'benchmark'
# Benchmark process creation patterns
def simple_fork_benchmark(iterations)
Benchmark.measure do
iterations.times do
pid = fork { exit }
Process.wait(pid)
end
end
end
def batch_fork_benchmark(iterations)
Benchmark.measure do
pids = iterations.times.map { fork { exit } }
Process.waitall
end
end
puts "Simple fork (10 iterations): #{simple_fork_benchmark(10)}"
puts "Batch fork (10 iterations): #{batch_fork_benchmark(10)}"
Process.spawn often performs better than fork+exec for running external commands because it avoids the intermediate process duplication step. When executing external programs, prefer Process.spawn
over fork
followed by exec
unless you need to perform Ruby operations in the child process before execution.
# Performance comparison: fork+exec vs spawn
require 'benchmark'
def fork_exec_pattern(command, args)
pid = fork do
exec(command, *args)
end
Process.wait(pid)
end
def spawn_pattern(command, args)
pid = Process.spawn(command, *args)
Process.wait(pid)
end
command = "echo"
args = ["hello", "world"]
iterations = 50
puts "Fork+exec pattern:"
puts Benchmark.measure { iterations.times { fork_exec_pattern(command, args) } }
puts "Spawn pattern:"
puts Benchmark.measure { iterations.times { spawn_pattern(command, args) } }
Resource limits affect process creation and execution through system-level constraints on memory, file descriptors, and CPU time. Use Process.getrlimit
and Process.setrlimit
to query and modify resource limits. Child processes inherit resource limits from their parents unless explicitly modified.
# Resource limit management
def show_limits
[:NPROC, :NOFILE, :AS].each do |resource|
soft, hard = Process.getrlimit(resource)
puts "#{resource}: soft=#{soft}, hard=#{hard}"
end
end
puts "Current limits:"
show_limits
# Reduce file descriptor limit for child processes
pid = fork do
Process.setrlimit(:NOFILE, 100, 100) # soft=100, hard=100
puts "Child limits:"
show_limits
# Attempt to open many files
files = []
begin
200.times do |i|
files << File.open("/dev/null")
end
rescue Errno::EMFILE
puts "Hit file descriptor limit at #{files.size} files"
ensure
files.each(&:close)
end
exit
end
Process.wait(pid)
puts "Parent limits after child:"
show_limits
Production Patterns
Web servers commonly use pre-forking to create worker processes that handle incoming requests. This pattern provides process isolation and crash recovery while sharing the initial application setup cost across multiple workers. Implement graceful shutdown handling to ensure clean termination of worker processes.
# Simple pre-forking web server pattern
class PreForkServer
def initialize(workers: 4)
@workers = workers
@worker_pids = []
@running = true
end
def start
setup_signal_handlers
spawn_workers
monitor_workers
end
private
def setup_signal_handlers
Signal.trap("TERM") { shutdown }
Signal.trap("INT") { shutdown }
Signal.trap("CHLD") { reap_workers }
end
def spawn_workers
@workers.times do
pid = fork do
worker_process
end
@worker_pids << pid
puts "Started worker #{pid}"
end
end
def worker_process
# Simulate request handling
while @running
puts "Worker #{Process.pid} handling request"
sleep rand(5)
end
rescue => e
puts "Worker #{Process.pid} error: #{e.message}"
ensure
puts "Worker #{Process.pid} shutting down"
end
def monitor_workers
while @running
sleep 1
respawn_failed_workers
end
end
def reap_workers
loop do
pid = Process.wait(-1, Process::WNOHANG)
break unless pid
@worker_pids.delete(pid)
puts "Reaped worker #{pid}"
end
rescue Errno::ECHILD
# No children to reap
end
def respawn_failed_workers
target_workers = @running ? @workers : 0
missing_workers = target_workers - @worker_pids.size
missing_workers.times do
pid = fork { worker_process }
@worker_pids << pid
puts "Respawned worker #{pid}"
end
end
def shutdown
puts "Shutting down server"
@running = false
@worker_pids.each do |pid|
Process.kill("TERM", pid)
end
Process.waitall
puts "Server shutdown complete"
exit
end
end
Background job processing systems use process forking to isolate job execution and prevent memory leaks from affecting long-running worker processes. Implement job timeouts and memory monitoring to restart workers that consume excessive resources.
# Background job processor with forking
class JobProcessor
def initialize
@jobs = []
@max_memory = 100_000 # KB
@job_timeout = 30 # seconds
end
def add_job(job)
@jobs << job
end
def process_jobs
@jobs.each { |job| process_job_forked(job) }
end
private
def process_job_forked(job)
pid = fork do
start_time = Time.now
begin
# Set up timeout
Signal.trap("ALRM") { raise "Job timeout" }
alarm(@job_timeout)
# Process job
puts "Processing job: #{job}"
result = perform_job(job)
puts "Job result: #{result}"
rescue => e
puts "Job failed: #{e.message}"
exit 1
ensure
alarm(0) # Cancel timeout
end
exit 0
end
# Monitor child process
monitor_child_process(pid, job)
end
def monitor_child_process(pid, job)
loop do
begin
finished_pid = Process.wait(pid, Process::WNOHANG)
if finished_pid
status = $?
if status.success?
puts "Job #{job} completed successfully"
else
puts "Job #{job} failed with status #{status.exitstatus}"
end
break
end
# Check memory usage
memory = get_process_memory(pid)
if memory > @max_memory
puts "Job #{job} exceeded memory limit (#{memory} KB)"
Process.kill("KILL", pid)
Process.wait(pid)
break
end
sleep 0.1
rescue Errno::ECHILD
puts "Job #{job} process disappeared"
break
end
end
end
def get_process_memory(pid)
`ps -o rss= -p #{pid}`.strip.to_i
rescue
0
end
def perform_job(job)
# Simulate job processing
case job
when /memory_intensive/
Array.new(200000) { rand(1000) } # Consume memory
when /cpu_intensive/
1000000.times { Math.sqrt(rand(1000)) } # Consume CPU
when /slow/
sleep 5
end
"Job #{job} processed at #{Time.now}"
end
def alarm(seconds)
Process.kill("ALRM", Process.pid) if seconds == 0
# Ruby's alarm is not available, simulate with timeout
end
end
# Usage example
processor = JobProcessor.new
processor.add_job("normal_task_1")
processor.add_job("memory_intensive_task")
processor.add_job("cpu_intensive_task")
processor.add_job("slow_task")
processor.process_jobs
Database connection management requires careful attention in forked processes because connections cannot be safely shared between processes. Close inherited connections in child processes and establish new connections to prevent connection conflicts and pool exhaustion.
# Database connection handling in forked processes
class DatabaseWorker
def initialize(db_config)
@db_config = db_config
@parent_connection = create_connection
end
def fork_worker(&block)
pid = fork do
# Close inherited connection
@parent_connection&.close
# Establish new connection for child
@worker_connection = create_connection
begin
instance_eval(&block)
ensure
@worker_connection&.close
end
exit
end
Process.wait(pid)
end
def execute_query(sql, params = [])
connection = @worker_connection || @parent_connection
connection.execute(sql, params)
end
private
def create_connection
# Simulate database connection creation
connection = Object.new
connection.define_singleton_method(:execute) do |sql, params|
puts "Executing: #{sql} with #{params} (PID: #{Process.pid})"
["result_#{rand(1000)}"]
end
connection.define_singleton_method(:close) do
puts "Closing connection (PID: #{Process.pid})"
end
connection
end
end
# Usage
worker = DatabaseWorker.new({host: "localhost", db: "test"})
# Parent process query
worker.execute_query("SELECT * FROM users", [1])
# Forked process queries
3.times do |i|
worker.fork_worker do
execute_query("SELECT * FROM orders WHERE user_id = ?", [i + 1])
execute_query("UPDATE users SET last_login = NOW() WHERE id = ?", [i + 1])
end
end
puts "All workers completed"
Reference
Core Process Methods
Method | Parameters | Returns | Description |
---|---|---|---|
fork { block } |
Block (optional) | Integer (parent), nil (child) |
Creates child process, executes block in child |
Process.spawn(cmd, *args, **opts) |
Command, arguments, options | Integer |
Spawns new process with command |
Process.wait(pid, flags=0) |
PID (Integer), flags (Integer) | Integer |
Waits for child process termination |
Process.wait2(pid, flags=0) |
PID (Integer), flags (Integer) | [Integer, Process::Status] |
Waits and returns PID with status |
Process.waitall |
None | Array |
Waits for all child processes |
Process.detach(pid) |
PID (Integer) | Thread |
Creates thread to wait for process |
Process Information Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Process.pid |
None | Integer |
Current process ID |
Process.ppid |
None | Integer |
Parent process ID |
Process.getpgrp |
None | Integer |
Current process group ID |
Process.getpgid(pid) |
PID (Integer) | Integer |
Process group ID for given PID |
Process.setpgrp |
None | Integer |
Sets current process as group leader |
Process.setsid |
None | Integer |
Creates new session and process group |
Signal Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Process.kill(signal, *pids) |
Signal (String/Integer), PIDs | Integer |
Sends signal to processes |
Signal.trap(signal) { block } |
Signal (String), block | Object |
Registers signal handler |
Signal.list |
None | Hash |
Maps signal names to numbers |
Resource Limit Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Process.getrlimit(resource) |
Resource symbol | [Integer, Integer] |
Returns soft and hard limits |
Process.setrlimit(resource, soft, hard=soft) |
Resource, limits | nil |
Sets resource limits |
Process.spawn Options
Option | Type | Description |
---|---|---|
:chdir |
String | Change working directory |
:env |
Hash | Environment variables (first argument) |
:in , :out , :err |
IO/String/Integer | Redirect standard streams |
:pgroup |
Boolean/Integer | Process group assignment |
:rlimit_* |
Array | Set resource limits |
:umask |
Integer | Set file creation mask |
:close_others |
Boolean | Close non-redirected file descriptors |
Process.wait Flags
Flag | Value | Description |
---|---|---|
Process::WNOHANG |
1 | Return immediately if no child available |
Process::WUNTRACED |
2 | Return for stopped children |
Common Signals
Signal | Number | Description |
---|---|---|
TERM |
15 | Termination request |
KILL |
9 | Force termination (cannot be trapped) |
INT |
2 | Interrupt (Ctrl+C) |
HUP |
1 | Hangup |
USR1 |
10 | User-defined signal 1 |
USR2 |
12 | User-defined signal 2 |
CHLD |
17 | Child status changed |
Resource Limit Constants
Constant | Description |
---|---|
:AS |
Address space size limit |
:CORE |
Core file size limit |
:CPU |
CPU time limit (seconds) |
:DATA |
Data segment size limit |
:FSIZE |
File size limit |
:MEMLOCK |
Locked memory limit |
:NOFILE |
File descriptor limit |
:NPROC |
Process limit |
:RSS |
Resident set size limit |
:STACK |
Stack size limit |
Process::Status Methods
Method | Returns | Description |
---|---|---|
#success? |
Boolean | True if process succeeded |
#exited? |
Boolean | True if process exited normally |
#exitstatus |
Integer | Exit status code |
#signaled? |
Boolean | True if terminated by signal |
#termsig |
Integer | Terminating signal number |
#stopped? |
Boolean | True if process stopped |
#stopsig |
Integer | Stop signal number |
#pid |
Integer | Process ID |
Environment Variables
Variable | Description |
---|---|
$$ |
Current process ID (global variable) |
$? |
Last child process status |
$CHILD_STATUS |
Alias for $? |
Error Classes
Exception | Description |
---|---|
Errno::ECHILD |
No child processes |
Errno::EINTR |
Interrupted system call |
Errno::ENOENT |
No such file or directory |
Errno::EACCES |
Permission denied |
Errno::EAGAIN |
Resource temporarily unavailable |