---
title: 'Process-Based Scaling'
source: 'https://academia.sh/en/courses/ruby-production/process-based-scaling'
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 memory of a forked process does not affect its parent'
    - 'Slices work across worker processes and collects the result through a pipe'
    - 'Shows that the worker count comes from an environment variable and the core count'
    - 'Explains that the determinant of process-based scaling is the environment'
---

# Process-Based Scaling

The worker count comes from the environment, not from the code. The lesson measures that the memory of a forked process does not reach its parent, that work can be sliced and collected through a pipe, and that the worker count is read from an environment variable and the core count.

The previous lesson showed that order determines the counter's result, and it measured a Ractor as one way to remove shared state. Process-based scaling reaches the same result by an older and more common path: each worker is a separate process that runs with its own memory, so there is no shared variable to begin with. One question remains: how many workers? The course's measuring axis applies here as well: **what determines this result?** This lesson's observation is the `worker count` row, and its determinant is **environment**.

In this course the word environment appears in two places. In M12/K03 the environment was the application's configuration layer: development, test, and production. Here the environment is one of the rule's three inputs: the clock, the core count, and the environment variables. The two are different things under the same name. M11/K03 showed how processes are born and how they talk through pipes; this lesson asks who determines how many of them there are. The core's `determinant.rb` file and this lesson's excerpt `worker.rb` are given below as they are. Both are source and have no output. The core is a model: the clock and the core count in `environment` come from the definition, not from the real machine.

```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
```

```ruby
# worker.rb — the worker count comes from an environment variable, or else from the core count
require_relative "determinant"

# Process-based scaling: the worker count comes from an environment variable, or else from the core count.
def worker_count(environment) = Integer(environment[:variables].fetch("WORKER_COUNT", environment[:cores]))
```

## A Forked Process Writes to Its Own Memory

**Forking** starts a copy of the running process. The copy inherits the parent's memory as it was at that moment, but from then on the two memories are separate. If the child changes a variable, the parent does not see it. The block below measures this separation: the child writes ninety-nine to the `value` variable and exits with code three; the parent reads the variable's value and the child's exit code.

```ruby
# child.rb — the child writes to its own memory, the exit code returns to the parent
value = 1
pid = fork do
  value = 99
  exit!(3)
end
Process.wait(pid)
p value
p $?.exitstatus
```

```
1
3
```

The parent sees one for `value`; the ninety-nine the child wrote did not reach the parent. The only thing that came back to the parent was the exit code. This is the isolation that a Ractor enforces by force, arriving for free from the operating system in processes. **RU5.** This measurement shows that forking separates the memory; it does not show how much space the separated memory takes. That question is in the next lesson.

## Slicing the Work and Collecting It Through a Pipe

Once the memory is separate, the result has to be carried back to the parent. The way to do that is a **pipe**. The block below splits the numbers from one to thirty into three slices of ten, hands each slice to a child, and the children write the slice number and the slice's sum to the same pipe. Since the order in which the children finish is not fixed, the parent sorts the results by number before printing.

```ruby
# slice.rb — the job is sliced across three workers, results are collected through a pipe
reader, writer = IO.pipe
pids = (0..2).map do |index|
  fork do
    reader.close
    slice = ((index * 10) + 1)..((index * 10) + 10)
    writer.puts "#{index} #{slice.sum}"
    writer.close
    exit!(0)
  end
end
writer.close
pids.each { |pid| Process.wait(pid) }
results = reader.each_line.map { |line| line.split.map(&:to_i) }.sort
p results
p results.sum { |_, total| total }
```

```
[[0, 55], [1, 155], [2, 255]]
465
```

The three slices gave fifty-five, one hundred fifty-five, and two hundred fifty-five; the total is four hundred sixty-five, the same as the sum of the numbers from one to thirty. The sort is part of the measurement: without it the lines would arrive in the order the children finished, and that order could change from run to run. **RU6.** This was measured for three children and a small job; the cost of starting a child can exceed the gain when the job is small, and that cost was not measured here.

## Where the Worker Count Comes From

The worker count is not hard-coded. A deployment can land on a four-core machine or an eight-core one, and the machine is expected to supply the number. `worker_count` looks first at the `WORKER_COUNT` environment variable and takes the core count if it is missing. The block below runs this definition in the core's base, then in an environment with the variable set, and then tries a value that is not a number.

```ruby
# worker_measure.rb — the worker count is read from the environment
require_relative "worker"

p worker_count(BASE.environment)
p worker_count(BASE.environment.merge(variables: { "WORKER_COUNT" => "3" }))
begin
  worker_count(BASE.environment.merge(variables: { "WORKER_COUNT" => "three" }))
rescue ArgumentError => e
  puts e.class
end
p determinant(->(r) { worker_count(r.environment) })
p determinant(->(r) { r.environment[:variables].fetch("WORKER_COUNT", "none") })
```

```
4
3
ArgumentError
["environment", "D- O- E+", 4]
["environment", "D- O- E+", "none"]
```

In the base the output is four: there is no environment variable, and the core count is four. When the variable is three, the count became three. The string `"three"` cannot be converted to an integer, so `ArgumentError` was raised; a wrongly written variable did not fall silently to the default, it blew up. On the fourth line the rule ran this computation in all three variants. Only the environment moved the result; the evidence is `D- O- E+` and the determinant is **environment**. The fifth line asks the same question without falling back to the core count: `"none"` if the environment variable is missing and `"3"` if it is set, and the determinant is again **environment**.

This output also shows a configuration decision: the worker count coming from the environment is not a flaw, it is a deliberate choice. The flaw would be code that does not know what the environment says. **RU7.** The core count is four in the model; on a real machine this number is read with `Etc.nprocessors` and differs from machine to machine.

```ruby
# cores.rb — the real core count is not printed, only that it exists is checked
require "etc"
p Etc.nprocessors.positive?
```

```
true
```

The real number was not printed because it differs on every machine; what was measured is that the number is greater than zero.

## The Bounding Measurement

Whether the rule also works in a forked process must be tested. In the block below, the sum of the second slice is computed in the child process, inside a call to `determinant`, and the result passes through the pipe to the parent with `Marshal`. The question is: what determines the slice's sum?

```ruby
# bound.rb — the rule classifies the child process's slice sum
require_relative "determinant"

reader, writer = IO.pipe
pid = fork do
  reader.close
  writer.write Marshal.dump(determinant(->(_r) { (11..20).sum }))
  writer.close
  exit!(0)
end
writer.close
Process.wait(pid)
p Marshal.load(reader.read)
```

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

The result is one hundred fifty-five and the evidence is `D- O- E-`; the determinant is **code**. The slice's sum comes from the numbers from eleven to twenty; the order in which the child finished, how many children there were, or how many cores the machine had does not change the sum. Process-based scaling separates two things: the value each worker computes belongs to the code, while how many workers run belongs to the environment. This separation matters in deployment: the same code gives the same total with four workers or with eight, but not in the same time.

A server that forks workers once, after the code is loaded, pays the forking cost once. This is called **pre-forking**, and the children share the parent's memory at first and copy only the pages they write to. **RU8.** The memory gain of pre-forking was not measured here; the assumption that the pages a child writes get copied depends on the memory measurement in the next lesson.

## Summary

- A forked process inherits the parent's memory but writes to its own copy; the only things returning to the parent are the exit code and whatever is written to the pipe.
- Work can be sliced and collected through a pipe; since the order in which the children finish is not fixed, the results are sorted by number.
- The determinant of the worker count is **environment**: it comes from the environment variable if there is one, otherwise from the core count, and its evidence has the form `D- O- E+`.
- An environment variable that is not a number raises `ArgumentError`; it does not fall silently to a default.
- A child's slice sum belongs to the code; how many workers run belongs to the environment.

## Next Step

Once the workers run with separate memory, the question arises of how many objects each one allocates. The next lesson measures why the same block allocates a thousand and one objects and why the first run costs more than the later ones.
