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 |