---
title: 'The Concurrency Model'
source: 'https://academia.sh/en/courses/ruby-production/the-concurrency-model'
course: 'The Ruby Ecosystem and Production'
language: en
updated: '2026-10-21T05:03:44+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that the result of an unlocked counter depends on the order of the threads'
    - 'Explains that the global interpreter lock does not protect the gap between a read and a write'
    - 'Shows that a mutex and a queue make the result independent of order'
    - 'Measures that a Ractor removes shared state but that freezing stays shallow'
---

# The Concurrency Model

The same code with the same inputs giving different numbers is an order problem. The lesson measures how a switch between a read and a write loses a count, how a mutex lets the result come from the code, and how a Ractor removes shared state.

The previous lesson showed that a test that reads the clock can fail tomorrow: the environment determined that result. This lesson turns to order. When four threads increment a counter a thousand times each, four thousand is expected; the counter sometimes returns less, and the code has not changed. The course's measuring axis continues: **what determines this result?** The answer is one of four names, printed in the output as plain ASCII strings: `code`, `dependency`, `order`, `environment`. This lesson's observation is the `unlocked counter` row, and its determinant is **order**.

M08/K04 asked which thread a step lands on. Here the question is not the identity of the thread but whether the order changes the result. The core's `determinant.rb` file is given below as it is; the block is source, it has no output, and it is pulled in with `require_relative`. The `lock` in the core is the contents of the lock file, that is, the installed versions. A mutex shares only the word with it, so this lesson names its mutex variable `mutex`.

```ruby
# determinant.rb — a run model and a helper that classifies what determines a result

# Run: the three inputs a result may depend on. Everything outside these three is code.
Run = Data.define(:lock, :seed, :environment)

BASE = Run.new(
  lock: { "formatter" => "1.4.2", "parser" => "2.0.1" },
  seed: 1,
  environment: { clock: Time.utc(2026, 10, 16, 9, 0), cores: 4, variables: {} }
)

# Each variant changes a single input; the others stay as in the base.
VARIANTS = {
  dependency: BASE.with(lock: { "formatter" => "1.5.0", "parser" => "2.1.0" }),
  order: BASE.with(seed: 2),
  environment: BASE.with(environment: { clock: Time.utc(2027, 3, 1, 9, 0), cores: 8,
                                        variables: { "WORKER_COUNT" => "3" } })
}

# Rule: the computation runs first in the base, then once in each variant. The first variant
# that changes the result (dependency, order, environment, in that order) is the determinant;
# if none changes it, code.
def determinant(computation)
  base = computation.call(BASE)
  changed = VARIANTS.to_h { |name, run| [name, computation.call(run) != base] }
  kind = changed.key(true)&.to_s || "code"
  evidence = changed.map { |name, c| "#{name[0].upcase}#{c ? '+' : '-'}" }.join(" ")
  [kind, evidence, base]
end
```

## The Gap Between a Read and a Write

The core's counter is not made of real threads; it is a **scheduler model**. Two threads read the counter, write one more than they read, and each makes three increments. A switch can happen between the read and the write; the `seed` picks which thread takes the next step. The correct result is six. The model stands in for real threads because the order of real threads can change from run to run, and the printed number would not come out the same twice. The seed fixes the order, so the broken result comes out the same on every run and can be measured.

```ruby
# counter.rb — scheduler model: unlocked counter
# Scheduler model: two threads read the counter and write one more; a switch can happen
# between the read and the write. The seed picks which thread takes the next step.
def unlocked_counter(run, increments: 3)
  random = Random.new(run.seed)
  counter = 0
  jobs = Array.new(2) { { left: increments, read: nil } }
  loop do
    waiting = jobs.select { |j| j[:left].positive? }
    break if waiting.empty?

    job = waiting.sample(random: random)
    if job[:read].nil?
      job[:read] = counter
    else
      counter = job[:read] + 1
      job[:read] = nil
      job[:left] -= 1
    end
  end
  counter
end
```

```ruby
# orders.rb — unlocked counter: determinant and six seeds
require_relative "determinant"
require_relative "counter"

p determinant(->(r) { unlocked_counter(r) })
results = (1..6).map { |seed| unlocked_counter(BASE.with(seed: seed)) }
p results
```

```
["order", "D- O+ E-", 6]
[6, 3, 5, 4, 4, 4]
```

The first line is the core's result: in the base the counter is six, and the evidence holds only `O+`. Changing the lock file or the environment does not move the result; changing the seed does. The second line shows the results of six seeds: four different values appear, and only one of them gives the correct six. The lost increments come from two threads reading the same value and writing the same value. **RU1.** These results hold only for two threads and three increments each; the loss rate with more threads cannot be read from this measurement.

## What the Global Interpreter Lock Does Not Protect

Ruby's **global interpreter lock** lets only one thread run Ruby code at a time. This prevents two threads from writing to the same memory location at the same moment, but it does not protect the interval between a thread's read and its write. `counter += 1` looks like a single step, yet it is a read, an addition, and a write. The scheduler can switch to another thread between those three steps, and that is exactly what the model does. For this reason an unlocked counter written with real threads is not printed in this lesson: its result can change from run to run.

A design in which order does not change the result closes the gap. A **mutex** admits only one thread into a region at a time. The block below creates four real threads, each increments the counter a thousand times, and every increment happens inside the `mutex`.

```ruby
# mutex.rb — four real threads, with a mutex
require_relative "determinant"

def mutex_counter(threads: 4, increments: 1000)
  counter = 0
  mutex = Mutex.new
  jobs = Array.new(threads) do
    Thread.new do
      increments.times { mutex.synchronize { counter += 1 } }
    end
  end
  jobs.each(&:join)
  counter
end

p mutex_counter
p determinant(->(_r) { mutex_counter })
```

```
4000
["code", "D- O- E-", 4000]
```

The result is four thousand. The rule ran this computation in all three variants and the result did not change in any of them; the evidence is `D- O- E-` and the determinant is **code**. The unlocked model depended on order, while the real counter written with a mutex depends on its own code. **RU2.** The three variants of this rule do not directly change the order of real threads: the `seed` drives only the scheduler in the model. Four thousand does not prove that the mutex preserves the result when the order changes; it says only that the result is consistent in this run.

## The Queue and an Exception in a Thread

Locking shared state is not the only way. If a producer and a consumer talk through a **queue**, the value passes from one hand to the other and the two sides never write to the same variable. In the block below the producer puts the numbers from zero to four on the queue and the consumer adds them up.

```ruby
# queue.rb — a producer and a consumer talk through a queue
queue = Thread::Queue.new
producer = Thread.new do
  5.times { |i| queue << i }
  queue << :done
end
total = 0
consumer = Thread.new do
  while (item = queue.pop) != :done
    total += item
  end
end
[producer, consumer].each(&:join)
p total
```

```
10
```

The total is ten. Only the consumer writes the `total` variable, so there is no other writer to slip in between a read and a write. The queue preserves order, but the result is independent of order as well: the order of the additions does not change the sum.

An exception that blows up in a thread does not vanish silently; it is raised again in the main thread at the `join` call. So that nothing is written to standard error, the block first turns reporting off. The exception message is in the lesson's own `raise` call.

```ruby
# exception.rb — an exception in a thread returns to the main thread at join
Thread.report_on_exception = false
job = Thread.new { raise "exploded" }
begin
  job.join
rescue => e
  puts "caught: #{e.class} #{e.message}"
end
p job.status
```

```
caught: RuntimeError exploded
nil
```

The exception was caught at `join` as a `RuntimeError`. The thread's status is `nil`: the thread ended with an exception. Without the `join` call the exception would never have reached the main thread. **RU3.** This measurement covers one thread and one exception; several threads blowing up at the same time was not measured here.

## The Bounding Measurement

A mutex protects shared state against order. A **Ractor** opens another path: a Ractor is an execution unit that runs with isolated memory and cannot reach local variables outside it. Shared state does not exist from the start, so there is nothing left to protect. The first block measures that a Ractor returns a value, and the second measures that a block touching an outside variable is rejected.

```ruby
# ractor.rb — an execution unit that runs with isolated memory
p Ractor.new { (1..10).sum }.value

outer = [1, 2, 3]
begin
  Ractor.new { outer.size }
rescue ArgumentError => e
  puts e.class
end
```

```
55
ArgumentError
```

The first Ractor returned fifty-five. The second was rejected with `ArgumentError`: the block was reading the `outer` variable outside it. Isolation removes shared state, not order. The order is still undetermined, but since two Ractors cannot write to the same variable, there is no place left where the order could spoil the result. For an object to be shared between Ractors it must be a **shareable object**. Freezing it is not enough.

```ruby
# shareable.rb — freezing is shallow
plain = [1, 2].freeze
inner_mutable = [+"a"].freeze
p Ractor.shareable?(plain)
p Ractor.shareable?(inner_mutable)
p Ractor.shareable?(Ractor.make_shareable([+"a"]))
```

```
true
false
true
```

A frozen array made of numbers is shareable. A frozen array that holds an unfrozen string is not: freezing freezes only the array itself, not what is inside it. `make_shareable` freezes the contents as well, and the result is shareable. **RU4.** These three results were measured on Ruby 4.0.7; the rules for Ractors can change between versions.

The bounding result is this: a Ractor does not destroy the rule's `order` category. The unlocked model depended on order because two threads shared a variable. When the sharing goes away, order no longer determines the result; as long as the sharing stays, order is the determinant.

## Summary

- **Order** determines the unlocked counter: with the same code, the same environment, and the same lock file, six seeds gave four different results, and only one was correct.
- The global interpreter lock does not protect the gap between a read and a write; `counter += 1` is three steps.
- The mutex fixed the counter at four thousand and the rule returned the category `code`; order no longer determines the result.
- A queue passes a value from one hand to the other; an exception in a thread is raised again at the `join` call.
- A Ractor removes shared state; freezing is shallow, and a frozen array holding an unfrozen string is not counted as shareable.

## Next Step

The question of why the unlocked counter sometimes counted fewer than six has its answer: order determined the counter. The next question is how many parts the work will be split into. The number of forked worker processes comes from an environment variable or from the core count; the next lesson measures where that number is read from and why the memory of a forked process does not show up in its parent.
