In light of a recent PR, I spent some time learning more about thread safety in Ruby. We often overlook it since we primarily deal with object instances and transactions. However, we needed to store a request-global state to disable model validation for a specific transaction type. The straightforward way is using a class-level attribute.
See it with my own eyes
I set up a test case with a simple controller that mimics the behavior. It generates a random value, stores it in a class variable, retrieves it, and raises an error if the value changes unexpectedly:
class ConcurrentController < ApplicationController
def index
val = Random.rand
# set a class val
SomeService.class_val = val
sleep(0.01)
# retrieve class val
raise "Error" if SomeService.class_val != val
render plain: "OK"
end
class SomeService
class << self
attr_accessor :class_val
end
end
end
When you call this in the browser, it returns “OK” consistently. However, using siege, a load testing tool (brew install siege
), reveals issues.
I created a urls.txt
file with the server URL, http://localhost:3000/concurrent/index.
Running siege with the code gives the following result:
> siege --time=5s --concurrent=10 -f urls.txt -i
...
HTTP/1.1 200 0.15 secs: 2 bytes ==> GET /concurrent/index
HTTP/1.1 500 0.18 secs: 8740 bytes ==> GET /concurrent/index
HTTP/1.1 200 0.12 secs: 2 bytes ==> GET /concurrent/index
HTTP/1.1 500 0.18 secs: 8740 bytes ==> GET /concurrent/index
Lifting the server siege...
Transactions: 51 hits
Availability: 31.87 %
Elapsed time: 5.89 secs
Data transferred: 0.91 MB
Response time: 1.14 secs
Transaction rate: 8.66 trans/sec
Throughput: 0.15 MB/sec
Concurrency: 9.89
Successful transactions: 51
Failed transactions: 109
Longest transaction: 3.28
Shortest transaction: 0.12
The service, which appears flawless manually, only has a success rate of 32% under load. 🤯
Why is that?
The main reason is that we store the state class_val
in a non-thread safe variable, shared across threads. While one thread sleeps, the value is modified by another.
There’s a whole theory behind this, involving Mutexes and locking. But there is …
An easy way out
Rails includes the CurrentAttributes
module for thread-safe operations (usually per request). It’s often used for current_user
.
In our case, modifying the service like this:
class SomeService
class Current < ActiveSupport::CurrentAttributes
attribute :class_val
end
class << self
def class_val=(val)
Current.class_val = val
end
def class_val
Current.class_val
end
end
end
… and running siege again, yields:
Lifting the server siege...
Transactions: 1295 hits
Availability: 100.00 %
Elapsed time: 5.93 secs
Data transferred: 0.00 MB
Response time: 0.05 secs
Transaction rate: 218.38 trans/sec
Throughput: 0.00 MB/sec
Concurrency: 9.96
Successful transactions: 1295
Failed transactions: 0
Longest transaction: 0.14
Shortest transaction: 0.03
💯 % success rate.
TL/DR
💡 Use a thread-safe variable for storing global state. In Rails, use CurrentAttributes
.
siege
is a simple tool for load testing applications locally.
I plan to explore the Concurrent ruby gem for concurrency patterns.
📖 References