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 |