CrackedRuby logo

CrackedRuby

fork and Process Management

Ruby fork and Process Management for creating child processes, handling signals, and managing system-level concurrency.

Concurrency and Parallelism Process-based Parallelism
6.5.1

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