When you are using Unicorn or Phusion Passenger (Community Edition), you typically don’t have to worry about thread safety because these servers are multi-process (worker-based).
Read more about application servers here.
However, Puma comes with multi-threaded options. In the MRI ecosystem, we often don’t prioritize writing thread-safe code, which can cause issues in a multi-threaded environment like Puma. If you are migrating your application server from Unicorn or Passenger to Puma (with multi-threaded mode enabled), make sure you follow these tips.
1. Good Practice: Use Constants and Freeze Them!
Ruby does not provide immutable data structures by default; even constants are mutable in Ruby’s world.
2.5.0 :001 > IAM_CONSTANT = "test"
=> "test"
2.5.0 :002 > IAM_CONSTANT = "new_test"
(irb):2: warning: already initialized constant IAM_CONSTANT
(irb):1: warning: previous definition of IAM_CONSTANT was here
=> "new_test"
2.5.0 :003 > IAM_CONSTANT
=> "new_test"
2.5.0 :001 > IAM_ARRAY_CONSTANT = ["Haha! I am constant, no one can change me!"]
=> ["Haha! I am constant, no one can change me!"]
2.5.0 :002 > IAM_ARRAY_CONSTANT << "Seriously?"
=> ["Haha! I am constant, no one can change me!", "Seriously?"]
2.5.0 :003 >
To prevent mutation, use .freeze:
2.5.0 :001 > IAM_ARRAY_CONSTANT = ["Haha! I am constant, no one can change me!"].freeze
=> ["Haha! I am constant, no one can change me!"]
2.5.0 :002 > IAM_ARRAY_CONSTANT << "Seriously?"
Traceback (most recent call last):
2: from /Users/satyanarayan/.rvm/rubies/ruby-2.5.0/bin/irb:11:in `<main>'
1: from (irb):2
FrozenError (can't modify frozen Array)
2. Don’t Mutate Global Variables
I have worked with legacy codebases where the current user was determined using a global variable. While this might work in a multi-process server like Passenger, it will fail in a multi-threaded environment like Puma.
class UsersController < ApplicationController
before_action :authenticate_user
def authenticate_user
$user_id = fetch_user_from_cookies
sleep(10)
if $user_id == "admin"
Rails.logger.info("Show admin template")
else
Rails.logger.info("Show normal template")
end
end
def index
render json: ["Hey, I am working fine"]
end
end
In a multi-threaded environment, $user_id is shared across all threads, leading to race conditions where one user’s ID might overwrite another’s.
3. Class Variables and Class Instance Variables are Not Thread-Safe
If you are mutating class variables or class instance variables within a class method, your code is likely not thread-safe and could lead to unpredictable behavior across requests.
4. Memoization: Use it Wisely
Memoization is a great practice for caching values across multiple calls within an object. However, be cautious when memoizing inside class methods, as those values will be shared across all requests in a multi-threaded environment.
class UsersController < ApplicationController
before_action :fetch_user
def fetch_user
@user = User.find_user_once(@user_id)
end
def index
render json: ["Hey, it works"]
end
end
class User
def self.find_user_once(user_id)
@test ||= User.find(user_id) # This is shared across all threads!
end
end
5. Audit Your Gems
Before adding a new gem to your project, read the documentation or source code to confirm that the library is thread-safe.