Introduction
Ruby 3.0 introduced two powerful concurrency primitives: Fiber schedulers and Ractors, besides the already existing Threads:
- Threads are the classic concurrency primitive in Ruby, and mostly map to native operating system threads. This way any system calls, IO, or other blocking operations will block a thread, allowing another one to run. However, on the official Ruby implementation (often referred to as CRuby or MRI), Threads cannot run in parallel due to the Global VM Lock (GVL). This means that even if you have multiple threads, only one of them can execute Ruby code at a time.
- Ractors provide parallelism, even on CRuby. They make it very hard to introduce concurrency issues between them, but put severe limitations on state sharing and cross-ractor communication. Ractors are only supported by CRuby, but that is generally not an issue, as other implementations, such as JRuby and TruffleRuby, support true parallelism with Threads. As of Ruby 4.0, Ractors are still considered experimental.
- Fiber schedulers allow you to run multiple Fibers concurrently on a single Thread, without having to explicitly pass control between them. This is a very efficient way to run concurrent code, especially for IO-bound workloads. As of Ruby 4.0, Fiber schedulers with IO support are still considered experimental.
All three of these need some form of concurrency coordination for shared state. These are usually the most complicated for Threads, as state is shared between them freely, and an interrupt can happen at any time. Fibers on the other hand have explicit control transfer (only on blocking operations or when giving up control), so their behavior is much more predictable. Ractors disallow sharing mutable state between them (or have built-in locking for the few cases where they allow it).
The Problem
Note
Please keep in mind that many of these libraries may gain better Ractor support in the future, and that the table might already be outdated. Feel free to open an issue if you find any inaccuracies. This is also in no way meant as a criticism of any of the libraries mentioned. They all have their own scopes, goals and priorities, and largely rely on volunteer contributions.
Both the Ruby core library, and other libraries, like the very popular concurrent-ruby gem, provide plenty of primitives to solve this. However, most of them are not compatible with Ractors. And those that are, are then in turn usually not compatible with Fiber schedulers. Due to this, most of the popular frameworks and libraries out there do not support Ractors at all:
| Library | Example classes | Multithreading | Non-blocking Fibers | Cross-Ractor Usage |
|---|---|---|---|---|
| Ruby | Array, Hash, String, Set, … | ⚠️ not thread-safe | ✅ supported | ⚠️ immutable/copy/move only |
| Thread, Fiber | ✅ supported | ✅ supported | ❌ not supported | |
| Mutex, ConditionVariable, Queue | ✅ supported | ⚠️ CRuby only | ❌ not supported | |
| Ractor, Ractor::Port | ✅ supported | ❌ not supported | ✅ supported | |
| WeakRef, Ruby::Box | ⚠️ Main Ractor only | ⚠️ Main Ractor only | ❌ not supported | |
| concurrent-ruby | Future, Exchanger, Tuple, … | ⚠️ Main Ractor only | ⚠️ Main Ractor only | ❌ not supported |
| async | Promise, Condition, Queue, … | ✅ supported | ✅ supported | ❌ not supported |
| Scheduler | ⚠️ Main Ractor only | ⚠️ Main Ractor only | ❌ not supported | |
| ratomic | Pool | ⚠️ CRuby only¹ | ❌ not supported | ⚠️ CRuby only¹ |
| LocalPool | ⚠️ CRuby only¹ | ⚠️ CRuby only¹ | ⚠️ CRuby only¹ | |
| Queue, Map | ⚠️ CRuby only¹ | ❌ not supported | 💣 breaks isolation¹² | |
| ractor_queue | RactorQueue | ⚠️ CRuby only¹ | ⚠️ CRuby only¹ | 💣 breaks isolation¹² |
| hamster / immutable | Hash, Vector, Set, List, … | ⚠️ Main Ractor only | ⚠️ Main Ractor only | ❌ not supported |
| ractor-sharing | TVar, LockVar, LockHash, … | ⚠️ CRuby only¹ | ❌ not supported | ⚠️ CRuby only¹ |
| ractor_safe | HashMap, AtomicInteger | ⚠️ CRuby only | ⚠️ CRuby only | ⚠️ CRuby only |
| Queue | ⚠️ CRuby only | ❌ not supported | ⚠️ CRuby only | |
| atomic-ruby | AtomicQueue, AtomicThreadPool | ⚠️ CRuby only | ⚠️ CRuby only | ❌ not supported |
| Atom, AtomicCountDownLatch, AtomicBoolean | ⚠️ CRuby only¹ | ⚠️ CRuby only¹ | ⚠️ CRuby only¹ | |
| AtomicConditionVariable | ⚠️ CRuby only | ❌ not supported | ❌ not supported |
Notes:
- Incorrectly flags mutable objects as frozen.
- Breaking Ractor isolation introduces concurrency issues not just in application code, but in Ruby itself, possibly leading to segmentation faults or undefined behavior. Note that
ractor_queuehas the ability to enforce shareability, but this feature is opt-in.
The Solution
Farce is an attempt to solve this. Code relying on Farce will work well with any concurrency primitive. Use an event loop with the async gem? Farce will fit right in. Running a pool of Ractors with Kino? Farce got you covered! You manually manage a single background thread? Farce solves that, too!
And it does so in a non-invasive way. You should be able to use Farce alongside any other gem!
# Using a counter as an example, there are many more classes provided by Farce
counter = Farce::Counter.new
# Works in code without concurrency
counter.add(5)
counter.to_i # => 5
# Works with threads
wait_for = 5.times.map do
Thread.new { counter.increment }
end
# Works with ractors
wait_for += 5.times.map do
Ractor.new(counter) { it.increment }
end
# Works with the async gem
Async do
5.times do
Async { counter.increment }
end
end
wait_for.each(&:join)
counter.to_i # => 20
Farce also aims to be fast and efficient, with performance ranging from minimal overhead to outperforming other options.
It does so by choosing the best implementation for the situation. The counter in the above example will use Java's AtomicLong on JRuby and TruffleRuby in GraalVM mode, an AtomicReference on TruffleRuby in native mode, and an atomic, native counter on CRuby, so it runs without having to use any locks on any of these platforms.
This is especially useful when coordinating work between Fiber schedulers and Ractors:
queue = Farce::Queue.new
# Background ractor pushing work into the queue
# This would not work with Ruby's built-in Queue
Ractor.new(queue) do |queue|
loop { queue.push expensive_work }
end
# This would not work with Ratomic::Queue
Async do
# Task waiting for work from the queue
Async { loop { do_something queue.pop } }
# Doesn't get blocked by the other task waiting for the queue
Async { }
end
Example
A map and a mutable string can be shared across Ractors. The transaction keeps their updates together. This example deliberately creates many Ractors. For application workloads, use a pool to reuse workers.
require "farce"
map = Farce::Map.new
output = Farce::Mutable.new "Hello from Farce! The map has 0 entries, with a total sum of 0."
# Mutate output in another Ractor - this would not work with a String.
# Using Farce::Ractor here instead of Ractor so it also works on JRuby and TruffleRuby.
ractor = Farce::Ractor.new(output) do |output|
sleep rand # I laugh in the face of race conditions. Ha, ha, ha, ha!
output.gsub!("Farce", "✨ Farce ✨")
end
# No idea if the above ractor is done modifying output, but we don't need to worry!
# We'll just pick a random key one hundred times and increase its counter by one.
# Oh, and update the output string.
100.times.map do
# And of course we need to do it all in parallel for maximum performance!
# Let's ignore the fact that this would be much faster if we didn't.
# Starting 100 ractors is the main performance issue here.
# But that wouldn't be an interesting example, right?
Farce::Ractor.new(output, map) do |output, map|
key = %i[foo bar baz].sample
# Wrap the modifications in a transaction, so map and output never disagree
Farce.transaction(output, map) do |_, output, map|
# Increase the value we have for the given key by one!
map[key] ||= 0
map[key] += 1
# Could also replace the lines above with:
#
# map.upsert(key, 1) { it + 1 }
#
# … which would be atomic.
# But we're in a transaction, so everything is atomic! ☢️
output.sub!(/\d+ entries/, "#{map.size} entries")
output.sub!(/sum of \d+/, "sum of #{map.values.sum}")
end
end
end.each(&:join)
# Let's be sure that first ractor has completed its modification
ractor.join
# Hello from ✨ Farce ✨! The map has 3 entries with a total sum of 100.
puts output