3 ways to make your ruby object thread-safe
… and check why 5600+ Rails engineers read also this
3 ways to make your ruby object thread-safe
Let’s say you have an object and you know or suspect it might be used (called) from many threads. What can you do to make it safe to use in such a way?
1. Make it stateless & frozen
Here is the most basic approach which is sometimes the easiest to go with and also very safe.
Make your object state-less. In other words, forbid an object from having any long-term internal state.
That means, use only local variables and no instance variables (@ivars
).
To be sure that you are not accidentally adding state, you can freeze the object. That way it’s going to raise an exception if you ever try to refactor it.
class MyCommandHandler
def initialize
make_threadsafe_by_stateless
end
def call(cmd)
local_var = cmd.something
output(local_var)
end
private
def make_threadsafe_by_stateless
freeze
end
def output(local_var)
puts(local_var)
end
end
CMD_HANDLER = MyCommandHandler.new
Since your object does not have any local state it can be freely used between multiple threads. It’s ok for example to use the same instance when processing different requests in a threaded application server such as Puma.
You can assign MyCommandHandler.new
to a global variable or pass as a dependency to objects created in different threads and things should be fine.
If someone from your team tries to refactor the code to:
class MyCommandHandler
def initialize
make_threadsafe_by_stateless
end
def call(cmd)
@ivar = cmd.something
output
end
private
def make_threadsafe_by_stateless
freeze
end
def output
puts(@ivar)
end
end
they are going to get an exception can't modify frozen MyCommandHandler
unless they remove make_threadsafe_by_stateless
in which case it’s a conscious decision, not an accidental one.
For years I struggled a bit when thinking about thread-safety and which objects can be used between threads and which can’t be and whether I need to make something thread-safe or not. Later I realized it’s not as much about a single object’s properties but rather about a graph of objects.
Imagine a situation like this:
class MyCommandHandler
def initialize(repository, adapter)
@repository = repository
@adapter = adapter
make_threadsafe_by_stateless
end
def call(cmd)
obj = @repository.find(cmd.id)
obj.do_something
@repository.update(obj)
@adapter.notify(SomethingHappened.new(cmd.id))
end
private
def make_threadsafe_by_stateless
freeze
end
end
CMD_HANDLER = MyCommandHandler.new(Repository.new, Adapter.new)
If CMD_HANDLER
is used between multiple threads, then its dependencies are as well.
That means that thread-safety is more a property for a graph of objects (object and its dependencies and their dependencies etc) rather than a property of a single object.
In this case, it’s not enough that MyCommandHandler
is stateless and thread-safe. Its dependencies should be as well for the whole solution to work properly.
2. Use thread-safe structure for local state
If you know that an object can be used between multiple threads you can compartmentalize its state per thread. For that you can use ThreadLocalVar
from concurrent-ruby
project:
ThreadLocalVar: Shared, mutable, isolated variable which holds a different value for each thread which has access. Often used as an instance variable in objects which must maintain different state for different threads
require 'concurrent'
class Subscribers
def initialize
@subscribers = Concurrent::ThreadLocalVar.new{ [] }
end
def add_subscriber(subscriber)
@subscribers.value += [subscriber]
end
def notify
@subscribers.value.each(&:call)
end
def remove_subscriber(subscriber)
@subscribers.value -= [subscriber]
end
end
SUBSCRIBERS = Subscribers.new
SUBSCRIBERS
can be used from within different threads because its state is different for every thread that uses it. @subscribers.value
is a different Array
for every thread. This might be useful and what you want/expect. But it also might not.
In RailsEventStore we use this pattern to keep a list of short-term handlers interested in events published by the current thread. For example, an import process can collect stats about the number of ProductImported
and ProductImportErrored
events that occur when parsing and processing an XLSX file.
3. Protect the state with mutexes
In this approach, the object’s state is shared between all threads but the access is limits to a single thread at once.
require 'thread'
class Subscribers
def initialize
@semaphore = Mutex.new
@subscribers = []
end
def add_subscriber(subscriber)
@semaphore.synchronize do
@subscribers += [subscriber]
end
end
def notify
@semaphore.synchronize do
@subscribers.each(&:call)
end
end
def remove_subscriber(subscriber)
@semaphore.synchronize do
@subscribers -= [subscriber]
end
end
end
SUBSCRIBERS = Subscribers.new
Although to be honest, I am not sure if synchronize
is needed for a method which does not change the state such as notify
… (discussion on reddit)
But instead of going that way, you might prefer to use already existing classes such as Concurrent::Array
and going with the previous method.
A thread-safe subclass of Array. This version locks against the object itself for every method call, ensuring only one thread can be reading or writing at a time. This includes iteration methods like #each.
require 'concurrent'
class Subscribers
def initialize
@subscribers = Concurrent::Array.new
end
def add_subscriber(subscriber)
@subscribers << subscriber
end
def notify
@subscribers.each(&:call)
end
def remove_subscriber(subscriber)
@subscribers.delete(subscriber)
end
end
SUBSCRIBERS = Subscribers.new
4. Bonus: New instance per usage
Your object does not need to be explicitly made thread safe if:
- you don’t share it between threads ;)
- ie. by always creating a new instance when it’s needed
class MyCommandHandler
def initialize(repository, adapter)
@repository = repository
@adapter = adapter
end
def call(cmd)
@obj = @repository.find(cmd.id)
@obj.do_something
@repository.update(@obj)
@adapter.notify(SomethingHappened.new(cmd.id))
end
end
CMD_HANDLER = -> (cmd) { MyCommandHandler.new(Repository.new, Adapter.new).call(cmd) }
Here, in case our application threads use CMD_HANDLER.call(...)
then we don’t need to worry about thread-safety because every time we need MyCommandHandler
, we instantiate a new object with the whole dependency tree. The dependencies (repository, adapter
) can use any of the mentioned techniques to be thread-safe as well, or they can be new instances as well.
And here lies the reason that many classes are not thread-safe in Ruby by default. They are simply not expected to be used from multiple threads. The authors did not imagine such use-case for them. That’s OK. The bigger issue, in my opinion, is that it’s often hard to find info about classes coming from some gems about their thread-safety.
Would you like to continue learning more?
If you enjoyed that story, subscribe to our newsletter. We share our every day struggles and solutions for building maintainable Rails apps which don’t surprise you.
You might enjoy reading:
- Ruby Event Store - use without Rails - did you know you can use RailsEventStore without Rails by going with RubyEventStore :)
- Relative Testing vs Absolute Testing - 2 modes of testing that you can switch between to make writing tests easier.
- Using ruby parser and the AST tree to find deprecated syntax - when grep is not enough for your refactorings.
- Big events vs Small events - from the perspective of refactoring - after working for several years in an event sourced system, Andrew noticed many problems connected with big events