Ratomic
Ratomic provides mutable data structures for Ruby Ractors. Its primitives are backed by native Rust concurrency libraries so Ruby code can share useful state across Ractors without falling back to one global lock. Pool uses Ruby Ractor ownership-transfer primitives instead of the native Rust path.
Project Direction
Ratomic focuses on practical Ractor-safe primitives with a small API surface, clear ownership semantics, and honest documentation about sharp edges.
Ratomic::Map is the current priority: a Ruby-facing concurrent Hash powered by
DashMap, with atomic per-key operations designed for real Ractor workloads.
Requirements
- Ruby 4.0 or newer
- Bundler
- Rust toolchain when building the native extension from source
Installation
Add Ratomic to your application's Gemfile:
bundle add ratomic
Then require it from Ruby:
require "ratomic"
Documentation
API documentation is published to GitHub Pages:
Examples And Benchmarks
redis_poccontains local Redis scripts that exerciseRatomic::Map,Ratomic::Counter, andRatomic::Poolunder Thread and Ractor workloads.- The
cdc-parallelRatomic benchmark demonstrates Ractor workers updating shared CDC processing metrics throughRatomic::MapandRatomic::Counter.
Usage
Ratomic provides two safety models:
Counter,Map, andQueueare shared concurrent structures.Pooltransfers ownership of mutable objects between Ractors.
That distinction matters. A mutable pooled object is not shared by multiple Ractors at the same time. It is moved to the caller on checkout and moved back to the pool on checkin.
These structures are designed for use as class-level constants so they can be shared by many Ractors.
Ratomic::Counter
Ratomic::Counter is a Ractor-shareable atomic counter.
counter = Ratomic::Counter.new
counter.read # => 0
counter.increment(1)
counter.increment(5)
counter.decrement(1)
counter.decrement(1)
counter.read # => 4
counter.to_i # => 4
counter.zero? # => false
Ratomic::Map
Ratomic::Map is a Ractor-safe concurrent Hash backed by Rust's DashMap. It is
not a full Hash replacement; iteration and arbitrary mutable object borrowing
are intentionally absent.
OFFSETS = Ratomic::Map.new
OFFSETS["mike"] = 123
OFFSETS["mike"] # => 123
OFFSETS.key?("mike") # => true
OFFSETS.fetch("missing", 0) # => 0
OFFSETS.fetch_or_store("count") { 0 } # => 0
OFFSETS.compute("mike") { |value| value + 1 } # => 124
OFFSETS.upsert("mike", 1) { |value| value + 1 } # => 125
OFFSETS.fetch_and_modify("mike") { |value| value + 1 }
OFFSETS.delete("mike") # => 126
OFFSETS.length
OFFSETS.empty?
OFFSETS.clear
Map also includes atomic convenience methods for common bucket patterns:
counts = Ratomic::Map.new
counts.increment("jobs") # => 1
counts.decrement("jobs") # => 0
groups = Ratomic::Map.new
groups.append("jobs", "import") # => ["import"]
groups.add_to_set("workers", "alpha") # => #<Set: {"alpha"}>
Ratomic::Queue
Ratomic::Queue is a Ractor-shareable multi-producer, multi-consumer queue.
queue = Ratomic::Queue.new(128)
queue.push("hello")
queue << "world"
queue.size # => 2
queue.empty? # => false
queue.peek # => "hello"
queue.pop # => "hello"
queue.pop # => "world"
queue.empty? # => true
The .new(capacity) method initializes the queue with a fixed-size buffer.
Capacity must be at least 1 and at most 2**20. Non-power-of-two capacities
are supported exactly.
Since Ratomic::Queue is concurrent, size, empty?, and peek are
moment-in-time observations. Their results may already be stale by the time your
code uses them.
Ratomic::Pool
Ratomic::Pool is a Ractor-safe ownership-transfer pool for mutable Ruby objects.
BUFFERS = Ratomic::Pool.new(5, 1.0) { [] }
BUFFERS.with do |buffer|
buffer.clear
buffer << "work"
end
Pool uses Ruby 4's Ractor::Port and move: true semantics so only one
Ractor owns a checked-out object at a time.
When an object is checked out:
- the pool moves the object to the caller
- the caller can mutate the object while it owns it
- the pool cannot hand that object to another Ractor until it is checked in
When an object is checked in:
- ownership moves back to the pool
- stale references held by the caller become moved objects
- using those stale references raises
Ractor::MovedError
This means incorrect usage fails at the Ruby object-ownership boundary rather than allowing two Ractors to mutate the same object concurrently.
outside = nil
BUFFERS.with do |buffer|
outside = buffer
buffer << "inside"
end
outside << "outside"
# raises Ractor::MovedError
Manual checkout and checkin are also supported:
buffer = BUFFERS.checkout
raise "pool checkout timeout" if buffer.nil?
begin
buffer << "manual work"
ensure
BUFFERS.checkin(buffer) if buffer
end
checkout returns nil if no pooled object becomes available before the
configured timeout. with raises Ratomic::Error in that case.
Pool uses ownership transfer, not Rust's full borrow checker:
- the Rust owner maps to the Ractor that currently checked out the pooled object
- the Rust move maps to
Ractor::Port#send(..., move: true) - Rust's "cannot use after move" rule maps to Ruby raising
Ractor::MovedError - borrowing is not modeled;
Pooltransfers ownership instead of lending references
This design addresses issue #5,
where using a pooled object after with could lead to memory corruption or a
process crash.
The lower-level Ratomic::FixedSizeObjectPool native class may still exist, but
Ratomic::Pool does not inherit from it. The public Pool API is implemented
in Ruby so it can use Ruby's Ractor ownership primitives directly.
Contributing
Please read the Code of Conduct before contributing.
After changing code, run:
rake
This compiles the Rust code and runs the test suite. The test suite writes a
SimpleCov report to coverage/index.html for the Ruby wrapper paths.
Thanks
Ilya Bylich wrote and documented his original research at Ruby, Ractors, and Lock-free Data Structures.
This repo continues that research into the usability and limitations of Ractor-friendly structures in Ruby code and gems.