CrackedRuby CrackedRuby

Overview

The event loop model represents a concurrency architecture where a single thread continuously monitors and processes events from a queue. When an I/O operation completes or an event occurs, the associated callback executes in the main thread. This contrasts with thread-per-connection models where each concurrent operation requires a separate thread.

The model originated in graphical user interface systems where a main loop processed user input events. Node.js popularized the event loop for server-side applications, demonstrating that a single-threaded event loop could handle thousands of concurrent connections efficiently. The architecture excels at I/O-bound workloads where operations spend most time waiting for external resources like databases, file systems, or network responses.

In an event loop system, when code initiates an I/O operation, the system registers a callback and immediately returns control to the event loop. The operation continues in the background (typically handled by the operating system kernel). When the operation completes, the system places an event in the queue. The event loop picks up this event and executes the registered callback with the operation's result.

# Conceptual event loop structure
loop do
  event = event_queue.pop
  
  if event.type == :io_complete
    callback = registered_callbacks[event.id]
    callback.call(event.result)
  elsif event.type == :timer_expired
    timer_callback = timer_registry[event.timer_id]
    timer_callback.call
  end
end

The event loop model addresses the problem of scalability under high concurrency. Traditional thread-per-connection models consume significant memory (each thread typically requires 1-2 MB of stack space) and incur context-switching overhead. An event loop handles thousands of connections with minimal memory and no thread context switches for I/O operations.

Key Principles

The event loop operates on several foundational concepts that define its behavior and capabilities.

Single-Threaded Execution: All application code executes in a single thread. The event loop processes one event at a time, executing its callback to completion before moving to the next event. This eliminates race conditions in application code and removes the need for locks or synchronization primitives. The single-threaded nature means blocking operations halt all event processing.

Non-Blocking I/O: The system uses operating system features to perform I/O operations without blocking the main thread. On Unix systems, this involves mechanisms like epoll (Linux), kqueue (BSD/macOS), or select (POSIX). When code initiates an I/O operation, the system immediately returns a promise or registers a callback. The actual I/O happens asynchronously, notifying the event loop upon completion.

# Non-blocking read operation concept
def read_file_async(path, callback)
  # Register the read operation with the system
  io_handle = system.async_read(path)
  
  # Register callback for when read completes
  event_loop.register_callback(io_handle) do |data|
    callback.call(data)
  end
  
  # Return immediately - don't wait for read to complete
end

read_file_async('/data/file.txt') do |content|
  puts "File content: #{content}"
end

# Execution continues immediately
puts "Read operation started"

Event Queue: The queue stores events representing completed operations or triggered conditions. Each event contains metadata about what occurred and any associated data. The event loop continuously checks this queue, processing events in order. Event priorities can influence processing order in some implementations.

Run-to-Completion: Once the event loop invokes a callback, that callback runs to completion without interruption. No other callback executes until the current one finishes. This guarantees that callback code never experiences concurrent execution, simplifying reasoning about state. The trade-off is that long-running callbacks block all other event processing.

# Run-to-completion demonstration
event_loop.register_event do
  puts "Event 1 start"
  sleep(5) # Blocks event loop for 5 seconds
  puts "Event 1 end"
end

event_loop.register_event do
  puts "Event 2 start" # Won't run until Event 1 completes
  puts "Event 2 end"
end

Callback Registration: Applications register functions (callbacks) to execute when specific events occur. The event loop maintains a registry mapping event types or identifiers to their callbacks. When an event arrives, the loop looks up and invokes the corresponding callback.

Cooperative Multitasking: The event loop model implements cooperative rather than preemptive multitasking. Application code must explicitly yield control back to the event loop. Long computations must break into smaller chunks, yielding between chunks. The event loop cannot preemptively interrupt running code.

Phase-Based Processing: Many event loop implementations divide each loop iteration into phases. Common phases include timers, I/O callbacks, idle/prepare hooks, I/O polling, check handlers, and close callbacks. Each phase processes its specific event types before moving to the next phase.

Ruby Implementation

Ruby's traditional threading model differs from JavaScript's event loop, but several libraries and recent Ruby versions provide event loop capabilities.

EventMachine: This library implements a reactor pattern event loop for Ruby. EventMachine provides an event-driven I/O framework suitable for building scalable network applications.

require 'eventmachine'

EM.run do
  # Schedule a timer
  EM.add_timer(2) do
    puts "Timer fired after 2 seconds"
  end
  
  # Periodic timer
  EM.add_periodic_timer(1) do
    puts "Periodic tick"
  end
  
  # Defer blocking operation
  operation = proc {
    # Runs in thread pool
    sleep(1)
    "Result from blocking operation"
  }
  
  callback = proc { |result|
    # Runs in event loop thread
    puts result
    EM.stop
  }
  
  EM.defer(operation, callback)
end

EventMachine creates a singleton event loop accessed through the EM module. The EM.run method starts the event loop, blocking until EM.stop is called. The loop processes timers, I/O events, and deferred operations.

Async Gem: A modern asynchronous I/O framework that uses Ruby's Fiber scheduler for cooperative multitasking.

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

Async do
  internet = Async::HTTP::Internet.new
  
  # Multiple concurrent requests
  responses = Async::Barrier.new
  
  urls = [
    'https://example.com',
    'https://example.org',
    'https://example.net'
  ]
  
  urls.each do |url|
    responses.async do
      response = internet.get(url)
      puts "#{url}: #{response.status}"
      response.close
    end
  end
  
  # Wait for all responses
  responses.wait
ensure
  internet&.close
end

The Async gem wraps operations in fibers, which can suspend and resume. When an I/O operation would block, the fiber suspends and the event loop switches to another fiber. This provides the concurrency benefits of an event loop while maintaining more readable sequential code.

Fiber Scheduler (Ruby 3.0+): Ruby 3.0 introduced a Fiber scheduler interface that allows gems to provide non-blocking behavior transparently. When a fiber scheduler is active, blocking operations automatically become non-blocking.

require 'async'

def fetch_data(url)
  # This appears blocking but becomes non-blocking with scheduler
  Net::HTTP.get(URI(url))
end

Async do |task|
  # Both fetches run concurrently
  task1 = task.async { fetch_data('https://api.example.com/data1') }
  task2 = task.async { fetch_data('https://api.example.com/data2') }
  
  result1 = task1.wait
  result2 = task2.wait
  
  puts "Retrieved #{result1.length + result2.length} bytes"
end

The fiber scheduler hooks into Ruby's I/O operations. When code calls a blocking method like read or sleep, the scheduler can intercept this call and suspend the fiber instead of blocking the thread. The scheduler registers interest in the I/O completion, allowing other fibers to run.

Implementing a Basic Event Loop: Understanding the mechanics through a simplified implementation.

class SimpleEventLoop
  def initialize
    @callbacks = []
    @timers = []
    @running = false
  end
  
  def schedule(&block)
    @callbacks << block
  end
  
  def add_timer(delay, &block)
    @timers << {
      execute_at: Time.now + delay,
      callback: block
    }
  end
  
  def run
    @running = true
    
    while @running
      # Process ready callbacks
      until @callbacks.empty?
        callback = @callbacks.shift
        callback.call
      end
      
      # Process expired timers
      now = Time.now
      @timers.reject! do |timer|
        if timer[:execute_at] <= now
          timer[:callback].call
          true # Remove from array
        else
          false # Keep in array
        end
      end
      
      # Small sleep to prevent busy waiting
      sleep(0.01) if @callbacks.empty? && @timers.empty?
      
      # Exit if no more work
      break if @callbacks.empty? && @timers.empty?
    end
  end
  
  def stop
    @running = false
  end
end

loop = SimpleEventLoop.new

loop.schedule do
  puts "First callback"
end

loop.add_timer(1) do
  puts "Timer fired"
  loop.stop
end

loop.run

This simplified implementation demonstrates the core concept: maintaining queues of callbacks and timers, checking for ready work, and executing callbacks in sequence.

Nio4r Integration: Ruby's nio4r gem provides a cross-platform selector that can monitor multiple I/O objects.

require 'nio'

selector = NIO::Selector.new

# Monitor a socket for readability
server = TCPServer.new(3000)
monitor = selector.register(server, :r)

loop do
  # Wait for events, timeout after 1 second
  selector.select(1) do |monitor|
    if monitor.readable?
      client = server.accept
      puts "Client connected: #{client.peeraddr}"
      
      # Could register client for reading
      # client_monitor = selector.register(client, :r)
      
      client.close
    end
  end
end

The nio4r gem underlies many Ruby event loop implementations, providing the low-level I/O multiplexing required for efficient non-blocking I/O.

Implementation Approaches

Different event loop implementations make distinct architectural choices affecting performance and capabilities.

Single-Threaded Reactor: The classic event loop pattern runs entirely in one thread. All application code, callbacks, and event processing occur in this thread. The operating system's I/O multiplexing facility (epoll, kqueue) notifies the event loop when I/O operations complete.

This approach maximizes simplicity and eliminates concurrency issues in application code. The limitation is that CPU-intensive work blocks all event processing. JavaScript in browsers and Node.js follow this model.

# Single-threaded reactor pseudocode
class SingleThreadedReactor
  def run
    loop do
      # Wait for I/O events (blocking call)
      ready_events = io_multiplexer.wait
      
      # Process each ready event
      ready_events.each do |event|
        callback = callbacks[event.identifier]
        callback.call(event.data)
      end
      
      # Process timers
      process_expired_timers
      
      # Process scheduled callbacks
      process_scheduled_callbacks
    end
  end
end

Thread Pool Hybrid: This variant maintains an event loop in the main thread but uses a thread pool for blocking operations. When code initiates a blocking operation, the event loop dispatches it to a worker thread. When the operation completes, the worker thread queues a callback event for the main event loop.

EventMachine's EM.defer implements this pattern. The main event loop remains responsive while blocking work executes in parallel.

class ThreadPoolReactor
  def initialize(pool_size: 4)
    @event_queue = Queue.new
    @work_queue = Queue.new
    @workers = pool_size.times.map { create_worker }
  end
  
  def create_worker
    Thread.new do
      loop do
        work = @work_queue.pop
        result = work[:operation].call
        
        # Post result back to event loop
        @event_queue.push({
          type: :completion,
          callback: work[:callback],
          result: result
        })
      end
    end
  end
  
  def defer(operation, &callback)
    @work_queue.push({
      operation: operation,
      callback: callback
    })
  end
  
  def run
    loop do
      event = @event_queue.pop
      event[:callback].call(event[:result])
    end
  end
end

Multi-Process Event Loop: This approach runs multiple event loop processes, each handling a subset of connections. A load balancer distributes incoming connections across processes. Each process runs its own event loop, enabling utilization of multiple CPU cores.

# Multi-process event loop
def start_worker
  EventMachine.run do
    EventMachine.start_server('0.0.0.0', 3000, ConnectionHandler)
  end
end

# Fork worker processes
workers = 4.times.map do
  fork do
    start_worker
  end
end

# Wait for workers
workers.each { |pid| Process.wait(pid) }

This strategy provides parallelism for CPU-bound work while maintaining the event loop's benefits for I/O-bound operations within each process. Communication between processes requires explicit mechanisms like pipes or message queues.

Fiber-Based Event Loop: Uses fibers (lightweight coroutines) to achieve concurrency within a single thread. When a fiber would block on I/O, it suspends and another fiber resumes. The event loop scheduler manages fiber execution.

Ruby's Async gem demonstrates this approach. Fibers provide more natural control flow than callbacks while maintaining single-threaded execution.

# Fiber-based event loop concept
class FiberScheduler
  def initialize
    @io_selector = NIO::Selector.new
    @ready_fibers = []
  end
  
  def schedule_fiber(&block)
    fiber = Fiber.new do
      block.call
    end
    @ready_fibers << fiber
  end
  
  def wait_readable(io)
    # Suspend current fiber until IO is readable
    monitor = @io_selector.register(io, :r)
    Fiber.yield # Suspend this fiber
    @io_selector.deregister(monitor)
  end
  
  def run
    loop do
      # Resume ready fibers
      while fiber = @ready_fibers.shift
        fiber.resume if fiber.alive?
      end
      
      # Wait for I/O events
      @io_selector.select(0.01) do |monitor|
        fiber = monitor.value # Fiber waiting on this I/O
        @ready_fibers << fiber
      end
      
      break if @ready_fibers.empty? && @io_selector.empty?
    end
  end
end

Design Considerations

Choosing an event loop architecture requires evaluating workload characteristics and system requirements.

I/O-Bound vs CPU-Bound Workloads: Event loops excel at I/O-bound applications where operations spend most time waiting for external resources. The single-threaded model handles thousands of concurrent I/O operations efficiently. CPU-bound workloads perform poorly on single-threaded event loops since computations block all event processing.

For mixed workloads, hybrid approaches work best. Dispatch CPU-intensive work to thread pools or separate processes while handling I/O through the event loop.

# Handling mixed workload
Async do |task|
  # I/O-bound: runs on event loop
  response = task.async { fetch_from_api(url) }
  
  # CPU-bound: offload to thread pool
  computation = task.async do
    Thread.new do
      complex_calculation(response.wait)
    end.value
  end
  
  result = computation.wait
end

Memory Efficiency: Event loops consume less memory than thread-per-connection models. A single-threaded event loop handling 10,000 connections uses approximately the same memory as 10-20 threads. Thread stacks typically require 1-2 MB each, while event callbacks only need stack space when executing.

The trade-off is that event loops maintain callback registries and event queues that grow with the number of concurrent operations. Connection state must be explicitly managed rather than stored implicitly in thread-local variables.

Debugging Complexity: Single-threaded event loops simplify reasoning about state since no concurrent access occurs. However, debugging becomes challenging when tracking asynchronous control flow through callbacks. Stack traces become less meaningful as they only show the callback chain, not the originating call.

Fiber-based approaches provide better stack traces since the fiber's stack preserves the call chain. Callback-based event loops often require explicit context propagation for debugging.

Error Handling: Exceptions in callbacks can crash the entire event loop if not handled properly. Each callback should have error handling at its boundary. Unhandled exceptions have nowhere to propagate in callback-based systems.

# Error handling in event loop
def safe_callback(&block)
  proc do
    begin
      block.call
    rescue => e
      log_error(e)
      # Don't let exception escape and crash event loop
    end
  end
end

event_loop.register(safe_callback { risky_operation })

Latency Characteristics: Event loops provide predictable latency for I/O operations when callbacks execute quickly. Long-running callbacks introduce head-of-line blocking, delaying all subsequent events. Breaking long operations into smaller chunks prevents latency spikes.

# Breaking long operation into chunks
def process_large_dataset(dataset, chunk_size: 100)
  chunks = dataset.each_slice(chunk_size)
  
  process_chunk = proc do
    chunk = chunks.next rescue nil
    return unless chunk
    
    # Process this chunk
    chunk.each { |item| handle_item(item) }
    
    # Schedule next chunk
    EventMachine.next_tick(&process_chunk)
  end
  
  EventMachine.next_tick(&process_chunk)
end

Integration Complexity: Event loop systems require all code to be non-blocking. Integrating blocking libraries forces compromises: wrapping blocking calls in thread pool dispatches, finding non-blocking alternatives, or accepting that some operations will block the loop.

Ruby's ecosystem includes many blocking libraries designed for thread-based concurrency. Adopting an event loop may require replacing these dependencies or implementing adapters.

Performance Considerations

Event loop performance characteristics determine suitability for different application types.

Concurrency Limits: A single-threaded event loop handles concurrent connections limited by memory and callback execution time. Well-designed event loops easily handle 10,000+ concurrent connections on modern hardware. The actual limit depends on per-connection memory overhead and event processing rate.

Multi-process event loops scale to CPU core count. Four event loop processes can handle 40,000+ connections (10,000 per process) while utilizing multiple cores.

Context Switch Overhead: Event loops eliminate thread context switches for I/O operations. Operating system thread switches require saving and restoring CPU registers and switching memory contexts, costing thousands of CPU cycles. Event loops only switch between callbacks in the same thread through function calls, requiring minimal overhead.

The benefit is most pronounced under high connection counts. With 10,000 active connections, a thread-per-connection model performs millions of context switches per second. An event loop performs zero thread switches for I/O waiting.

I/O Multiplexing Efficiency: Modern event loops use efficient I/O multiplexing mechanisms. Linux's epoll scales to tens of thousands of file descriptors with O(1) performance for adding/removing and O(ready_events) for waiting. This contrasts with older select() which requires O(n) operations for n file descriptors.

# Performance comparison concept
def benchmark_approach(connection_count)
  start_time = Time.now
  
  # Measure time to handle N concurrent connections
  connections = connection_count.times.map { open_connection }
  
  # Event loop handles all concurrently
  event_loop.wait_all(connections)
  
  elapsed = Time.now - start_time
  throughput = connection_count / elapsed
  
  puts "Handled #{connection_count} connections in #{elapsed}s"
  puts "Throughput: #{throughput} connections/sec"
end

Callback Overhead: Each callback invocation incurs function call overhead. For very simple operations (like incrementing a counter), this overhead becomes noticeable. However, I/O operations typically involve enough work that callback overhead remains negligible.

Deeply nested callbacks (callback hell) can create long call chains, slightly degrading performance. Fiber-based approaches reduce this overhead by maintaining normal call stacks.

Memory Access Patterns: Event loops process events sequentially in a single thread, potentially improving CPU cache utilization. Thread-based models switch between threads frequently, thrashing CPU caches. The performance gain varies by workload but can be significant for memory-intensive operations.

Backpressure Handling: Event loops must implement backpressure mechanisms to prevent fast producers from overwhelming slow consumers. Without backpressure, event queues grow unbounded, consuming memory and increasing latency.

# Backpressure in event loop
class RateLimitedEventLoop
  def initialize(max_pending: 1000)
    @event_queue = []
    @max_pending = max_pending
  end
  
  def schedule_event(event)
    if @event_queue.size >= @max_pending
      # Apply backpressure: reject or delay
      raise "Event queue full"
    end
    
    @event_queue << event
  end
  
  def process_events
    until @event_queue.empty?
      event = @event_queue.shift
      handle_event(event)
    end
  end
end

Common Patterns

Several patterns emerge repeatedly in event loop programming.

Callback Pattern: The fundamental pattern where operations accept callbacks that execute upon completion. This inverts control flow compared to synchronous programming.

# Callback pattern
def fetch_user(user_id, &callback)
  EventMachine::HttpRequest.new("http://api/users/#{user_id}").get.callback do |http|
    user = JSON.parse(http.response)
    callback.call(user)
  end
end

fetch_user(123) do |user|
  puts "User: #{user['name']}"
end

The callback pattern becomes unwieldy with nested operations, creating deeply indented code (callback hell). Each nesting level adds complexity and makes error handling difficult.

Promise/Future Pattern: Wraps asynchronous operations in objects that represent eventual values. Promises allow chaining operations and provide structured error handling.

# Promise-like pattern in Ruby
class Promise
  def initialize
    @callbacks = []
    @errbacks = []
    @state = :pending
  end
  
  def then(&callback)
    if @state == :fulfilled
      callback.call(@value)
    else
      @callbacks << callback
    end
    self
  end
  
  def rescue(&errback)
    if @state == :rejected
      errback.call(@error)
    else
      @errbacks << errback
    end
    self
  end
  
  def fulfill(value)
    @state = :fulfilled
    @value = value
    @callbacks.each { |cb| cb.call(value) }
    @callbacks.clear
  end
  
  def reject(error)
    @state = :rejected
    @error = error
    @errbacks.each { |eb| eb.call(error) }
    @errbacks.clear
  end
end

def fetch_data_promise(url)
  promise = Promise.new
  
  EventMachine::HttpRequest.new(url).get.callback do |http|
    promise.fulfill(http.response)
  end.errback do |error|
    promise.reject(error)
  end
  
  promise
end

fetch_data_promise('http://api/data')
  .then { |data| process_data(data) }
  .then { |result| save_result(result) }
  .rescue { |error| handle_error(error) }

Async/Await Pattern: Provides synchronous-looking code that executes asynchronously. Ruby's Fiber-based approaches enable this pattern through fiber suspension and resumption.

# Async/await-like pattern with fibers
def fetch_and_process
  Async do
    # Looks synchronous but runs asynchronously
    user_data = fetch_user(123)
    orders = fetch_orders(user_data[:id])
    total = calculate_total(orders)
    
    puts "Total: #{total}"
  end
end

Event Emitter Pattern: Objects emit named events that listeners can subscribe to. This decouples event producers from consumers.

class EventEmitter
  def initialize
    @listeners = Hash.new { |h, k| h[k] = [] }
  end
  
  def on(event, &handler)
    @listeners[event] << handler
  end
  
  def emit(event, *args)
    @listeners[event].each do |handler|
      handler.call(*args)
    end
  end
end

server = EventEmitter.new

server.on(:connection) do |client|
  puts "Client connected: #{client}"
end

server.on(:data) do |client, data|
  puts "Received data: #{data}"
end

# Elsewhere in code
server.emit(:connection, client_socket)
server.emit(:data, client_socket, received_data)

Pipeline Pattern: Chains multiple asynchronous operations where each operation's output feeds into the next operation's input.

# Pipeline pattern
class Pipeline
  def initialize(initial_value)
    @value = initial_value
  end
  
  def pipe(&operation)
    @value = operation.call(@value)
    self
  end
  
  def execute(&final_callback)
    final_callback.call(@value)
  end
end

Pipeline.new(user_id)
  .pipe { |id| fetch_user(id) }
  .pipe { |user| validate_user(user) }
  .pipe { |user| load_permissions(user) }
  .execute { |user| process_authenticated_user(user) }

Reference

Event Loop Components

Component Description Responsibility
Event Queue Stores pending events Maintains FIFO order of events
Callback Registry Maps events to handlers Associates event identifiers with callback functions
I/O Multiplexer Monitors file descriptors Detects ready I/O operations using epoll/kqueue
Timer Heap Tracks scheduled timers Executes callbacks at specified times
Phase Manager Controls loop phases Processes different event types in sequence

Event Loop Phases

Phase Purpose When Executed
Timers Execute scheduled timers First in each iteration
Pending Callbacks Handle deferred I/O callbacks After timers
Idle/Prepare Run internal operations Before polling
Poll Wait for new I/O events Main blocking point
Check Execute setImmediate callbacks After poll
Close Handle close events Last in iteration

Ruby Event Loop Libraries

Library Approach Best For Active Development
EventMachine Reactor pattern Network servers Maintenance mode
Async Fiber scheduler Modern async I/O Yes
Celluloid Actor model Concurrent objects No
nio4r I/O multiplexing Building event loops Yes
Concurrent Ruby Thread pools CPU-bound work Yes

Performance Characteristics

Metric Event Loop Thread-Per-Connection
Memory per connection ~4 KB 1-2 MB
Context switch cost Function call Thread switch
Max connections 10,000+ 1,000-5,000
CPU utilization (I/O bound) Low Low
CPU utilization (CPU bound) Blocked Parallel

Design Decision Matrix

Workload Type Recommended Approach Rationale
High I/O, low CPU Single-threaded event loop Maximum efficiency
Balanced I/O and CPU Event loop with thread pool Offload CPU work
CPU intensive Multi-process or threads Need parallelism
Real-time requirements Dedicated threads Predictable latency
Mixed workload Hybrid approach Optimize per operation type

Common Event Types

Event Type Trigger Typical Handler Action
I/O Readable Data available on socket Read data, parse, process
I/O Writable Socket ready for writing Write buffered data
Timer Expired Time threshold reached Execute scheduled task
Signal Operating system signal Cleanup, reload configuration
Custom Application-defined Application-specific logic

Error Handling Strategies

Strategy Implementation Trade-offs
Try-catch per callback Wrap each callback Verbose, prevents crashes
Global error handler Catch at event loop level Simple, may miss context
Error events Emit error events Flexible, requires listeners
Circuit breaker Stop after N failures Prevents cascade failures
Supervisor Restart failed components Resilient, adds complexity