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