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.
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 # OK - accessed from main fiber
Fiber.schedule do
body.read # 💥 Raises Async::Safe::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
Transferring Ownership
Explicitly transfer ownership between fibers:
request = create_request
process_in_main_fiber(request)
Fiber.schedule do
Async::Safe.transfer(request) # Transfer ownership
process_in_worker_fiber(request) # ✅ OK now
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.
How It Works
- Default Assumption: All objects follow a single-owner model (not thread-safe).
- TracePoint Monitoring: Tracks which fiber/thread first accesses each object.
- Violation Detection: Raises an exception when a different fiber/thread accesses the same object.
- Explicit Safety: Objects/methods can be marked as thread-safe to allow concurrent access.
- Zero Overhead: Monitoring is only active when explicitly enabled.
Use Cases
- Detecting concurrency bugs in development and testing.
- Validating thread safety assumptions in async/fiber-based code.
- Finding race conditions before they cause production issues.
- Educational tool for learning about thread safety in Ruby.
Performance
- Zero overhead when disabled - TracePoint is not activated.
- Minimal overhead when enabled - suitable for development/test environments.
- Not recommended for production - use only in development/testing.