Parallel work without a framework
A worker pool
A pool is a queue of jobs and a fixed number of threads that take them. The queue does the locking; the pool decides how many run at once:
count = Integer(ARGV.fetch(0, "4"))
jobs = Queue.new
results = Queue.new
1.upto(8) { |i| jobs << i }
workers = count.times.map do
Thread.new do
loop do
job = begin
jobs.pop(true)
rescue ThreadError
break
end
results << job * job
end
end
end
workers.each(&:join)
values = []
values << results.pop until results.empty?
p values.sort
ruby workers.rb 4
[1, 4, 9, 16, 25, 36, 49, 64]
pop(true) is the non-blocking form: it raises ThreadError when the queue is empty, and the worker exits. That is the shortest pool that finishes, and it works because all the jobs are queued before the workers start.
Ending the pool
A pool that reads jobs while they arrive cannot use the empty check: a worker would exit before the next job is pushed. The reliable end is a sentinel per worker:
count.times { jobs << :stop }
workers = count.times.map do
Thread.new do
while (job = jobs.pop) != :stop
results << job * job
end
end
end
One :stop per worker, pushed after the last job, means every worker gets one and none blocks forever. A single sentinel leaves the other workers waiting on an empty queue, which is the hang that a timeout in the test would catch.
Fork for CPU work
Squares are cheap; real CPU work in Ruby needs processes because of the GVL. The same pool shape with fork uses every core:
ruby -e 'pids = 4.times.map { |i| fork { sleep 0.1; exit!(i) } }; p pids.map { |pid| Process.wait2(pid).last.exitstatus }'
[0, 1, 2, 3]
A real job pool splits the input into ranges, forks one process per range and collects the results through a pipe or a temporary file. The parent waits on all of them and fails the run when any exitstatus is not zero.
Ractor in one example
A Ractor is Ruby's actor: a unit of parallelism with its own objects, communicating by messages. One example is enough to see the shape:
ruby -e 'r = Ractor.new { 1 + 1 }; p r.take'
-e:1: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues. 2
The warning is part of the output on Ruby 3.4 and should be read as it says: the API works, the implementation is not finished, and a gem that depends on it is early. Ractor.new { } takes arguments and returns values by message, and only shareable objects can cross the boundary.
What to watch for
More workers than cores makes a CPU pool slower, not faster: the context
switches cost more than the work. Start with the number of cores, measure,
and stop there.
A pool needs a bounded queue and a failure path. A producer that outruns
the consumers grows the queue until the memory goes; a worker that raises
must report the error to the parent, or the job silently disappears.
Next: What a CI pipeline is
Article author: Arthur Isaev