Build an async job pipeline with fibre
For Rust developers who know the basics of Tokio
You'll build a small Tokio program with three producers, a pool of workers and one collector. The producers push jobs into a bounded mpmc queue, the workers process them and an mpsc channel carries the results to the collector. The program shuts down on its own once the producers finish.
Before you start
You need Rust with Cargo. fibre comes from crates.io:
fibre = "0"Set up the project#
Start a new binary crate:
Add fibre, Tokio and futures-util to Cargo.toml. The pipeline only needs Tokio's single-threaded runtime, its macros and its timers. futures-util provides StreamExt, which you'll use on the result channel at the end.
[]
= "0"
= { = "1", = ["macros", "rt", "time"] }
= "0.3"The program uses the current_thread runtime throughout. Everything runs on one thread, so the output comes out in the same order every time and you can compare it line for line with what's shown here.
Send jobs to a worker#
The job queue is an mpmc channel. That's fibre's many-to-many channel: any number of tasks can send into it and any number can receive from it. Each job goes to exactly one receiver. You start with one of each. Replace src/main.rs with this:
use mpmc;
async
mpmc::bounded_async(8) returns an AsyncSender and an AsyncReceiver for a queue that holds at most 8 jobs. send returns a future that resolves once the job is in the queue. It fails only if every receiver is gone. recv resolves with the next job. Once every sender has been dropped and the queue is empty, it resolves with Err(RecvError::Disconnected) instead.
That error is how the worker knows to stop. The drop(job_tx) after the loop drops the last sender. The worker drains the three jobs that are already queued and then its while let loop ends. Run it:
Add more producers#
Senders on an mpmc channel are Clone, so each producer gets its own clone. Give Job a field for the producer's name and replace main with a version that spawns three producer tasks:
async
Each producer owns its clone of the sender. That clone is dropped when the producer's task finishes. The original
job_txstill has to be dropped by hand right after the clones are made. If you leave it alive, the channel always has one sender left and the worker waits forever for a job that never arrives.
A producer that sees send fail stops early instead of panicking. That happens only if the workers have all gone away. There's nothing useful to do with the job at that point.
worker: alpha #0 has 4 words
worker: alpha #1 has 2 words
worker: beta #0 has 2 words
worker: beta #1 has 4 words
worker: gamma #0 has 2 words
worker: gamma #1 has 3 words
worker: queue closed, exiting
The queue holds 8 jobs and there are only 6, so each producer sends both of its jobs as soon as it runs.
See backpressure#
A bounded queue makes fast producers wait for a slow consumer. To see it, shrink the queue to 2, make the worker take 100ms per job and have each producer print a line when a job gets in. The helper ms rounds the elapsed time to the nearest 100ms so the numbers stay stable from run to run. Here is the whole file:
use mpmc;
use Duration;
use ;
async
0ms alpha queued #0
0ms alpha queued #1
0ms beta queued #0
100ms worker: alpha #0 has 4 words
100ms gamma queued #0
200ms worker: alpha #1 has 2 words
200ms beta queued #1
300ms worker: beta #0 has 2 words
300ms gamma queued #1
400ms worker: gamma #0 has 2 words
500ms worker: beta #1 has 4 words
600ms worker: gamma #1 has 3 words
600ms worker: queue closed, exiting
At time zero three jobs get in: two fill the queue and the worker has already pulled alpha #0 out, which frees one slot for beta #0. After that every producer's send sits waiting until the worker finishes a job and takes the next one, so from then on exactly one new job enters the queue every 100ms. Nothing is dropped and memory stays flat no matter how many jobs the producers have lined up.
If you'd rather shed load than wait, use try_send. It never waits. When the queue is full it returns TrySendError::Full with the job inside, so you can log it, retry it later or throw it away. Swap the producer loop body for this and add use fibre::error::TrySendError; at the top:
match job_tx.try_send
0ms alpha queued #0
0ms alpha queued #1
0ms beta dropped #0, queue full
0ms beta dropped #1, queue full
0ms gamma dropped #0, queue full
0ms gamma dropped #1, queue full
100ms worker: alpha #0 has 4 words
200ms worker: alpha #1 has 2 words
200ms worker: queue closed, exiting
The catch-all arm covers TrySendError::Closed, which means the receivers are gone. Put the blocking send back before moving on.
Run a pool of workers#
Receivers on an mpmc channel are Clone too. Every clone pulls from the same queue and each job goes to whichever worker asks first, so adding workers is a matter of cloning the receiver. Remove the println! from the producer loop so the output stays short, then replace the single worker with two:
let mut workers = Vecnew;
for worker in 0..2
drop;
for handle in workers
100ms worker 0: alpha #0 has 4 words
100ms worker 1: alpha #1 has 2 words
200ms worker 0: beta #0 has 2 words
200ms worker 1: beta #1 has 4 words
300ms worker 0: gamma #0 has 2 words
300ms worker 0: queue closed, exiting
300ms worker 1: gamma #1 has 3 words
300ms worker 1: queue closed, exiting
Two workers finish the six jobs in 300ms instead of 600ms. The drop(job_rx) after the loop matters for the same reason as drop(job_tx): the original receiver would otherwise stay open and keep the channel alive with nobody reading from it.
Collect results over mpsc#
Printing from inside the workers is fine for a demo, but a real pipeline hands results to the next stage. Many workers feeding one collector is the pattern mpsc is built for. Its receiver is not Clone, which guarantees there's only ever one consumer. Its async receiver implements futures::Stream, so the collector can read it with StreamExt::next.
This is the finished program. Replace all of src/main.rs with it:
use ;
use StreamExt;
use Duration;
use sleep;
async
worker 0: alpha #0 has 4 words
worker 1: alpha #1 has 2 words
worker 0: beta #0 has 2 words
worker 1: beta #1 has 4 words
worker 0: queue closed, exiting
worker 1: queue closed, exiting
worker 0: gamma #0 has 2 words
worker 1: gamma #1 has 3 words
collector: all workers gone, 17 words total
Each worker gets a clone of done_tx and main drops the original. next() returns None only when every worker has exited and the result channel is empty, so the collector loop is also how main waits for the whole pipeline. There are no join handles to track.
Shut down in order#
The order of the last output lines shows how shutdown works. Both workers print "queue closed" before the collector prints the gamma results. The workers sent those results and exited. The results sat in the mpsc buffer until the collector got to them. When the senders go away, the receiver still drains everything already buffered before it reports the end.
The whole shutdown is driven by dropping senders, one stage at a time:
producers finish -> last job_tx dropped
workers drain queue -> recv() returns Err, workers exit, last done_tx dropped
collector drains -> next() returns None, main returns
You don't need a cancellation token or a shutdown message for this. Each stage finishes its buffered work and then closes the next stage by dropping its sender.
Dropping from the receiving side behaves differently. A receiver that is dropped or closed stops at once and doesn't drain what's left in the buffer. Once every receiver is gone, the next send on that channel fails. That's why the producers and workers here break out of their loops on a send error rather than unwrapping it. For a clean stop, always close a pipeline from the sending end.
Where to go next#
The rest of fibre is covered in the channels guide at https://github.com/excsn/fibre/blob/main/channels/README.GUIDE.md. It covers spsc for one-to-one pipes, spmc for broadcasting every message to every consumer, spmc::topic for publish-subscribe, oneshot for request and reply, rendezvous channels and the batch API. The same guide shows how to mix sync threads and async tasks on one channel with to_sync() and to_async().
The full API, including every error type, is on docs.rs at https://docs.rs/fibre.
