Getting Started
This guide explains how to use async-safe to detect thread safety violations in your Ruby code.
Installation
Add the gem to your project:
$ bundle add async-safe
Usage
Enable monitoring in your test suite or development environment:
require 'async/safe'
# Enable monitoring
Async::Safe.enable!
# Your concurrent code here...
When a violation is detected, an Async::Safe::ViolationError will be raised immediately with details about the object, method, and execution contexts involved.
Concurrent Access Detection
async-safe detects concurrent access (data races) to objects across fibers. Objects can move freely between fibers - violations are only raised when two fibers try to access the same object simultaneously.
Async::Safe.enable!
request = Request.new("http://example.com")
request.process # Main fiber
Fiber.new do
# No problem - sequential access is allowed
request.process # ✅ OK
end.resume
However, actual concurrent access is detected:
require 'async'
counter = Counter.new
counter.increment
Async do |task|
task.async do
counter.increment # Fiber A accessing
sleep 0.1 # ... method is still running
end
task.async do
sleep 0.05 # Wait for Fiber A to start
counter.increment # 💥 Concurrent access detected!
end
end
This approach focuses on catching actual bugs (data races) while allowing objects to move naturally between fibers.
Guard-Based Concurrency
For objects with multiple independent operation types (like streams with separate read/write operations), async_safe? can return different guard symbols for different operations:
class Stream
def self.async_safe?(method)
case method
when :read then :readable
when :write then :writable
else false
end
end
def read; end
def write(data); end
end
This allows:
- ✅ Concurrent
readandwrite(different guards::readableand:writable) - ❌ Concurrent
readandread(same:readableguard) - ❌ Concurrent
writeandwrite(same:writableguard)
Each guard can only be held by one fiber at a time, but different guards can be held concurrently.
Single-Owner Model
By default, all classes are assumed to be async-safe. To enable tracking for specific classes, mark them with ASYNC_SAFE = false:
class MyBody
ASYNC_SAFE = false # Enable tracking for this class
def initialize(chunks)
@chunks = chunks
@index = 0
end
def read
chunk = @chunks[@index]
@index += 1
chunk
end
end
body = MyBody.new(["a", "b", "c"])
body.read # Main fiber
Fiber.schedule do
body.read # ✅ OK - sequential access is allowed
end
# But concurrent access is detected:
require 'async'
Async do |task|
task.async { body.read } # Two fibers accessing
task.async { body.read } # at the same time → ViolationError!
end
Marking Async-Safe Classes
Mark entire classes as safe for concurrent access:
class MyQueue
async_safe!
def initialize
@queue = Thread::Queue.new
end
def push(item)
@queue.push(item)
end
def pop
@queue.pop
end
end
queue = MyQueue.new
queue.push("item")
Fiber.schedule do
queue.push("another") # ✅ OK - class is marked async-safe
end
Alternatively, you can manually set the constant:
class MyQueue
ASYNC_SAFE = true
# ... implementation
end
Or use a hash for per-method configuration:
class MixedClass
ASYNC_SAFE = {
read: true, # This method is async-safe
write: false # This method is NOT async-safe
}.freeze
# ... implementation
end
Marking Methods with Hash
Use a hash to specify which methods are async-safe:
class MixedSafety
ASYNC_SAFE = {
safe_read: true, # This method is async-safe
increment: false # This method is NOT async-safe
}.freeze
def initialize(data)
@data = data
@count = 0
end
def safe_read
@data # Async-safe method
end
def increment
@count += 1 # Not async-safe - will be tracked
end
end
obj = MixedSafety.new("data")
Fiber.schedule do
obj.safe_read # ✅ OK - method is marked async-safe
obj.increment # 💥 Raises Async::Safe::ViolationError!
end
Or use an array to list async-safe methods:
class MyClass
ASYNC_SAFE = [:read, :inspect].freeze
# read and inspect are async-safe
# all other methods will be tracked
end
Integration with Tests
Add to your test helper (e.g., config/sus.rb or spec/spec_helper.rb):
require 'async/safe'
Async::Safe.enable!
Then run your tests normally:
$ bundle exec sus
Any thread safety violations will cause your tests to fail immediately with a clear error message showing which object was accessed incorrectly and from which fibers.
Determining Async Safety
When deciding whether to mark a class or method with ASYNC_SAFE = false, consider these factors:
Async-Safe Patterns
Immutable objects:
class ImmutableUser
def initialize(name, email)
@name = name.freeze
@email = email.freeze
freeze # Entire object is frozen
end
attr_reader :name, :email
end
Pure functions (no state modification):
class Calculator
def add(a, b)
a + b # No instance state, pure computation
end
end
Thread-safe synchronization:
class SafeQueue
ASYNC_SAFE = true # Explicitly marked
def initialize
@queue = Thread::Queue.new # Thread-safe internally
end
def push(item)
@queue.push(item) # Delegates to thread-safe queue
end
end
Unsafe (Single-Owner) Patterns
Mutable instance state:
class Counter
ASYNC_SAFE = false # Enable tracking
def initialize
@count = 0
end
def increment
@count += 1 # Reads and writes @count (race condition!)
end
end
Stateful iteration:
class Reader
ASYNC_SAFE = false # Enable tracking
def initialize(data)
@data = data
@index = 0
end
def read
value = @data[@index]
@index += 1 # Mutates state
value
end
end
Lazy initialization:
class DataLoader
ASYNC_SAFE = false # Enable tracking
def data
@data ||= load_data # Not atomic! (race condition)
end
end
Mixed Safety
Use hash or array configuration for classes with both safe and unsafe methods:
class MixedClass
ASYNC_SAFE = {
read_config: true, # Safe: only reads frozen data
update_state: false # Unsafe: modifies mutable state
}.freeze
def initialize
@config = {setting: "value"}.freeze
@state = {count: 0}
end
def read_config
@config[:setting] # Safe: frozen hash
end
def update_state
@state[:count] += 1 # Unsafe: mutates state
end
end
Quick Checklist
Mark a method as unsafe (ASYNC_SAFE = false) if it:
- ❌ Modifies instance variables.
- ❌ Uses
||=for lazy initialization. - ❌ Iterates with mutable state (like
@index). - ❌ Reads then writes shared state.
- ❌ Accesses mutable collections without synchronization.
A method is likely safe if it:
- ✅ Only reads from frozen/immutable data.
- ✅ Has no instance state.
- ✅ Uses only local variables.
- ✅ Delegates to thread-safe primitives
Thread::Queue,Mutex, etc. - ✅ The object itself is frozen.
When in Doubt
If you're unsure whether a class is thread-safe:
- Mark it as unsafe (
ASYNC_SAFE = false) - let the monitoring catch any issues. - Run your tests with monitoring enabled.
- If no violations occur after thorough testing, it's likely safe.
- Review the code for the patterns above.