CrackedRuby CrackedRuby

I/O Models (Blocking, Non-blocking, Async)

Overview

I/O models define how programs interact with external resources like files, network connections, and devices. These models determine whether program execution pauses during I/O operations or continues processing other tasks. The three primary models—blocking, non-blocking, and asynchronous—represent different approaches to handling I/O latency and concurrency.

Blocking I/O represents the traditional model where a program halts execution while waiting for an I/O operation to complete. When a thread reads from a socket, it remains suspended until data arrives. This model simplifies reasoning about program flow but limits concurrency to the number of available threads.

Non-blocking I/O allows programs to initiate I/O operations and immediately check their status without waiting. The program polls for completion or receives immediate feedback that the operation would block. This model requires explicit polling loops but enables a single thread to manage multiple I/O operations.

Asynchronous I/O decouples operation initiation from completion notification. Programs register callbacks or handlers that execute when I/O completes. This model maximizes concurrency within a single thread through event-driven architecture but introduces complexity in control flow and error handling.

The choice between these models affects application scalability, resource utilization, code complexity, and performance characteristics. Web servers handling thousands of concurrent connections typically benefit from non-blocking or asynchronous models, while simple scripts processing files sequentially work fine with blocking I/O.

Key Principles

Blocking I/O Operations

Blocking I/O suspends the calling thread until the operation completes. The kernel places the thread in a wait state, removing it from the scheduler's run queue. This suspension continues until data becomes available, the operation completes, or an error occurs. The thread consumes minimal CPU resources while blocked but occupies memory for its stack and context.

The operating system handles the wait state transparently. When data arrives on a socket or a disk read completes, the kernel wakes the thread and returns control to the application. From the application's perspective, the read or write operation appears as a single synchronous call that blocks until completion.

# Blocking read - thread suspends until data arrives
socket = TCPSocket.new('example.com', 80)
data = socket.read(1024)  # Thread blocks here
puts "Received: #{data}"

The blocking model simplifies error handling and program logic. Operations either succeed and return data or fail with an exception. No state machine or callback chain handles partial completions or retries.

Non-blocking I/O Operations

Non-blocking I/O returns immediately whether or not data is available. The system call sets a flag indicating the operation should not block. If the operation cannot complete immediately, it returns an error code indicating the operation would block rather than suspending the thread.

Applications using non-blocking I/O must repeatedly poll or use I/O multiplexing to determine when operations can proceed. The select, poll, or epoll system calls allow monitoring multiple file descriptors simultaneously, notifying the application when any become ready for reading or writing.

# Non-blocking socket - returns immediately
socket = TCPSocket.new('example.com', 80)
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [0, 0].pack('l_2'))

begin
  data = socket.read_nonblock(1024)
  puts "Received: #{data}"
rescue IO::WaitReadable
  # Socket not ready, would have blocked
  IO.select([socket])  # Wait for readability
  retry
end

The readiness notification model separates checking for readiness from performing I/O. A file descriptor might be readable, but the subsequent read could still return partial data or encounter errors. Applications handle these cases through additional logic.

Asynchronous I/O Operations

Asynchronous I/O separates initiation and completion. The application submits an I/O request with a completion handler, then continues execution. When the operation completes, the system notifies the application through callbacks, signals, or completion queues.

The event loop pattern forms the core of asynchronous I/O. The loop monitors pending operations and dispatches completion handlers when operations finish. This pattern enables handling thousands of concurrent operations within a single thread.

# Asynchronous pattern with Fiber
require 'async'
require 'async/http/internet'

Async do
  internet = Async::HTTP::Internet.new
  
  # Multiple concurrent requests
  responses = 3.times.map do |i|
    Async do
      internet.get("https://example.com/api/#{i}")
    end
  end
  
  responses.each { |r| puts r.wait.read }
end

Asynchronous I/O maintains operation context through closures or continuation passing. The completion handler has access to variables from the initiation scope, preserving state across the asynchronous boundary.

Concurrency Models

Each I/O model supports different concurrency strategies. Blocking I/O achieves concurrency through multiple threads or processes. Each concurrent operation requires a dedicated execution context, limiting scalability by available threads and memory.

Non-blocking I/O enables concurrency within a single thread through multiplexing. One thread monitors multiple file descriptors, processing whichever operations can proceed. This approach reduces context switching overhead but requires careful state management.

Asynchronous I/O achieves concurrency through cooperative multitasking. Operations yield control when waiting for I/O, allowing other operations to progress. The event loop schedules these operations, providing concurrency without thread overhead.

State Management

Blocking I/O maintains state on the call stack. Local variables and function parameters preserve context while the thread blocks. When the operation completes, execution resumes with all state intact.

Non-blocking I/O requires explicit state machines. The application tracks which operations are pending, what data has been partially received, and what processing remains. This state management adds complexity but provides fine-grained control.

Asynchronous I/O captures state in closures or promises. The completion handler closes over variables from the initiation context, maintaining state without explicit tracking. This approach simplifies some scenarios but complicates error propagation and cancellation.

Ruby Implementation

Ruby provides multiple mechanisms for I/O operations across all three models. The IO class forms the foundation, with subclasses like File, Socket, and their variants implementing specific I/O types. Ruby defaults to blocking I/O but supports non-blocking operations and asynchronous patterns through fibers.

Blocking I/O in Ruby

The standard IO#read, IO#write, and related methods perform blocking operations. These methods suspend the calling thread until the operation completes or encounters an error.

# Blocking file read
File.open('large_file.txt') do |file|
  content = file.read  # Blocks until entire file is read
  process(content)
end

# Blocking network I/O
require 'socket'

server = TCPServer.new(8080)
loop do
  client = server.accept  # Blocks waiting for connection
  data = client.read      # Blocks reading client data
  client.write("Response: #{data}")
  client.close
end

Ruby's Global Interpreter Lock (GIL) releases during blocking I/O operations, allowing other Ruby threads to execute. This makes blocking I/O in Ruby somewhat thread-friendly, though each thread still consumes memory and scheduling overhead.

Non-blocking I/O in Ruby

Ruby provides non-blocking variants of I/O methods through read_nonblock, write_nonblock, and related methods. These methods raise IO::WaitReadable or IO::WaitWritable exceptions when operations would block.

require 'socket'

socket = TCPSocket.new('example.com', 80)

# Non-blocking write
begin
  bytes_written = socket.write_nonblock("GET / HTTP/1.1\r\n\r\n")
  puts "Wrote #{bytes_written} bytes"
rescue IO::WaitWritable
  IO.select(nil, [socket])  # Wait until writable
  retry
end

# Non-blocking read with select
begin
  data = socket.read_nonblock(4096)
  puts data
rescue IO::WaitReadable
  IO.select([socket])  # Wait until readable
  retry
end

The IO.select method provides I/O multiplexing, monitoring multiple file descriptors for readiness. It accepts arrays of IOs for reading, writing, and error conditions, returning arrays of ready descriptors.

# Monitor multiple sockets
sockets = [socket1, socket2, socket3]

loop do
  readable, writable, errors = IO.select(sockets, sockets, sockets, 5.0)
  
  readable&.each do |sock|
    begin
      data = sock.read_nonblock(4096)
      handle_data(sock, data)
    rescue IO::WaitReadable
      # Should not happen after select, but handle anyway
    end
  end
  
  writable&.each do |sock|
    begin
      sock.write_nonblock(pending_data[sock])
    rescue IO::WaitWritable
      # Partially written, retry later
    end
  end
end

Asynchronous I/O with Fibers

Ruby implements cooperative concurrency through fibers. A fiber represents a lightweight execution context that can yield control and resume later. Ruby 3 introduced the Fiber Scheduler interface, enabling asynchronous I/O operations through fiber-based concurrency.

require 'async'

Async do |task|
  # Concurrent file reads
  results = 10.times.map do |i|
    task.async do
      File.read("file_#{i}.txt")
    end
  end
  
  results.each { |r| puts r.wait }
end

The Async gem provides a production-ready fiber scheduler implementation. It integrates with Ruby's IO operations, automatically making them non-blocking when running within an async context.

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

Async do
  internet = Async::HTTP::Internet.new
  
  # Concurrent HTTP requests
  tasks = urls.map do |url|
    Async do
      response = internet.get(url)
      {url: url, body: response.read}
    end
  end
  
  results = tasks.map(&:wait)
  results.each { |r| puts "#{r[:url]}: #{r[:body].size} bytes" }
end

Fiber Scheduler Interface

Ruby's Fiber Scheduler provides hooks for implementing asynchronous I/O. Schedulers intercept blocking operations and convert them to non-blocking operations with fiber yields.

# Custom fiber scheduler skeleton
class SimpleScheduler
  def io_wait(io, events, timeout)
    # Convert blocking I/O wait to non-blocking with select
    readable = (events & IO::READABLE) != 0
    writable = (events & IO::WRITABLE) != 0
    
    r = readable ? [io] : nil
    w = writable ? [io] : nil
    
    result = IO.select(r, w, nil, timeout)
    result ? events : false
  end
  
  def kernel_sleep(duration)
    # Implement sleep without blocking the thread
    deadline = Time.now + duration
    while Time.now < deadline
      Fiber.yield
    end
  end
  
  def fiber(&block)
    # Create and schedule a new fiber
    Fiber.new(blocking: false, &block)
  end
end

# Use custom scheduler
Fiber.set_scheduler(SimpleScheduler.new)

Implementation Approaches

Thread-per-Connection Model

The thread-per-connection approach assigns one thread to each concurrent I/O operation. Each thread performs blocking I/O, simplifying code at the cost of resource usage. This model works well for applications handling dozens of concurrent connections but struggles at higher scales.

require 'socket'

server = TCPServer.new(8080)

loop do
  client = server.accept
  
  Thread.new(client) do |conn|
    begin
      request = conn.read
      response = process_request(request)
      conn.write(response)
    ensure
      conn.close
    end
  end
end

Each thread consumes memory for its stack (typically 1-2 MB) and adds context switching overhead. Operating systems handle thousands of threads reasonably, but performance degrades with tens of thousands. Thread creation and destruction also impose costs.

Reactor Pattern with Event Loop

The reactor pattern uses a single-threaded event loop to handle multiple connections. The loop monitors file descriptors for readiness, dispatching handlers when I/O operations can proceed without blocking.

require 'socket'

class Reactor
  def initialize
    @sockets = {}
    @running = true
  end
  
  def attach(socket, handler)
    @sockets[socket] = handler
  end
  
  def run
    while @running
      readable, writable, errors = IO.select(@sockets.keys, [], [], 1.0)
      
      readable&.each do |sock|
        handler = @sockets[sock]
        begin
          data = sock.read_nonblock(4096)
          handler.call(sock, data)
        rescue IO::WaitReadable
          # Spurious wakeup, ignore
        rescue EOFError
          sock.close
          @sockets.delete(sock)
        end
      end
    end
  end
end

reactor = Reactor.new
server = TCPServer.new(8080)

# Accept connections in the reactor loop
reactor.attach(server, ->(srv, _) {
  client = srv.accept
  reactor.attach(client, ->(sock, data) {
    response = process_request(data)
    sock.write(response)
  })
})

reactor.run

This pattern maximizes CPU efficiency by avoiding thread overhead and context switching. One thread handles thousands of connections, limited only by file descriptor limits and memory for connection state.

Proactor Pattern with Async Completion

The proactor pattern initiates asynchronous operations and processes completion notifications. Unlike the reactor pattern, which waits for readiness, the proactor pattern waits for completion.

require 'async'

class AsyncServer
  def initialize(port)
    @port = port
  end
  
  def run
    Async do |task|
      endpoint = Async::IO::Endpoint.tcp('0.0.0.0', @port)
      
      endpoint.accept do |client|
        task.async do
          handle_client(client)
        end
      end
    end
  end
  
  def handle_client(client)
    request = client.read
    response = process_request(request)
    client.write(response)
  ensure
    client.close
  end
end

server = AsyncServer.new(8080)
server.run

The proactor pattern simplifies application code by hiding readiness monitoring. The scheduler handles notification when operations complete, dispatching handlers with operation results.

Hybrid Approaches

Many applications combine models for different I/O types. Network I/O might use asynchronous operations while file I/O remains synchronous. Background threads might handle CPU-intensive processing while the main thread manages I/O events.

require 'async'
require 'concurrent-ruby'

class HybridServer
  def initialize
    @thread_pool = Concurrent::ThreadPoolExecutor.new(
      min_threads: 4,
      max_threads: 16,
      max_queue: 100
    )
  end
  
  def run
    Async do |task|
      endpoint = Async::IO::Endpoint.tcp('0.0.0.0', 8080)
      
      endpoint.accept do |client|
        task.async do
          # Async I/O for network
          request = client.read
          
          # Thread pool for CPU-intensive work
          future = Concurrent::Future.execute(executor: @thread_pool) do
            process_request(request)
          end
          
          response = future.value
          client.write(response)
          client.close
        end
      end
    end
  end
end

Practical Examples

Web Server with Different Models

A simple HTTP server demonstrates each I/O model's characteristics. The blocking version uses threads, the non-blocking version uses IO.select, and the async version uses fibers.

# Blocking thread-based server
require 'socket'

class BlockingServer
  def initialize(port)
    @server = TCPServer.new(port)
  end
  
  def start
    loop do
      Thread.start(@server.accept) do |client|
        request = client.readpartial(4096)
        
        # Simulate processing time
        sleep(0.1)
        
        response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!"
        client.write(response)
        client.close
      end
    end
  end
end

# Non-blocking select-based server
class NonBlockingServer
  def initialize(port)
    @server = TCPServer.new(port)
    @clients = {}
  end
  
  def start
    loop do
      sockets = [@server] + @clients.keys
      readable, _, _ = IO.select(sockets, nil, nil, 1.0)
      
      readable&.each do |socket|
        if socket == @server
          client = @server.accept
          client.instance_variable_set(:@buffer, "")
          @clients[client] = Time.now
        else
          handle_client(socket)
        end
      end
      
      # Timeout old connections
      @clients.reject! { |sock, time| Time.now - time > 30 }
    end
  end
  
  def handle_client(socket)
    begin
      data = socket.read_nonblock(4096)
      buffer = socket.instance_variable_get(:@buffer)
      buffer << data
      
      if buffer.include?("\r\n\r\n")
        response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!"
        socket.write(response)
        socket.close
        @clients.delete(socket)
      end
    rescue IO::WaitReadable
      # Not ready yet
    rescue EOFError
      socket.close
      @clients.delete(socket)
    end
  end
end

# Async fiber-based server
require 'async'
require 'async/io'

class AsyncServer
  def initialize(port)
    @port = port
  end
  
  def start
    Async do |task|
      endpoint = Async::IO::Endpoint.tcp('0.0.0.0', @port)
      
      endpoint.accept do |client|
        task.async do
          request = client.read
          
          # Simulate processing with non-blocking sleep
          task.sleep(0.1)
          
          response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!"
          client.write(response)
          client.close
        end
      end
    end
  end
end

File Processing Pipeline

Processing multiple files demonstrates how I/O models affect throughput and resource usage. The blocking version processes sequentially, while async versions process concurrently.

# Sequential blocking processing
def process_files_blocking(filenames)
  results = []
  
  filenames.each do |filename|
    content = File.read(filename)
    processed = expensive_transform(content)
    output_file = "processed_#{filename}"
    File.write(output_file, processed)
    results << output_file
  end
  
  results
end

# Async concurrent processing
require 'async'

def process_files_async(filenames)
  Async do |task|
    tasks = filenames.map do |filename|
      task.async do
        content = File.read(filename)
        processed = expensive_transform(content)
        output_file = "processed_#{filename}"
        File.write(output_file, processed)
        output_file
      end
    end
    
    tasks.map(&:wait)
  end
end

# With bounded concurrency
def process_files_bounded(filenames, max_concurrent: 10)
  Async do |task|
    semaphore = Async::Semaphore.new(max_concurrent)
    
    tasks = filenames.map do |filename|
      task.async do
        semaphore.acquire do
          content = File.read(filename)
          processed = expensive_transform(content)
          output_file = "processed_#{filename}"
          File.write(output_file, processed)
          output_file
        end
      end
    end
    
    tasks.map(&:wait)
  end
end

Database Query Aggregation

Fetching data from multiple database sources shows async I/O benefits for I/O-bound operations with independent requests.

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

class DataAggregator
  def initialize
    @internet = Async::HTTP::Internet.new
  end
  
  def aggregate_user_data(user_id)
    Async do |task|
      # Launch concurrent requests to different services
      profile_task = task.async { fetch_profile(user_id) }
      orders_task = task.async { fetch_orders(user_id) }
      preferences_task = task.async { fetch_preferences(user_id) }
      
      # Wait for all to complete
      profile = profile_task.wait
      orders = orders_task.wait
      preferences = preferences_task.wait
      
      {
        profile: profile,
        orders: orders,
        preferences: preferences
      }
    end
  end
  
  private
  
  def fetch_profile(user_id)
    response = @internet.get("https://api.example.com/users/#{user_id}")
    JSON.parse(response.read)
  end
  
  def fetch_orders(user_id)
    response = @internet.get("https://api.example.com/orders?user=#{user_id}")
    JSON.parse(response.read)
  end
  
  def fetch_preferences(user_id)
    response = @internet.get("https://api.example.com/preferences/#{user_id}")
    JSON.parse(response.read)
  end
end

Design Considerations

When to Use Blocking I/O

Blocking I/O suits applications with low concurrency requirements, simple control flow needs, and abundant system resources. Scripts that process files sequentially, command-line tools, and applications handling fewer than 100 concurrent operations benefit from blocking I/O's simplicity.

The straightforward programming model reduces bugs related to race conditions, callback ordering, and state management. Debugging remains simpler because stack traces represent actual execution flow. Testing requires no special async handling.

Applications spending significant time on CPU-bound processing rather than waiting for I/O gain little from non-blocking or async models. The overhead of event loops or fiber scheduling adds complexity without performance benefit.

When to Use Non-blocking I/O

Non-blocking I/O works best when applications must handle thousands of concurrent connections with minimal memory footprint and fine-grained control over I/O operations. Network proxies, load balancers, and connection multiplexers benefit from direct control over socket readiness.

This model provides explicit control over which operations proceed and when. Applications can implement custom prioritization, throttling, or fairness policies. The absence of threads eliminates synchronization concerns.

The complexity cost is significant. State machines replace linear control flow. Partial reads and writes require explicit buffering. Error handling becomes more verbose because operations split across multiple function calls.

When to Use Asynchronous I/O

Asynchronous I/O suits applications requiring high concurrency with manageable code complexity. Web servers, API gateways, and microservices handling hundreds or thousands of concurrent requests benefit from async's concurrency without thread overhead.

The event-driven model enables building responsive applications that remain interactive during long-running operations. User interfaces can update while background operations progress. Request handlers can wait for multiple operations concurrently without explicit threading.

Async I/O introduces challenges in error propagation, debugging, and resource cleanup. Stack traces span multiple event loop iterations, complicating debugging. Ensuring cleanup on error paths requires careful attention. Performance profiling becomes more complex because time spent in callbacks appears separate from initiation.

Latency vs Throughput Trade-offs

Blocking I/O often provides lower latency for individual operations in low-concurrency scenarios. No event loop overhead exists, and operations proceed immediately when resources are available. Context switching directly to a ready thread happens faster than fiber scheduling.

Non-blocking and async I/O maximize throughput by handling more concurrent operations per resource unit. Applications serve more requests with fewer threads, reducing memory usage and context switching overhead. This throughput benefit increases with concurrency level.

The crossover point depends on operation characteristics and system resources. Measure actual performance under realistic load rather than assuming one model performs better.

Resource Utilization Patterns

Blocking I/O with threads consumes memory proportional to concurrent operations. Each thread requires stack space (1-2 MB default) plus thread control structures. Systems with 8 GB RAM can practically support 1,000-2,000 threads before memory becomes constrained.

Non-blocking I/O with event loops uses memory for connection state and buffers only. Ten thousand connections might require 50-100 MB depending on buffer sizes and application data structures. CPU utilization remains low because no context switching occurs.

Async I/O with fibers provides a middle ground. Fibers consume less memory than threads (typically 4-8 KB stack) but more than raw event loops. Ruby's fiber overhead is minimal, supporting tens of thousands of concurrent fibers.

Error Handling Complexity

Blocking I/O centralizes error handling at operation sites. Exceptions propagate up the call stack normally. Resource cleanup happens through ensure blocks or automatic destructors.

# Simple error handling with blocking I/O
begin
  file = File.open('data.txt')
  content = file.read
  process(content)
rescue IOError => e
  logger.error("File error: #{e.message}")
ensure
  file&.close
end

Async I/O distributes error handling across callbacks. Errors occurring in callbacks must be explicitly propagated to callers. Ensuring cleanup on error paths requires careful callback chaining or promise rejection handling.

# Error handling with async I/O
Async do |task|
  begin
    content = File.read('data.txt')
    process(content)
  rescue IOError => e
    logger.error("File error: #{e.message}")
    raise  # Propagate to task
  end
end

Performance Considerations

Scalability Characteristics

Blocking I/O scalability is limited by thread count. Creating 10,000 threads consumes 10-20 GB of memory for stacks alone. Context switching overhead increases with thread count, reducing CPU efficiency. Thread creation and destruction impose non-trivial costs.

Non-blocking I/O scales to tens of thousands of connections on commodity hardware. A single thread manages all connections through select, poll, or epoll. Memory usage remains proportional to active connections, not potential connections.

Asynchronous I/O with fibers combines scalability with manageable complexity. Fiber memory overhead is 100-200x lower than threads. Ruby implementations support 10,000+ concurrent fibers efficiently.

CPU Utilization Patterns

Blocking I/O with threads causes high context switching overhead at scale. The kernel scheduler switches between thousands of threads, each getting brief CPU time slices. This overhead becomes significant above 1,000 concurrent threads.

Non-blocking I/O with event loops maximizes CPU efficiency for I/O-bound operations. A single thread handles all I/O through system calls that monitor multiple descriptors. CPU utilization drops to near zero when no I/O is ready, allowing other processes to use resources.

# Non-blocking event loop CPU efficiency
require 'socket'

connections = {}
server = TCPServer.new(8080)

loop do
  readable, writable, _ = IO.select([server] + connections.keys, 
                                     connections.keys, [], 1.0)
  
  # CPU active only when I/O is ready
  readable&.each { |sock| handle_readable(sock, connections) }
  writable&.each { |sock| handle_writable(sock, connections) }
end

Async I/O with fibers has minimal overhead for context switches because they happen in user space. No kernel involvement occurs for fiber yields. Overhead remains under 1% of execution time in typical applications.

Latency Analysis

Blocking I/O provides consistent latency for individual operations when thread count remains reasonable. No event loop processing delays exist. Operations start immediately when threads are available.

Thread scheduling introduces latency variance. When more threads exist than CPU cores, the scheduler introduces delays before threads execute. This latency increases with thread count and system load.

Non-blocking I/O with event loops introduces minimal latency for individual operations. Event loop iterations process pending operations quickly. Latency variance comes from event loop processing time, which increases with concurrent operation count.

Async I/O with fibers adds fiber scheduling overhead. Each fiber yield and resume has small but measurable cost. Applications performing many small I/O operations might observe higher latency than blocking I/O with threads.

Memory Efficiency

Thread memory consumption becomes prohibitive at high concurrency. Default thread stack sizes (1-2 MB) mean 10,000 threads consume 10-20 GB. Reducing stack size risks stack overflow. Thread control structures add additional overhead.

Non-blocking I/O memory usage scales with connection state. Each connection requires buffers for partial reads and writes, plus application state. Ten thousand connections might use 50-200 MB depending on buffer sizes.

Fiber memory overhead is minimal. Ruby fibers start with small stacks (4 KB) that grow as needed. Ten thousand fibers consume 40-80 MB plus application state.

Benchmarking Different Models

Measuring performance requires realistic workloads. Synthetic benchmarks often miss real-world characteristics like connection lifetime distribution, request size variance, and processing time variability.

require 'benchmark'

# Benchmark blocking vs async file reads
filenames = 100.times.map { |i| "file_#{i}.txt" }

Benchmark.bm(20) do |x|
  x.report("blocking:") do
    filenames.each { |f| File.read(f) }
  end
  
  x.report("async:") do
    Async do |task|
      tasks = filenames.map { |f| task.async { File.read(f) } }
      tasks.map(&:wait)
    end
  end
end

Monitor memory usage, CPU utilization, and connection count under load. Tools like vmstat, top, and Ruby's memory profiler reveal resource consumption patterns.

Optimization Strategies

Buffer sizes affect performance across all I/O models. Larger buffers reduce system call frequency but increase memory usage. Optimal buffer size depends on typical data sizes and available memory.

# Buffer size tuning
BUFFER_SIZES = [1024, 4096, 16384, 65536]

BUFFER_SIZES.each do |size|
  Benchmark.bm(20) do |x|
    x.report("buffer=#{size}:") do
      File.open('large_file.dat', 'rb') do |f|
        while chunk = f.read(size)
          process(chunk)
        end
      end
    end
  end
end

Connection pooling reduces overhead from repeatedly opening and closing connections. Pools work with all I/O models but particularly benefit blocking I/O by reusing threads.

Batching operations reduces per-operation overhead. Reading or writing multiple small pieces as a single larger operation improves throughput.

Reference

I/O Model Comparison

Model Concurrency Memory Usage CPU Efficiency Code Complexity Latency
Blocking Thread-based High (MB/thread) Low at scale Low Consistent
Non-blocking Single-threaded Low High High Variable
Asynchronous Fiber-based Medium (KB/fiber) High Medium Variable

Ruby I/O Methods

Method Type Behavior Use Case
read Blocking Reads until EOF or specified bytes Simple sequential reads
write Blocking Writes all data or raises exception Simple sequential writes
read_nonblock Non-blocking Returns immediately or raises exception Manual I/O multiplexing
write_nonblock Non-blocking Returns bytes written or raises Manual I/O multiplexing
readpartial Blocking Returns available data up to limit Progressive processing
sysread Blocking Low-level read system call Performance-critical code
syswrite Blocking Low-level write system call Performance-critical code

Exception Types

Exception Raised By Meaning Handling
IO::WaitReadable read_nonblock Operation would block reading Use IO.select to wait
IO::WaitWritable write_nonblock Operation would block writing Use IO.select to wait
EOFError read methods End of file reached Normal termination
IOError All I/O methods I/O operation failed Check error details
Errno::EAGAIN Non-blocking methods Resource temporarily unavailable Retry operation
Errno::EINTR Blocking methods System call interrupted Retry operation

IO.select Parameters

Parameter Type Purpose Example
read_array Array of IO IOs to monitor for readability [socket1, socket2]
write_array Array of IO IOs to monitor for writability [socket3]
error_array Array of IO IOs to monitor for errors [socket1, socket2, socket3]
timeout Numeric or nil Maximum wait time in seconds 5.0 or nil

Fiber Methods

Method Purpose Example
Fiber.new Creates new fiber Fiber.new { code }
Fiber.yield Yields control to scheduler Fiber.yield(value)
fiber.resume Resumes fiber execution fiber.resume(args)
Fiber.current Returns current fiber Fiber.current
Fiber.set_scheduler Sets fiber scheduler Fiber.set_scheduler(scheduler)
Fiber.scheduler Gets current scheduler Fiber.scheduler

Decision Matrix

Requirement Recommended Model Rationale
< 100 concurrent operations Blocking with threads Simplicity outweighs overhead
100-1000 concurrent operations Async with fibers Balance of performance and complexity
1000+ concurrent operations Non-blocking or Async Maximum scalability
CPU-bound processing Blocking with thread pool Threads enable true parallelism
Network proxy/gateway Non-blocking Fine-grained control needed
Web application Async High concurrency with maintainable code
File processing pipeline Async Concurrent operations on independent files
Simple CLI tool Blocking Minimal complexity needed

Async Library Comparison

Library Model Scheduler Integration Complexity
async Fiber-based Built-in Automatic Low
eventmachine Event loop Custom Manual Medium
nio4r Selector-based Libev Manual Medium
concurrent-ruby Thread pool Standard threads Manual Low

Performance Characteristics

Metric Blocking Non-blocking Async
Memory per connection 1-2 MB 10-50 KB 10-100 KB
Max concurrent operations ~1000 ~100000 ~50000
Context switch cost High (kernel) None Low (userspace)
Single request latency Low Low Medium
Total throughput Medium High High
Code complexity Low High Medium

Common Gotchas

Issue Model Problem Solution
Thread exhaustion Blocking Too many threads created Use thread pool with limit
Spurious wakeups Non-blocking select returns but no data Check exceptions after read_nonblock
Partial writes Non-blocking write_nonblock partial completion Track bytes written, retry remainder
Callback hell Async Deep nesting of callbacks Use async/await pattern or promises
GIL contention All Ruby threads blocked on GIL Use non-blocking I/O or C extensions
Resource leaks All Connections not closed on error Use ensure blocks or finalizers
Fiber starvation Async Long-running fiber blocks others Add explicit yield points
Buffer bloat All Large buffers waste memory Tune buffer sizes to typical data sizes