CrackedRuby CrackedRuby

Synchronous vs Asynchronous

Overview

Synchronous and asynchronous execution represent two distinct models for handling operations in software systems. Synchronous execution follows a blocking model where each operation completes before the next begins. The program waits for each task to finish, maintaining a predictable sequential flow. Asynchronous execution employs a non-blocking model where operations can initiate without waiting for previous operations to complete, allowing multiple tasks to progress concurrently.

The distinction matters most when dealing with I/O operations—network requests, file system access, database queries—where waiting for external resources dominates execution time. In synchronous code, a database query blocks the entire thread until results return. The program sits idle during this wait period. In asynchronous code, the program initiates the query and continues executing other work, processing the results when they arrive.

# Synchronous: blocks until complete
def fetch_user(id)
  response = HTTP.get("/users/#{id}")  # Blocks here
  JSON.parse(response.body)
end

user = fetch_user(1)  # Must wait for completion
process(user)         # Executes only after fetch completes
# Asynchronous: continues without waiting
def fetch_user_async(id)
  HTTP.get("/users/#{id}") do |response|  # Returns immediately
    user = JSON.parse(response.body)
    process(user)  # Executes when response arrives
  end
end

fetch_user_async(1)  # Returns immediately
do_other_work()      # Executes while request is in flight

The synchronous model maps naturally to sequential human reasoning. Code reads top-to-bottom, matching execution order. Debugging follows a linear path through the call stack. Error handling uses straightforward exception mechanisms. This simplicity makes synchronous code easier to write, test, and maintain for many scenarios.

The asynchronous model trades simplicity for efficiency. A single thread can manage thousands of concurrent I/O operations instead of blocking on each. Web servers handle multiple requests simultaneously without spawning threads for each connection. Applications remain responsive during long-running operations. This efficiency becomes critical for I/O-intensive systems where waiting time dominates processing time.

Ruby's evolution reflects this trade-off. Traditional Ruby code follows synchronous patterns, using threads for concurrency. Modern Ruby provides multiple asynchronous mechanisms: Fibers for cooperative multitasking, the async gem for structured concurrency, and libraries like EventMachine for event-driven programming. Each approach offers different trade-offs between simplicity and performance.

Key Principles

Synchronous execution operates on a blocking principle. When code calls a function, control transfers to that function. The caller waits until the function returns. This creates a strict ordering where operation N+1 cannot begin until operation N completes. The call stack grows as functions invoke other functions, shrinks as they return. At any moment, exactly one piece of code executes.

def process_order(order)
  validate_order(order)      # Step 1: blocks until complete
  charge_payment(order)      # Step 2: blocks until complete
  update_inventory(order)    # Step 3: blocks until complete
  send_confirmation(order)   # Step 4: blocks until complete
end

Each function in this chain must complete before the next begins. If charge_payment takes 2 seconds to contact a payment processor, the thread waits those 2 seconds. No other work happens during this wait. This guarantees ordering: inventory never updates before payment succeeds, confirmation never sends before inventory updates.

Asynchronous execution operates on a non-blocking principle. Operations return immediately, before their work completes. The system registers a callback or continuation that executes when the operation finishes. Multiple operations can be in-flight simultaneously. Control flow becomes non-linear: code after an async call executes before that call completes.

def process_order_async(order)
  validate_order_async(order) do |valid_order|
    charge_payment_async(valid_order) do |charged_order|
      update_inventory_async(charged_order) do |updated_order|
        send_confirmation_async(updated_order)
      end
    end
  end
end

This callback-based approach maintains ordering through nesting. Each operation's callback initiates the next step. The thread doesn't wait—it moves on to other work. When charge_payment_async receives a response 2 seconds later, its callback fires, triggering the next step.

Concurrency distinguishes parallelism from asynchrony. Parallel execution requires multiple CPU cores running code simultaneously. Asynchronous execution achieves concurrency on a single core by interleaving operations. When one operation waits for I/O, another runs. This works because I/O operations spend most of their time waiting for external systems, not consuming CPU cycles.

The event loop forms the foundation of asynchronous systems. This mechanism continuously checks for completed I/O operations and executes their callbacks. When no I/O is ready, the loop waits. When I/O completes, the loop dispatches the appropriate callback. This pattern enables thousands of concurrent operations with minimal overhead.

# Conceptual event loop
loop do
  ready_operations = check_for_completed_io()
  ready_operations.each do |operation|
    operation.callback.call(operation.result)
  end
  wait_for_next_io_event() if ready_operations.empty?
end

State management differs fundamentally between models. Synchronous code maintains state in local variables. The call stack preserves this state across function calls. When a function returns, its caller continues with its local variables intact. This automatic state preservation simplifies reasoning about program behavior.

Asynchronous code cannot rely on the call stack for state preservation. By the time a callback executes, the original call stack has unwound. State must be explicitly captured and passed to callbacks, stored in closures, or managed through promises and futures. This explicit state management increases complexity but enables fine-grained control over concurrent operations.

Error handling follows different paths. Synchronous code uses exceptions that propagate up the call stack. A try-catch block handles errors from any called function. The error path matches the call path. Asynchronous code requires explicit error callbacks or promise rejection handlers. Errors don't propagate automatically—each async operation needs error handling at its boundary.

Ruby Implementation

Ruby provides multiple mechanisms for asynchronous execution, each with distinct trade-offs. Native threads offer true parallelism but carry significant overhead. Fibers provide lightweight cooperative multitasking. The async gem delivers structured concurrency with minimal boilerplate. EventMachine pioneered event-driven Ruby programming.

Threads represent Ruby's traditional concurrency mechanism. Each thread gets its own stack and can block independently. The operating system schedules threads across CPU cores. Ruby's Global Interpreter Lock (GIL) prevents true parallel execution of Ruby code, but threads still provide value for I/O-bound operations.

threads = []

threads << Thread.new do
  response = HTTP.get('https://api.example.com/users')
  users = JSON.parse(response.body)
  users.each { |user| process_user(user) }
end

threads << Thread.new do
  response = HTTP.get('https://api.example.com/orders')
  orders = JSON.parse(response.body)
  orders.each { |order| process_order(order) }
end

threads.each(&:join)  # Wait for both to complete

While these threads don't execute Ruby code in parallel due to the GIL, they do allow concurrent I/O operations. When one thread blocks on HTTP.get, the other thread can execute. This makes threads effective for I/O-bound workloads despite the GIL.

Fibers provide cooperative multitasking without OS thread overhead. A fiber is a lightweight execution context that can suspend and resume. Unlike threads, fibers never preempt—they yield control explicitly. This eliminates race conditions while enabling concurrent I/O.

fiber1 = Fiber.new do
  puts "Fiber 1 starting"
  Fiber.yield "First yield"
  puts "Fiber 1 resuming"
  Fiber.yield "Second yield"
  puts "Fiber 1 finishing"
end

fiber2 = Fiber.new do
  puts "Fiber 2 starting"
  Fiber.yield "First yield"
  puts "Fiber 2 finishing"
end

puts fiber1.resume  # => "Fiber 1 starting" then "First yield"
puts fiber2.resume  # => "Fiber 2 starting" then "First yield"
puts fiber1.resume  # => "Fiber 1 resuming" then "Second yield"
puts fiber2.resume  # => "Fiber 2 finishing"

Fibers excel when building custom schedulers. The async gem builds on fibers to provide a complete asynchronous framework. It handles scheduling, I/O multiplexing, and error propagation automatically.

require 'async'
require 'async/http/internet'

Async do
  internet = Async::HTTP::Internet.new
  
  # These requests happen concurrently
  response1 = internet.get('https://api.example.com/users')
  response2 = internet.get('https://api.example.com/orders')
  
  users = JSON.parse(response1.read)
  orders = JSON.parse(response2.read)
  
  puts "Fetched #{users.length} users and #{orders.length} orders"
ensure
  internet.close
end

The async gem's Async block creates a reactor that schedules fiber execution. When a fiber awaits I/O, the reactor suspends it and runs another fiber. When I/O completes, the reactor resumes the waiting fiber. This provides the efficiency of asynchronous I/O with synchronous-looking code.

Async tasks enable structured concurrency. Parent tasks can spawn child tasks and wait for them to complete. Errors in child tasks propagate to parents. This maintains the benefits of exception-based error handling in asynchronous code.

require 'async'

Async do |task|
  results = task.async do
    [
      task.async { fetch_users },
      task.async { fetch_orders },
      task.async { fetch_products }
    ].map(&:wait)
  end.wait
  
  users, orders, products = results
  process_data(users, orders, products)
end

Each task.async call creates a new fiber. The parent task waits for all children to complete. If any child raises an exception, it propagates to the parent. This matches the familiar exception handling model from synchronous code.

EventMachine pioneered event-driven programming in Ruby before fibers gained traction. It implements a reactor pattern with callback-based APIs. While older than fiber-based approaches, EventMachine still appears in production systems.

require 'eventmachine'
require 'em-http-request'

EventMachine.run do
  http1 = EventMachine::HttpRequest.new('https://api.example.com/users').get
  http2 = EventMachine::HttpRequest.new('https://api.example.com/orders').get
  
  http1.callback do
    users = JSON.parse(http1.response)
    puts "Fetched #{users.length} users"
  end
  
  http1.errback do
    puts "User fetch failed: #{http1.error}"
  end
  
  http2.callback do
    orders = JSON.parse(http2.response)
    puts "Fetched #{orders.length} orders"
  end
  
  EventMachine.stop
end

EventMachine's callback style requires different thinking than synchronous code. Error handling splits into separate errback callbacks. State must be captured in closures. Coordinating multiple operations requires additional machinery like barriers or counters.

Ruby's standard library includes Thread::Queue and Thread::ConditionVariable for thread synchronization. These primitives enable producer-consumer patterns and coordinated access to shared resources.

queue = Thread::Queue.new

producer = Thread.new do
  10.times do |i|
    queue << i
    sleep 0.1
  end
  queue.close
end

consumer = Thread.new do
  while item = queue.pop
    puts "Processing #{item}"
    sleep 0.2
  end
end

producer.join
consumer.join

The queue handles synchronization automatically. When empty, pop blocks until an item arrives. When closed, pop returns nil to signal completion. This pattern works well for background job processing and data pipelines.

Practical Examples

Consider a web scraper that fetches data from multiple URLs. The synchronous approach processes URLs sequentially, waiting for each request to complete before starting the next.

require 'net/http'
require 'uri'
require 'json'

def scrape_synchronous(urls)
  results = []
  
  urls.each do |url|
    uri = URI(url)
    response = Net::HTTP.get_response(uri)
    
    if response.is_a?(Net::HTTPSuccess)
      results << JSON.parse(response.body)
    else
      results << { error: "Failed to fetch #{url}" }
    end
  end
  
  results
end

urls = [
  'https://api.example.com/data/1',
  'https://api.example.com/data/2',
  'https://api.example.com/data/3'
]

start_time = Time.now
results = scrape_synchronous(urls)
elapsed = Time.now - start_time

puts "Fetched #{results.length} URLs in #{elapsed.round(2)}s"
# If each request takes 1 second: ~3 seconds total

With three URLs taking one second each, synchronous execution takes roughly three seconds. Each request blocks until it completes. The CPU sits idle during network waits. An asynchronous version overlaps these waits.

require 'async'
require 'async/http/internet'

def scrape_async(urls)
  Async do
    internet = Async::HTTP::Internet.new
    
    tasks = urls.map do |url|
      Async do
        response = internet.get(url)
        JSON.parse(response.read)
      rescue => e
        { error: "Failed to fetch #{url}: #{e.message}" }
      end
    end
    
    results = tasks.map(&:wait)
    internet.close
    results
  end.wait
end

start_time = Time.now
results = scrape_async(urls)
elapsed = Time.now - start_time

puts "Fetched #{results.length} URLs in #{elapsed.round(2)}s"
# Same three 1-second requests: ~1 second total

The asynchronous version initiates all requests concurrently. While waiting for network responses, the reactor switches between tasks. Total time approximates the slowest individual request rather than their sum. This 3x improvement scales with the number of concurrent operations.

Database queries demonstrate similar patterns. An application might need user data, their orders, and their preferences—three independent queries. Synchronous execution chains them sequentially.

class UserDashboard
  def initialize(user_id)
    @user_id = user_id
  end
  
  def load_data
    user = User.find(@user_id)           # Query 1: 50ms
    orders = Order.where(user_id: @user_id)  # Query 2: 100ms
    preferences = Preference.find_by(user_id: @user_id)  # Query 3: 30ms
    
    {
      user: user,
      orders: orders,
      preferences: preferences
    }
  end
end

# Total time: 50ms + 100ms + 30ms = 180ms

These queries don't depend on each other. Loading them concurrently reduces latency.

require 'async'

class AsyncUserDashboard
  def initialize(user_id)
    @user_id = user_id
  end
  
  def load_data
    Async do |task|
      user_task = task.async { User.find(@user_id) }
      orders_task = task.async { Order.where(user_id: @user_id) }
      prefs_task = task.async { Preference.find_by(user_id: @user_id) }
      
      {
        user: user_task.wait,
        orders: orders_task.wait,
        preferences: prefs_task.wait
      }
    end.wait
  end
end

# Total time: max(50ms, 100ms, 30ms) = 100ms

Concurrent execution reduces latency from 180ms to 100ms—the duration of the slowest query. The database handles all three queries in parallel. This pattern applies to any independent operations: API calls, file reads, cache lookups.

Background job processing shows when synchronous execution works better. Consider a job that processes uploaded images: validates format, resizes to multiple dimensions, uploads to storage, updates database. Each step depends on the previous step's output.

class ImageProcessor
  def process(image_file)
    # Step 1: Validate
    raise "Invalid format" unless valid_format?(image_file)
    
    # Step 2: Read
    image_data = File.read(image_file)
    
    # Step 3: Process
    thumbnail = resize_image(image_data, 150, 150)
    medium = resize_image(image_data, 500, 500)
    large = resize_image(image_data, 1200, 1200)
    
    # Step 4: Upload
    thumbnail_url = upload_to_storage(thumbnail, 'thumbnail')
    medium_url = upload_to_storage(medium, 'medium')
    large_url = upload_to_storage(large, 'large')
    
    # Step 5: Record
    Image.create!(
      thumbnail_url: thumbnail_url,
      medium_url: medium_url,
      large_url: large_url
    )
  end
end

Steps 1, 2, 4, and 5 form a strict sequence. Step 3 contains parallelizable work—three independent resize operations. Step 4 also contains parallelizable uploads. A hybrid approach applies asynchronous execution only where beneficial.

require 'async'

class HybridImageProcessor
  def process(image_file)
    raise "Invalid format" unless valid_format?(image_file)
    image_data = File.read(image_file)
    
    # Parallel resizing
    sizes = Async do |task|
      thumb_task = task.async { resize_image(image_data, 150, 150) }
      med_task = task.async { resize_image(image_data, 500, 500) }
      lg_task = task.async { resize_image(image_data, 1200, 1200) }
      
      [thumb_task.wait, med_task.wait, lg_task.wait]
    end.wait
    
    # Parallel uploads
    urls = Async do |task|
      thumb_task = task.async { upload_to_storage(sizes[0], 'thumbnail') }
      med_task = task.async { upload_to_storage(sizes[1], 'medium') }
      lg_task = task.async { upload_to_storage(sizes[2], 'large') }
      
      [thumb_task.wait, med_task.wait, lg_task.wait]
    end.wait
    
    Image.create!(
      thumbnail_url: urls[0],
      medium_url: urls[1],
      large_url: urls[2]
    )
  end
end

This hybrid approach maintains sequential logic where necessary while parallelizing independent operations. Resize operations and uploads both execute concurrently, reducing total processing time without complicating the overall flow.

Rate-limited API clients show another asynchronous pattern. Many APIs limit request rates: 100 requests per minute, 10 concurrent connections. A synchronous client processes requests sequentially, leaving capacity unused.

require 'async'
require 'async/semaphore'
require 'async/http/internet'

class RateLimitedClient
  def initialize(max_concurrent: 10)
    @semaphore = Async::Semaphore.new(max_concurrent)
  end
  
  def fetch_all(urls)
    Async do
      internet = Async::HTTP::Internet.new
      
      tasks = urls.map do |url|
        Async do
          @semaphore.async do
            response = internet.get(url)
            JSON.parse(response.read)
          end.wait
        end
      end
      
      results = tasks.map(&:wait)
      internet.close
      results
    end.wait
  end
end

client = RateLimitedClient.new(max_concurrent: 10)
results = client.fetch_all(Array.new(100) { |i| "https://api.example.com/items/#{i}" })

The semaphore limits concurrency to 10 simultaneous requests. When 10 requests are in-flight, additional requests wait for slots to free. This maximizes throughput while respecting API limits. A synchronous version would take 10x longer, processing one request at a time.

Design Considerations

Choosing between synchronous and asynchronous execution depends on workload characteristics, system requirements, and complexity tolerance. I/O-bound operations benefit most from asynchronous execution. CPU-bound operations gain little from async without true parallelism. Mixed workloads require careful analysis.

I/O-bound workloads spend most time waiting for external resources: databases, APIs, file systems, networks. During these waits, CPU cores sit idle in synchronous code. Asynchronous execution fills this idle time with other work. The efficiency gain correlates with the ratio of wait time to processing time. If an operation waits 95% of its duration, asynchronous execution can theoretically achieve 20x higher throughput on the same hardware.

# I/O-bound: 1ms CPU + 99ms network wait
def fetch_data(id)
  prepare_request(id)      # 1ms CPU
  HTTP.get("/data/#{id}")  # 99ms waiting
end

# Synchronous: 100 requests = 10,000ms (10 seconds)
# Asynchronous: 100 concurrent requests = ~100ms + overhead

CPU-bound workloads spend most time computing: image processing, encryption, parsing, algorithms. These operations use CPU cores continuously. Asynchronous execution on a single core provides no benefit—one core can only execute one computation at a time. These workloads need parallelism, not asynchrony.

# CPU-bound: 100ms computation + 1ms I/O
def process_data(data)
  result = expensive_computation(data)  # 100ms CPU
  store_result(result)                  # 1ms I/O
end

# Synchronous: 100 requests = 10,100ms
# Asynchronous single-core: ~10,100ms (no benefit)
# Parallel multi-core: 10,100ms / core_count

Response time requirements influence the decision. Interactive applications need low latency. Users expect web pages to load in under a second, UI interactions to respond in milliseconds. Asynchronous execution reduces latency by overlapping operations. Batch processes care about throughput, not individual operation latency. Processing 1 million records overnight tolerates high per-record latency if total throughput remains high.

Scale considerations matter. Systems handling hundreds of concurrent operations need asynchronous execution to avoid thread exhaustion. A web server with 10,000 concurrent connections cannot allocate one thread per connection—memory and context switching overhead become prohibitive. Asynchronous servers handle thousands of connections with a handful of threads. Small-scale systems with dozens of concurrent operations can use simpler synchronous threading.

Complexity tolerance varies by team and project. Asynchronous code increases complexity: non-linear control flow, explicit state management, callback handling, error propagation across async boundaries. Teams experienced with async patterns absorb this complexity more easily. Projects with tight deadlines or junior developers may favor synchronous simplicity despite performance costs.

Error recovery strategies differ. Synchronous code uses exceptions that unwind the stack to the nearest handler. Async code requires explicit error handling at each async boundary. For systems requiring sophisticated error recovery—retries, partial failures, compensating transactions—async error handling provides fine-grained control. For systems with simple error handling, synchronous exceptions suffice.

Resource constraints affect the decision. Memory-limited systems benefit from asynchronous execution's efficient resource use. Thread-per-connection models consume megabytes per thread. Async models share threads across operations, reducing memory footprint. CPU-limited systems gain nothing from async single-threaded execution—threads or processes provide actual parallelism.

Testing complexity increases with asynchronous code. Synchronous code tests execute sequentially, reproducing bugs consistently. Async tests deal with timing issues, race conditions, and non-deterministic failures. Tests must coordinate async operations, handle callbacks, and manage test fixtures across async boundaries. This testing burden must be weighed against performance benefits.

Third-party library support influences feasibility. Ruby's ecosystem includes both sync and async libraries. Database drivers, HTTP clients, and queue systems offer async variants. Projects requiring libraries without async support cannot fully adopt async patterns. Mixing sync and async code creates complexity as sync calls block the entire reactor.

The maintenance burden extends beyond initial development. Asynchronous systems require monitoring for different metrics: reactor utilization, task queue depth, fiber counts. Debugging async systems differs from sync: stack traces span multiple fibers, errors surface asynchronously, state scatters across closures. Operations teams must develop new skills for async system maintenance.

Migration costs matter for existing systems. Converting synchronous code to asynchronous requires extensive refactoring. Every I/O operation needs async variants. Error handling changes. Tests need updates. Dependencies require async-compatible versions. This migration investment must be justified by performance gains or new capabilities.

Performance Considerations

Asynchronous execution delivers performance benefits for I/O-bound workloads through higher concurrency and better resource utilization. The magnitude of improvement depends on the I/O-to-CPU ratio, the degree of available concurrency, and overhead from async coordination.

Throughput measures operations completed per unit time. Synchronous systems achieve throughput of operations_per_second = threads / average_operation_time. With 10 threads and 100ms operations, throughput reaches 100 operations/second. Asynchronous systems achieve operations_per_second = 1 / processing_time (ignoring I/O wait). With 1ms processing time and 99ms I/O wait, async throughput reaches 1,000 operations/second on a single core.

require 'benchmark'

# Synchronous benchmark
def sync_benchmark(count)
  Benchmark.measure do
    count.times do |i|
      sleep(0.01)  # Simulates 10ms I/O
      compute(i)    # 1ms CPU work
    end
  end
end

# Asynchronous benchmark
def async_benchmark(count)
  Benchmark.measure do
    Async do |task|
      tasks = count.times.map do |i|
        task.async do
          sleep(0.01)
          compute(i)
        end
      end
      tasks.each(&:wait)
    end.wait
  end
end

# Results for 100 operations:
# Synchronous: ~1.1 seconds (100 * 11ms)
# Asynchronous: ~0.11 seconds (overlapped I/O)

Latency measures time from operation start to completion. For single operations, sync and async latencies match—one operation takes the same time regardless of execution model. For sets of operations, async reduces total latency by overlapping work. Three 100ms sync operations take 300ms sequentially, 100ms concurrently.

Async overhead comes from context switching, callback invocation, and scheduler bookkeeping. Fiber context switches cost microseconds—negligible compared to millisecond I/O operations. For extremely fast operations (microseconds), async overhead can exceed operation time, making sync execution faster. The breakeven point typically falls around 10-100 microseconds per operation.

require 'async'
require 'benchmark'

def fast_operation
  x = 1
  100.times { x = x * 2 / 2 }
  x
end

# Sync: operation time only
sync_time = Benchmark.measure { 1000.times { fast_operation } }

# Async: operation + overhead
async_time = Benchmark.measure do
  Async do |task|
    tasks = 1000.times.map { task.async { fast_operation } }
    tasks.each(&:wait)
  end.wait
end

# For very fast operations, async overhead dominates
# Sync: 0.001s, Async: 0.005s (overhead exceeds benefit)

Memory consumption differs significantly. Each thread requires a stack—typically 1MB on Linux. 1,000 threads consume 1GB just for stacks. Fibers use kilobyte-sized stacks, enabling 1,000 concurrent operations in single-digit megabytes. This memory efficiency allows much higher concurrency.

Scalability limits differ. Thread-based systems hit thread count limits—tens of thousands before scheduler overhead dominates. Async systems scale to millions of concurrent operations. Event-driven servers like Node.js and async Ruby applications routinely handle 100,000+ concurrent connections on modest hardware.

CPU utilization patterns reveal efficiency differences. Synchronous I/O-bound code shows low CPU usage—cores idle during waits. Asynchronous code shows higher CPU utilization as one core services many operations. This apparent difference masks the reality: async uses the same total CPU time more efficiently. A synchronous system might show 5% CPU usage with 95% idle time, while async shows 80% CPU usage by filling idle time with useful work.

Reactor saturation represents async performance limits. A single-core reactor can process operations until CPU becomes the bottleneck. If operations require 1ms CPU time each, peak throughput reaches 1,000 operations/second regardless of concurrency. Adding more concurrent operations beyond this point provides no benefit. Multi-reactor architectures solve this by running multiple reactors across CPU cores.

require 'async'
require 'async/reactor'

# Single reactor: all work on one core
Async do |task|
  1000.times { task.async { cpu_bound_work } }
end.wait

# Multi-reactor: distribute across cores
reactors = 4.times.map do |i|
  Thread.new do
    Async do |task|
      250.times { task.async { cpu_bound_work } }
    end.wait
  end
end
reactors.each(&:join)

Connection pooling affects async performance. Database connection pools typically size to the number of threads. Async systems need fewer connections since one thread handles many operations. However, operations still block during database I/O. Pool size must balance concurrency against database server load. Too few connections serialize operations, too many overwhelm the database.

Backpressure handling prevents async systems from overwhelming downstream services. When operations arrive faster than they can be processed, queues grow unbounded, consuming memory and increasing latency. Proper async systems implement backpressure: slowing or rejecting new work when capacity is reached.

require 'async'
require 'async/semaphore'

class BackpressureHandler
  def initialize(max_concurrent: 100, queue_limit: 1000)
    @semaphore = Async::Semaphore.new(max_concurrent)
    @queue = []
    @queue_limit = queue_limit
  end
  
  def process(item)
    raise "Queue full" if @queue.length >= @queue_limit
    
    @queue << item
    
    Async do
      @semaphore.async do
        item = @queue.shift
        handle_item(item)
      end
    end
  end
end

Profiling async systems requires specialized tools. Traditional profilers track CPU time per function, missing I/O wait time. Async profilers must track reactor cycles, pending task counts, and I/O latencies. Ruby's TracePoint API enables custom profiling for async code patterns.

Common Pitfalls

Callback hell creates deeply nested, unreadable code. Each async operation requires a callback for its result. Chaining operations creates nested callbacks that grow rightward, obscuring logic and complicating error handling.

# Callback hell example
fetch_user(user_id) do |user|
  fetch_orders(user.id) do |orders|
    orders.each do |order|
      fetch_order_items(order.id) do |items|
        items.each do |item|
          fetch_product(item.product_id) do |product|
            process_product(product, item, order, user)
          end
        end
      end
    end
  end
end

This pattern makes code hard to read, test, and modify. Modern async approaches solve this with promises, async/await syntax, or structured concurrency. The async gem's fiber-based approach maintains sequential code structure.

# Structured concurrency avoids nesting
Async do
  user = fetch_user(user_id)
  orders = fetch_orders(user.id)
  
  orders.each do |order|
    items = fetch_order_items(order.id)
    
    items.each do |item|
      product = fetch_product(item.product_id)
      process_product(product, item, order, user)
    end
  end
end

Blocking operations inside async contexts defeat the purpose of asynchronous execution. When async code calls a blocking operation, the entire reactor blocks. All other operations stall until the blocking call completes.

require 'async'

# BAD: Blocking call inside async
Async do |task|
  task.async do
    # This blocks the entire reactor
    result = RestClient.get('https://api.example.com/data')
    process(result)
  end
  
  task.async do
    # This can't run while the first task blocks
    other_work
  end
end

The solution requires async-compatible libraries. Using Net::HTTP in an async block blocks the reactor. Using Async::HTTP::Internet maintains asynchrony. Library compatibility critically affects async system performance.

Error handling across async boundaries requires explicit handling at each point. Exceptions in callbacks don't propagate to the original caller—the call stack has unwound. Unhandled exceptions in async operations can silently fail or crash the reactor.

# BAD: Unhandled async errors
Async do |task|
  task.async do
    data = fetch_data  # Might raise exception
    process(data)
  end
  # Exception lost - no handler
end

# GOOD: Explicit error handling
Async do |task|
  task.async do
    begin
      data = fetch_data
      process(data)
    rescue => e
      log_error(e)
      handle_failure
    end
  end
end

The async gem propagates exceptions from child tasks to parent tasks when using wait. Tasks that never call wait lose their exceptions.

Race conditions emerge when multiple async operations access shared state. Without explicit synchronization, operations interleave unpredictably, corrupting data or producing incorrect results.

# BAD: Race condition
counter = 0

Async do |task|
  100.times do
    task.async do
      # Read-modify-write race
      temp = counter
      counter = temp + 1
    end
  end
end.wait

puts counter  # Expected: 100, Actual: varies (e.g., 73)

Ruby's GIL prevents some races but not all. Operations that yield to the scheduler can be interrupted, creating races. Explicit synchronization using mutexes or atomic operations prevents races.

# GOOD: Synchronized access
counter = 0
mutex = Mutex.new

Async do |task|
  100.times do
    task.async do
      mutex.synchronize do
        counter += 1
      end
    end
  end
end.wait

puts counter  # Always 100

Memory leaks occur when async operations hold references longer than necessary. Closures capture context, preventing garbage collection. Long-lived callbacks accumulate memory until the system runs out.

# BAD: Memory leak
def register_callbacks
  data = Array.new(1_000_000) { rand }  # Large array
  
  EventMachine.add_periodic_timer(1) do
    # Closure captures 'data', preventing GC
    puts "Periodic work: #{data.length}"
  end
end

Careful scope management prevents leaks. Limit closure scope to necessary variables. Clear references when operations complete.

Starvation happens when high-priority operations monopolize the reactor. Lower-priority operations never execute, causing timeouts or system unresponsiveness.

# BAD: CPU-bound work starves other tasks
Async do |task|
  task.async { expensive_computation }  # Runs indefinitely
  task.async { important_io_operation } # Never gets scheduled
end

Yielding periodically in CPU-bound operations allows the scheduler to service other tasks. Alternatively, offload CPU-bound work to thread pools.

Timeout handling requires explicit implementation in async code. Synchronous operations can use timeouts at the I/O layer. Async operations need application-level timeout logic.

require 'async'
require 'async/clock'

def fetch_with_timeout(url, timeout: 5)
  Async do |task|
    result = nil
    
    timeout_task = task.async do
      task.sleep(timeout)
      :timeout
    end
    
    fetch_task = task.async do
      internet.get(url)
    end
    
    result = task.wait_any([timeout_task, fetch_task])
    
    if result == :timeout
      raise Timeout::Error, "Operation exceeded #{timeout}s"
    end
    
    result
  end.wait
end

Context propagation fails when async operations lose request context. In web applications, request-scoped data (user ID, trace IDs, database connections) must propagate to async operations.

# BAD: Context lost
class ApplicationController
  def show
    @current_user = User.find(session[:user_id])
    
    Async do
      # @current_user not available here
      fetch_data_for_user(@current_user)
    end
  end
end

Solutions include passing context explicitly, using fiber-local variables, or context propagation libraries.

Reference

Execution Model Comparison

Characteristic Synchronous Asynchronous
Control Flow Sequential, blocking Non-blocking, callback/continuation-based
Concurrency Requires threads Single-threaded concurrency possible
Resource Usage One thread per operation Many operations per thread
Memory Overhead High (thread stacks) Low (fiber stacks)
Code Complexity Lower, linear flow Higher, non-linear flow
Error Handling Exception propagation Explicit callback/promise error handling
Debugging Stack traces show call chain Callbacks fragment stack traces
Best For CPU-bound, low concurrency I/O-bound, high concurrency

Ruby Async Mechanisms

Mechanism Concurrency Model Complexity Use Case
Threads Preemptive, parallel I/O Medium I/O parallelism, legacy code
Fibers Cooperative, manual control High Custom schedulers, coroutines
Async gem Structured concurrency Low Modern async applications
EventMachine Event-driven callbacks High Legacy event-driven systems
Thread::Queue Producer-consumer Low Background job processing

Performance Characteristics

Operation Type Sync Performance Async Performance Improvement Factor
I/O-bound (95% wait) Operations per thread Limited by CPU Up to 20x
I/O-bound (50% wait) Operations per thread Limited by CPU Up to 2x
CPU-bound Operations per core No benefit 1x
Mixed workload Varies Varies 2-5x

Common Async Patterns

Pattern Description Implementation
Fire and Forget Start operation without waiting task.async without wait
Wait All Start multiple, wait for all tasks.map(wait)
Wait Any Start multiple, wait for first task.wait_any
Rate Limiting Control concurrent operation count Async::Semaphore
Timeout Cancel operations exceeding duration task.with_timeout
Backpressure Reject work when overloaded Queue size limits
Circuit Breaker Stop attempts after failures Failure counting

Decision Criteria

Factor Choose Synchronous When Choose Asynchronous When
Workload CPU-bound operations I/O-bound operations
Scale Low concurrency needs High concurrency needs
Latency Individual operation speed matters Total throughput matters
Complexity Team prefers simplicity Team handles async complexity
Libraries Sync libraries only available Async libraries available
Resources Memory abundant, threads acceptable Memory constrained
Testing Simple test requirements Complex test infrastructure exists

Async Gem Common Operations

Operation Code Pattern Purpose
Basic Task Async do work end Create async context
Concurrent Tasks task.async do work end Spawn concurrent operation
Wait for Completion task.wait Block until task completes
Wait Multiple tasks.map(wait) Wait for all tasks
Wait Any task.wait_any(tasks) Wait for first completion
Timeout task.with_timeout(seconds) Limit operation duration
Barrier barrier.wait Synchronize multiple tasks
Semaphore semaphore.async do work end Limit concurrency

Error Handling Patterns

Scenario Synchronous Approach Asynchronous Approach
Single Operation begin rescue end Wrap async block with begin rescue
Multiple Operations Rescue propagates up task.wait propagates exceptions
Partial Failures All-or-nothing with transactions Handle each task result separately
Retries Loop with retry counter Recursive async with retry limit
Timeouts Built into I/O libraries Implement with task.with_timeout
Cleanup Ensure blocks Ensure blocks in async context

Thread Safety Considerations

Resource Type Synchronization Needed Approach
Shared Counter Yes Mutex or Atomic
Instance Variables Yes if multiple fibers Mutex or fiber-local
Class Variables Yes Mutex
Constants No Read-only safe
Local Variables No Fiber-scoped
Thread-Local Variables No per thread Use fiber-local
Fiber-Local Variables No per fiber Built-in isolation