Ractor::Pipeline
A DSL to build stream processing pipelines with Ractors. The basic model is
close to a Unix shell pipeline: stream produces an input stream, each
pipe/filter_pipe stage is a persistent Ractor connected by
Ractor::Ports, and terminal operations such as reduce consume the output
in the caller Ractor.
Unlike enum.map{}.filter{}, which describes only data operations, this DSL
intentionally couples data operations with the execution topology: what you
see in the code is the Ractor graph that runs.
require "ractor/pipeline"
include Ractor::Pipeline
# conceptually: cat FILE | grep foo | wc
stream(File.foreach(name)).
filter_pipe(lanes: 4){ it.include?("foo") }.
reduce([0, 0, 0]) do |(lines, words, bytes), line|
[lines + 1, words + line.scan(/\S+/).size, bytes + line.bytesize]
end
File.foreach ──> filter Ractor x4 ──> Port ──> reduce in caller
Key properties:
- 1 stage =
lanes:persistent Ractors — workers process many elements; no Ractor is created per element. - Demand-driven scheduling — a multi-lane stage is fed by pull: a worker gets a new batch only when it asks for one, so a stuck worker never has work piling up behind it, and a fast or infinite source is read only as fast as the pipeline consumes it.
- Transparent batching —
stream(src, batch: 1000)packs 1000 elements per message to amortize the per-message cost; stage blocks still see single elements.
Requires Ruby 4.0+ (Ractor::Port, Ractor.shareable_proc). The Ractor API
is experimental, and so is this library.
Overview
| DSL | Enumerable analogue | topology |
|---|---|---|
stream(src, batch: k) |
src.each |
source (fed from the caller) |
stream1(obj) |
[obj].each |
single-element source |
.pipe{ f(it) } |
map |
1 persistent Ractor |
.pipe(lanes: n){} |
map |
n Ractors, demand-driven, unordered |
.filter_pipe{ pred(it) } |
filter |
1 Ractor (sends the original element) |
.flat_pipe{ enum } |
flat_map |
1 input -> N outputs |
.tee(pipe{}, pipe{}) |
— | broadcast to branches, merged output |
.reduce(init){} .each{} .to_a .count .first(n) |
same | terminal, runs in the caller, no Ractor |
API guide
include Ractor::Pipeline makes the vocabulary available
(Ractor::Pipeline.stream(...) also works). Stage-building methods return
the pipeline object, so they chain; nothing runs until a terminal operation
is called.
stream(source, batch: 1)
Creates a pipeline whose input is each element of source (anything that
responds to each). Elements are fed from a background Thread in the
caller Ractor, concurrently with the terminal operation, so producing and
consuming overlap.
stream([1, 2, 3]) # three elements
stream(1..) # infinite stream (terminate with .first etc.)
stream(File.foreach(name)) # one element per line
batch: k packs k elements into one message. This is transparent — stage
blocks still receive one element at a time — and it amortizes the
per-message cost (sync + copy + envelope) over k elements, which matters
whenever the work per element is small (see
Measured performance). Batches shrink through
filter_pipe and are re-split to <= k after flat_pipe; the message
count is unchanged, so the amortization survives filtering.
When the first stage is multi-lane, the source is read on demand: the
feeder only reads ahead by the workers' open demand tokens, so
stream(huge_or_infinite_source) does not balloon memory.
stream1(obj) is a shorthand for stream([obj]): it flows obj as a
single element, even when obj itself is each-able.
stream1(config).pipe{ build(it) }.first
pipe(lanes: 1){ block }
A processing stage: applies the block to each element and sends the return
value downstream. The current element is it.
stream([1, 2, 3]).pipe{ it * 2 }.to_a #=> [2, 4, 6]
With lanes: n, the stage becomes n worker Ractors and the boundary into
it becomes demand-driven: each worker grants a small number of demand
tokens (2 per producer) and producers send a batch only to a worker they
hold a token for. A worker that is stuck on an expensive element simply
stops granting tokens, so the work is distributed by actual availability,
not round-robin. Completion order is not preserved (see
Ordering).
stream(rows).pipe(lanes: 8){ expensive(it) }
Consecutive stages form a pipeline and run concurrently — while stage 2 processes element k, stage 1 processes element k+1:
stream(src).pipe{ parse(it) }.pipe{ format(it) }
# src ──> Ractor(parse) ──> Ractor(format) ──> ...
Boundaries into a single consumer (a lanes: 1 stage, or the terminal
operation) are plain push over the port's FIFO: no demand round-trip, no
reordering.
filter_pipe(lanes: 1){ block }
Like pipe, but the block is a predicate: the original element (not the
block's return value) is sent downstream iff the block returns truthy.
stream(1..10).filter_pipe{ it.even? }.to_a #=> [2, 4, 6, 8, 10]
flat_pipe(lanes: 1){ block }
The block returns an each-able object; each of its elements is sent downstream individually (1 input -> N outputs). Useful for feeding many files into one fixed-size Ractor graph:
stream(file_names).
flat_pipe{ File.foreach(it) }. # 1 file -> N lines
filter_pipe(lanes: 4){ it.include?("Ractor") }.
count
tee(branch, branch, ...)
Broadcasts each element to every branch (not load balancing). Branches are receiver-less fragments; their outputs are merged, unordered, into one downstream stream. Non-shareable elements are copied once per branch.
evens, odds = stream(1..10).
tee(
filter_pipe{ it.even? }.pipe{ [:even, it] },
filter_pipe{ it.odd? }.pipe{ [:odd, it] },
).
reduce([[], []]) do |(evens, odds), (tag, n)|
tag == :even ? [evens << n, odds] : [evens, odds << n]
end
Terminal operations
Terminal operations run in the caller Ractor (no Ractor is created for them) and start the pipeline. Their blocks are ordinary blocks — no isolation restrictions, so a mutable accumulator is fine.
pl.reduce(initial){ |acc, elem| ... } # returns the final accumulator
pl.each{ |elem| ... } # yields each element, returns self
pl.to_a # collects into an Array
pl.count # number of output elements
pl.first # first element (stops the pipeline)
pl.first(n) # first n elements as an Array
first cancels the rest of the stream: it stops the feeder, closes its
output port, and broadcasts a cancel message, so workers drop their
backlog and terminate immediately (in-flight sends to closed ports raise
Ractor::ClosedError and cascade the shutdown upstream, like SIGPIPE in a
shell pipeline). Safe to use with an infinite stream(1..).
Errors
-
An exception raised in a stage block cancels the pipeline and is re-raised by the terminal operation in the caller:
stream(1..10).pipe{ raise "boom" if it == 5; it }.to_a #=> RuntimeError "boom" raised from .to_a -
Stage blocks are isolated with
Ractor.shareable_procat construction time. Capturing a shareable outer value snapshots it; capturing a non-shareable value raisesRactor::IsolationErrorimmediately at.pipetime (not at run time).selfinside a stage block isnil, so top-level helper methods are callable but instance methods are not.factor = 10 stream(1..3).pipe{ it * factor }.to_a #=> [10, 20, 30] (snapshot) buf = String.new stream(1..3).pipe{ buf << it.to_s } #=> Ractor::IsolationError at .pipe -
Everything a stage block returns must be sendable through a
Ractor::Port. A common gotcha: a Hash built withHash.new{ ... }holds a Proc as its default and cannot cross the boundary — build plain hashes ((h[k] ||= 0) += 1style) in stage blocks.
Samples
wc: cat README.md | grep Ruby | wc
lines, words, bytes =
stream(File.foreach("README.md")).
filter_pipe(lanes: 4){ it.include?("Ruby") }.
reduce([0, 0, 0]) do |(l, w, b), line|
[l + 1, w + line.scan(/\S+/).size, b + line.bytesize]
end
Log crunching: parse JSONL, aggregate per path
One message carries a chunk of lines; each stage worker parses its chunk
and returns a small pre-aggregated hash, so the caller-side reduce merges
#chunks hashes instead of touching every record
(runnable version: examples/logstats.rb):
stats = stream(lines.each_slice(1000)).
pipe(lanes: 8){ aggregate(it) }. # chunk -> {path => [req, err, ms]}
reduce({}){ |acc, st| merge(acc, st) }
Unordered results: carry an index and reassemble
picture = stream(0...height).
pipe(lanes: 8){ [it, render_row(it)] }.
reduce(Array.new(height)){ |acc, (y, row)| acc[y] = row; acc }
Early termination of an infinite stream
stream(1..).pipe{ it * it }.first(5) #=> [1, 4, 9, 16, 25]
Fine-grained sources: use batch:
stream(File.foreach(name), batch: 1000).
filter_pipe(lanes: 4){ it.include?("VALUE") }.
count
More runnable samples: examples/demo.rb (feature tour), examples/perf.rb and examples/readme_bench.rb (the measurements below).
Semantics notes
- Ordering: a chain of
lanes: 1stages preserves input order, with or withoutbatch:. Multi-lane stages are unordered (elements overtake each other across lanes). - Boundaries copy: non-shareable objects are deep-copied at each Ractor
boundary (
Ractor::Port#senddefault); shareable objects are passed by reference. - End of stream propagates via an in-band EOS message: each worker counts EOS from its upstream producers, drains its buffered output, and broadcasts EOS downstream, so fan-in and fan-out shut down cleanly.
- Cancellation (
first, exceptions) closes the terminal's port and broadcasts cancel; workers drop their backlog and exit, and the closure cascades upstream viaRactor::ClosedError(SIGPIPE-style). - Backpressure: demand-driven boundaries are bounded (2 batches per
producer/consumer pair), and a multi-lane head stage throttles the
source itself. Push boundaries (into
lanes: 1stages and the terminal) are unbounded FIFO queues.
Measured performance
Numbers from examples/readme_bench.rb (min of 5 runs) on Ruby 4.1.0dev
(2026-08-18 master, 98b3b8034d) on a 20-thread laptop (Core i7-1370P,
6P+8E cores, WSL2). Absolute numbers vary a lot with machine, clocks, and
Ruby version — treat them as shape, not truth. Ruby 4.0.2 behaves
similarly except that multi-Ractor CPU throughput at high lane counts is
~25% lower.
Lanes sweep — speedup over serial for pipe(lanes: n):
| workload (serial time) | lanes 2 | lanes 4 | lanes 8 | lanes 16 |
|---|---|---|---|---|
| fib(28) x 32, uniform CPU (0.52s) | x1.9 | x3.3 | x2.6 | x5.5 |
| skewed load, 1 heavy + 31 light (0.85s) | x1.8 | x3.2 | x2.8 | x2.4 |
| mandelbrot, 48 uneven rows (0.32s) | x2.4 | x4.5 | x5.3 | x6.9 |
| JSONL aggregation, 400k lines (0.25s) | x1.6 | x2.8 | x2.4 | x2.0 |
Notes:
- Skewed load is bounded by the heavy element itself (optimal makespan
~x2.8 here); demand-driven distribution saturates that bound from
lanes: 4on. Blind round-robin caps at ~x2.4 because elements queued behind the heavy one cannot move to an idle worker. - JSONL parsing is allocation-bound, and allocation throughput scales worse across Ractors than pure computation, so extra lanes stop helping early. Pre-aggregate in the workers and keep boundary objects small.
- The recurring dip at
lanes: 8is a machine artifact of this hybrid P/E-core laptop under WSL2 (eight busy workers get placed badly), not a property of the lane count. Expect ±20% run-to-run variance from clock scaling in every cell.
Message granularity — trivial filter over 100k short lines:
| serial | 1 line = 1 message | batch: 1000 |
|---|---|---|
| 0.011s | 1.56s | 0.021s |
Per-element messages cost ~1µs each (sync + copy + envelope); batching
amortizes that ~75x. Rule of thumb: aim for >= 1ms of work per message,
via batch: or by chunking the source.
Source throttling — infinite source, pipe(lanes: 2){ it }.first(3):
the source's each is invoked a few dozen times at most (5–40 across
runs). A push-fed pipeline reads hundreds of thousands of elements before
the cancellation lands.
vs the parallel gem
The parallel gem is a data-parallel
map over a ready-made collection using forked processes (or threads);
Ractor::Pipeline is an in-process streaming topology. On the overlapping
map-shaped workloads (examples/vs_parallel.rb, same machine/Ruby as
above, parallel 2.1.0):
| workload | Ractor::Pipeline | Parallel processes | Parallel threads |
|---|---|---|---|
| 32 x fib(28), 16-way | x6.6 | x4.6 | x1.0 (GVL) |
| skewed load, 4-way | x3.3 | x3.4 | — |
| JSONL aggregation, 8-way | x2.5 | x1.7 | x0.8 |
| 10k trivial jobs, 4-way | 0.002s | 0.510s | — |
- Threads cannot help CPU-bound work under the GVL; processes and Ractors both can.
- Both distribute work by demand, so skewed load balances equally well.
- Ractor messages are in-process copies, cheaper than fork + Marshal over
pipes: the gap widens when jobs carry data (JSONL: x2.5 vs x1.7) and
becomes decisive for small jobs (250x on trivial jobs, thanks to
batch:and reusable workers). - What Parallel cannot express at all: multi-stage pipelines (stages running concurrently), infinite/throttled sources, and early cancellation of a running stream. What Parallel gives you instead: full process isolation and compatibility with any Ruby object without shareability rules.
License
MIT. See LICENSE.txt.