Concurrency in Action
Build a concurrent Mandelbrot set renderer in Rust while learning threads, data parallelism, and how the ownership system prevents data races at compile time
The tour so far has covered Rust's syntax and its networking capabilities in isolation. Real programs rarely stay in one lane. A web server handles thousands of connections simultaneously. An image processor splits work across every core on the machine. A game engine runs physics, audio, and rendering in parallel. Concurrency is not an advanced feature reserved for specialists — it is the default shape of useful software.
Rust's approach to concurrency stands apart because it does not bolt safety onto an unsafe foundation. The ownership and type system that prevents memory errors in single-threaded code is the exact same mechanism that prevents data races in concurrent code. This means the compiler catches threading mistakes before the program ever runs.
This chapter walks through building a concurrent Mandelbrot set renderer. Along the way, you will see threads, message passing, shared state, and the invisible safety guarantees that make Rust's concurrency model work.
What the Mandelbrot Set Actually Is
Before writing concurrent code, you need something worth making concurrent. The Mandelbrot set is a fractal — an infinitely detailed shape that emerges from a simple equation. Given a complex number c, you iterate this formula:
z_0 = 0
z_{n+1} = z_n^2 + c
If z stays bounded (never escapes beyond a distance of 2 from the origin) after some maximum number of iterations, then c belongs to the Mandelbrot set. If z escapes, it does not belong, and the number of iterations it took to escape determines the color of that pixel.
The set is visually striking — the iconic black cardioid surrounded by intricate, swirling patterns — but from a computational perspective, it is simply a grid of independent calculations. Every pixel is computed the same way, and no pixel's result depends on any other pixel's result. This property, called embarrassing parallelism, makes the Mandelbrot set a natural fit for demonstrating concurrency.
Why This Example:
Rendering a fractal is deliberately CPU-bound. Network latency and disk I/O do not mask the concurrency behavior here. You see the threads working, which is the point.
Parsing Pair Command-Line Arguments
The program needs four inputs: the output filename, the image dimensions in pixels, and the region of the complex plane to render. A command-line interface keeps the focus on the computation itself.
// src/main.rs
use std::env;
struct Args {
filename: String,
bounds: (usize, usize),
upper_left: (f64, f64),
lower_right: (f64, f64),
}
fn parse_args() -> Result<Args, String> {
let args: Vec<String> = env::args().collect();
if args.len() != 5 {
return Err(format!(
"Usage: {} FILE PIXELS UPPERLEFT LOWERRIGHT\n\
Example: {} mandel.png 1000x750 -1.20,0.35 -1.00,0.20",
args[0], args[0]
));
}
let filename = args[1].clone();
let bounds = parse_pair(&args[2], 'x')
.ok_or("Image bounds must be WIDTHxHEIGHT (e.g., 1000x750)")?;
let upper_left = parse_complex(&args[3])
.ok_or("Upper-left corner must be REAL,IMAG (e.g., -1.20,0.35)")?;
let lower_right = parse_complex(&args[4])
.ok_or("Lower-right corner must be REAL,IMAG (e.g., -1.00,0.20)")?;
Ok(Args { filename, bounds, upper_left, lower_right })
}
fn parse_pair<T: std::str::FromStr>(s: &str, separator: char) -> Option<(T, T)> {
match s.find(separator) {
None => None,
Some(index) => {
match (T::from_str(&s[..index]), T::from_str(&s[index + 1..])) {
(Ok(l), Ok(r)) => Some((l, r)),
_ => None,
}
}
}
}
fn parse_complex(s: &str) -> Option<(f64, f64)> {
parse_pair(s, ',')
}
The parse_pair function is generic over any type that implements FromStr. This means it handles both usize for pixel dimensions and f64 for floating-point coordinates. The <T: std::str::FromStr> syntax is a trait bound — it tells the compiler that T must support being parsed from a string slice. This is the same pattern Rust's standard library uses internally, and it is worth getting comfortable with early.
Calling parse("1000x750", 'x') splits on the x, parses both sides into usize values, and returns Some((1000, 750)) if both sides parse successfully. If the separator is missing or either side fails to parse, it returns None. The caller converts that None into a human-readable error message with ok_or.
No Error Recovery Here:
This parser returns Result<Args, String> and exits on failure. A production tool would use a dedicated error type or anyhow. For a focused concurrency demo, String errors keep the scaffolding minimal.
Mapping from Pixels to Complex Numbers
The image is a grid of pixels. The complex plane is a continuous region. Rendering requires a function that maps each pixel coordinate to the corresponding complex number.
/// Map a pixel (column, row) to a point in the complex plane.
///
/// `bounds` is the width and height of the image in pixels.
/// `pixel` is the (column, row) of the specific pixel.
/// `upper_left` and `lower_right` define the region of the complex plane.
fn pixel_to_point(
bounds: (usize, usize),
pixel: (usize, usize),
upper_left: (f64, f64),
lower_right: (f64, f64),
) -> (f64, f64) {
let (width, height) = (
lower_right.0 - upper_left.0,
upper_left.1 - lower_right.1,
);
(
upper_left.0 + pixel.0 as f64 * width / bounds.0 as f64,
upper_left.1 - pixel.1 as f64 * height / bounds.1 as f64,
)
}
The pixel.1 as f64 conversions are explicit. Rust never performs implicit numeric casts, even widening ones that would be lossless. This is deliberate: implicit conversions are a persistent source of subtle bugs in C and C++, and Rust's designers chose explicitness over convenience. You type more, but you never wonder whether a cast happened silently.
The y-axis is inverted because pixel rows count downward from the top, while the complex plane's imaginary axis increases upward. Subtracting rather than adding handles this inversion.
Plotting the Set
With the coordinate mapping in place, the core computation determines whether a given complex number belongs to the Mandelbrot set.
use num::Complex;
/// Determine how many iterations it takes for `c` to escape.
///
/// Returns `None` if `c` is considered a member of the set
/// (did not escape within `limit` iterations).
/// Returns `Some(count)` if `c` escaped after `count` iterations.
fn escape_time(c: Complex<f64>, limit: usize) -> Option<usize> {
let mut z = Complex { re: 0.0, im: 0.0 };
for i in 0..limit {
if z.norm_sqr() > 4.0 {
return Some(i);
}
z = z * z + c;
}
None
}
The condition z.norm_sqr() > 4.0 checks whether z has escaped beyond a radius of 2 from the origin. Squaring both sides of the inequality avoids computing a square root on every iteration. This is not a micro-optimization that prematurely complicates the code — it removes a floating-point operation from the innermost loop of a compute-bound function. The cost of sqrt would dominate the iteration time.
The num crate provides Complex<f64>. Add it to Cargo.toml:
[dependencies]
num = "0.4"
Now the program can compute escape times for individual pixels. The next step is rendering those results into an image.
Writing Image Files
The program outputs a PNG where each pixel's color encodes the escape time of the corresponding complex number. The image crate handles the file format details.
[dependencies]
image = "0.25"
num = "0.4"
use image::png::PNGEncoder;
use image::ColorType;
use std::fs::File;
/// Render a rectangular region of the Mandelbrot set into a pixel buffer.
fn render(
pixels: &mut [u8],
bounds: (usize, usize),
upper_left: (f64, f64),
lower_right: (f64, f64),
) {
assert_eq!(pixels.len(), bounds.0 * bounds.1);
for row in 0..bounds.1 {
for column in 0..bounds.0 {
let point = pixel_to_point(bounds, (column, row), upper_left, lower_right);
let c = Complex { re: point.0, im: point.1 };
pixels[row * bounds.0 + column] = match escape_time(c, 255) {
None => 0,
Some(count) => 255 - count as u8,
};
}
}
}
/// Write the pixel buffer as a grayscale PNG.
fn write_image(
filename: &str,
pixels: &[u8],
bounds: (usize, usize),
) -> Result<(), std::io::Error> {
let output = File::create(filename)?;
let encoder = PNGEncoder::new(output);
encoder
.encode(pixels, bounds.0 as u32, bounds.1 as u32, ColorType::Gray(8))?;
Ok(())
}
Pixels that never escape within 255 iterations are colored black (0). Pixels that escape quickly are bright (closer to 255), and those that escape slowly are dark. This creates the characteristic glowing halos around the black set interior.
The assert_eq! at the top of render is intentional. If the caller passes a buffer of the wrong size, the program panics immediately rather than writing past the buffer or producing a corrupted image silently. In Rust, panicking on a contract violation is the correct behavior — it surfaces the bug at the call site rather than allowing corrupt state to propagate.
A Concurrent Mandelbrot Program
The sequential version works, but it uses exactly one CPU core. On an 8-core machine, 87.5% of the available compute sits idle. The work is embarrassingly parallel: each row of pixels is independent. Splitting the rows across threads will use all available cores.
Rust's standard library provides std::thread::spawn for creating threads and std::sync::mpsc for message passing between them. The design is straightforward: spawn one thread per row, send the computed row back through a channel, and assemble the rows into the final image in the main thread.
use std::sync::mpsc;
use std::thread;
fn render_concurrent(
pixels: &mut [u8],
bounds: (usize, usize),
upper_left: (f64, f64),
lower_right: (f64, f64),
) {
let threads = 8;
let rows_per_band = bounds.1 / threads + 1;
let (tx, rx) = mpsc::channel();
for i in 0..threads {
let tx = tx.clone();
let start_row = i * rows_per_band;
let end_row = (start_row + rows_per_band).min(bounds.1);
thread::spawn(move || {
let mut band = vec![0u8; (end_row - start_row) * bounds.0];
for row in start_row..end_row {
for column in 0..bounds.0 {
let point = pixel_to_point(bounds, (column, row), upper_left, lower_right);
let c = Complex { re: point.0, im: point.1 };
band[(row - start_row) * bounds.0 + column] =
match escape_time(c, 255) {
None => 0,
Some(count) => 255 - count as u8,
};
}
}
tx.send((start_row, band)).unwrap();
});
}
// Drop the original sender so the receiver knows when all threads are done.
drop(tx);
for (start_row, band) in rx {
let len = band.len();
let start = start_row * bounds.0;
pixels[start..start + len].copy_from_slice(&band);
}
}
Each thread receives a clone of the sender. The move keyword captures tx, start_row, and end_row by value, transferring ownership into the closure. This is not a stylistic choice — it is enforced by the compiler. If the closure borrowed these values, the borrow would need to last for the thread's entire lifetime, which the main thread's stack frame cannot guarantee. Ownership moves into the thread, and when the thread exits, those values are dropped.
The drop(tx) after spawning all threads is critical. The receiver rx iterates until all senders are dropped. If the original tx were not dropped, the loop in the main thread would block forever waiting for a message that never arrives. This is a pattern worth internalizing: channels in Rust signal completion by dropping the sender, not by sending a special sentinel value.
Do Not Forget to Drop the Sender:
Without drop(tx), the program hangs indefinitely. The main thread waits on rx, and rx waits for all senders to be dropped. The original tx being alive keeps the channel open. This is a runtime deadlock the compiler cannot detect.
The copy_from_slice call assembles the final image. Each band arrives with a starting row, and the main thread copies its pixels into the correct position in the output buffer.
Why Not Share the Pixel Buffer Directly?
A natural question: why use channels at all? Why not pass a mutable reference to the pixel buffer into each thread and let them write their rows directly?
The answer is Rust's borrowing rules. &mut [u8] is a mutable reference. Only one mutable reference to a piece of data can exist at a time. Multiple threads cannot each hold a mutable reference to the same buffer — the compiler rejects this at compile time. Even if they could, unsynchronized writes to the same allocation from multiple threads would be a data race.
Channels sidestep this by giving each thread ownership of its own Vec<u8>. The threads never share memory; they communicate by sending owned data through the channel. This is the message passing model of concurrency, and Rust's type system makes it the path of least resistance.
Running the Mandelbrot Plotter
With everything wired together, the main function ties parsing, rendering, and output into a single program.
fn main() {
let args = parse_args().unwrap_or_else(|e| {
eprintln!("{}", e);
std::process::exit(1);
});
let mut pixels = vec![0u8; args.bounds.0 * args.bounds.1];
// Choose one:
// render(&mut pixels, args.bounds, args.upper_left, args.lower_right);
render_concurrent(&mut pixels, args.bounds, args.upper_left, args.lower_right);
write_image(&args.filename, &pixels, args.bounds)
.expect("Failed to write image");
}
Run it with:
cargo run --release -- mandel.png 4000x3000 -1.20,0.35 -1.00,0.20
The --release flag matters. Debug builds are significantly slower because the compiler skips optimizations to prioritize fast compilation. Rendering a 4000×3000 image in debug mode takes minutes. In release mode, it takes seconds.
What to Expect:
With 8 threads on an 8-core machine, the concurrent renderer should finish approximately 8 times faster than the sequential version. The image output is identical in both cases — concurrency here is a pure performance optimization with no behavioral change.
Safety Is Invisible
Run the concurrent Mandelbrot renderer. It produces a PNG. It does not crash, corrupt memory, or produce subtly wrong pixels. The remarkable thing is what did not happen.
There were no mutexes. No locks. No atomic operations written by hand. No debugging sessions spent chasing a data race that only reproduces on Tuesdays. The channels handled synchronization. The ownership system ensured that no two threads ever held mutable access to the same memory at the same time. If a thread tried to share a reference when it should have transferred ownership, the program did not compile.
This is not an accident of a trivial example. The same mechanisms scale to systems with hundreds of threads, complex dependency graphs, and long-running services. The compiler checks the same rules at every scale.
The Mandelbrot renderer also exposed a practical distinction: message passing via channels was the natural fit here because the work was partitioned into independent chunks with no shared mutable state. In other concurrent programs, shared state with mutexes or atomics is the right tool. Rust provides both, and the type system guides each approach toward correct usage.
What you just built is a template. An image processor splits frames across threads the same way. A scientific simulation divides spatial grids across threads the same way. A build system distributes compilation units across threads the same way. The details change, but the pattern — partition work, spawn threads, collect results through channels — generalizes across domains.