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