#ruby

Frozen middleware with Rack freeze

Atul Bhosale's avatar

Atul Bhosale

One of my favourite pastimes is to go through GitHub issues for libraries I like. One of those is the Rack gem, where I found an issue titled "Middleware should be frozen by default". A couple of questions I had were: What exactly is a frozen middleware? and why should that be done?

Example: Web request count

As a simple first example, let's consider a Rack middleware which counts the number of requests received by the server. A very simple (and broken) implementation might look like this:

class Counter
  def initialize
    @counter = 0
  end
 
  def call(_env)
    counter = @counter
    sleep 1
    counter += 1
    @counter = counter
    [200, { 'Content-Type' => 'text/html' }, ["#{@counter}"]]
  end
end

The @counter instance variable gets incremented each time call method gets called, which happens for every request. If you're not familiar with what a middleware is, or how they get used, these resources might be useful:

Rack Middlewares on Railscasts Understanding Rack apps and Middleware Introduction to Rack Middleware Middleware recipes on Sinatra Recipes

Running this application in a single threaded environment results in the following output:

request_count

You can run this is in single threaded mode as -

Rack::Server.start :app => Counter.new, server: :puma, max_threads: 1, min_threads: 1

Running this in a multi-threaded environment, however, results in the following output:

request_count

In the multi-threaded environment, the counter doesn't increment. This is called a race condition, and occurs when two or more threads can access shared data and they try to change it at the same time. Because the thread scheduling algorithm can swap between threads at any time, you don't know the order in which the threads will attempt to access the shared data. Hence, the result of the change in data is dependant on the thread scheduling algorithm i.e. both threads are racing to access/change the data.

Achieving thread-safety

When we want to avoid thread safety issues in multi-threaded environments we have some options:

  1. Not mutate the state in the middleware
  2. Freeze middleware instances to catch the thread-safety issues in the middleware that you didn't write yourself.
  3. Use data structures from the concurrent-ruby gem.

How do we do this?

Example: Web request thread-safe count

class Counter
  def initialize
    @atomic = Concurrent::AtomicReference.new(0)
  end
 
  def call(_env)
    @atomic.update { |v| v + 1 }
    [200, { 'Content-Type' => 'text/html' }, ["{@atomic}"]]
  end
end

The word atomic it means that the contents of the block are executed to completion without other threads being able to read/modify the value (note that this is not same as a mutex). Multiple threads attempting to change the same AtomicReference object will not make it end up in an inconsistent state.

Freezing middleware instances

Rack middleware is initialized only on the first request of the process. So any instance variables acts like class variables, and modifying them in call() isn't thread-safe. It's necessary to dup the middleware to be thread-safe. A middleware should be frozen to avoid potential issues with handling concurrent requests. Rack recently introduced a freeze_app method to freeze middleware instances. An example usage of that would be:

use (Class.new do
  def call(env)
    @a = 1 if env['PATH_INFO'] == '/a'
    @app.call(env)
  end
  freeze_app
end)

In this example, we are initializing an instance variable to 1 when we hit the /a url. We call freeze_app method in the middleware. When we run this program, and hit /a multiple times the freeze_app method will notify us that there is a problem by raising an exception, which you wouldn’t otherwise know:

FrozenError: can't modify frozen #<Class:0x00007f9b0d1e95b0>

The server will respond with 200 for all other URLs because we are not modifying the instance variable in those. Internally freeze_app method calls a .freeze on the middleware instances.

Can I unfreeze a frozen object?

No, it's not possible in MRI and JRuby.

Hope you found this useful.