Your program compiles, runs, prints nothing, and simply sits there forever.
Welcome to the deadlock club, the most silent bug family in concurrent programming.
In this tutorial we build the most classic deadlock of all, the lock ordering deadlock, also known as the ABBA deadlock.
Our playground: two embedded bus devices, SPI and I2C, each protected by its own Mutex.
Two threads transfer data between the buses in opposite directions, each locking its source first.
Thread A holds I2C and waits for SPI, thread B holds SPI and waits for I2C.
So A needs B and B needs A -> ABBA.
Both wait forever.
Let’s freeze some threads.
Shopping list
- GitHub for this tutorial:
- rustc 1.96.0 (ac68faa20 2026-05-25)
- Docker
- VSCode with the Dev Containers extension
What is a deadlock
A deadlock needs four conditions to exist, known as the Coffman conditions.
- Mutual exclusion: a resource can only be held by one thread at a time.
- Our SPI bus accepts a single master, that’s exactly what a Mutex enforces.
- Hold and wait: a thread keeps a resource while waiting for another one.
- Our transfer function locks the source bus, then waits for the destination bus, still holding the first lock.
- No preemption: a resource can only be released by its holder.
- A MutexGuard is released when the owning thread drops it, and nothing can steal it.
- Circular wait: a cycle of threads, each waiting for a resource held by the next one.
- Thread A holds I2C and waits for SPI, thread B holds SPI and waits for I2C.
Break a single one and the deadlock becomes impossible.
All four conditions are present in our deadlock version.
The fix keeps the first three untouched and breaks the fourth: with a global lock ordering, a circular wait can never appear.
Project structure
|-- Cargo.lock
|-- Cargo.toml
|-- src
| |-- core
| | |-- device.rs
| | `-- transfer.rs
| |-- core.rs
| `-- main.rs
`-- tests
`-- integration.rs
main.rs
main
fn main()
The entry point stays minimal.
It collects the command line arguments with env::args() into a Vec<String> and passes them to run.
All the logic lives in the core module: main is only the bridge to the outside world.
core/device.rs
DeviceKind
pub enum DeviceKind
An enum with two variants: I2c and Spi.
Their automatic values, 0 and 1, are also their indices in the shared vector.
Keep that in mind: these indices will decide the locking order.
The derive attributes are minimal: Copy to pass a variant to several threads freely, PartialEq to check that source and destination differ.
Device
pub struct Device
A device is a small structure with three private fields: a name, an array of three one-byte registers, and a usage counter.
impl Device
Two registers matter here:
REG_TO_READholds the data this device exposes.REG_TO_WRITEreceives data coming from another device.
Both are associated constants, so the code reads Device::REG_TO_READ instead of a magic number.
new
pub fn new(name: &str, registers: [u8; NUMBER_OF_REGISTERS]) -> Self
Creates a device with a name, its initial register values, and a usage counter starting at zero.
The name parameter is a string slice while the field is an owned String.
This is the classic Rust API choice: the caller writes a plain literal, and the conversion happens once, inside the constructor.
read_register
pub fn read_register(&self, index: usize) -> u8
Returns the value of a register.
An out-of-range index panics with the Rust native index out of bounds message.
This is the intended contract: a bad register index is a bug, not an error to handle.
write_register
pub fn write_register(&mut self, index: usize, value: u8)
Stores a value into a register and increments the usage counter.
The fields are private, so this method is the only way to modify a device.
Since the counter lives inside the method, no caller can ever forget to count a write.
That is the whole point of keeping the fields private.
Display for Device
impl fmt::Display for Device
Prints a device on one line: name, registers in hexadecimal, usage count.
One glance tells you the state of a bus.
core/transfer.rs
transfer_data
pub fn transfer_data(devices: &[Mutex<Device>], from: DeviceKind, to: DeviceKind, deadlock_mode: bool)
This is where everything happens.
The function starts with a protection: an assert_ne! that refuses identical source and destination.
Locking the same Mutex twice in the same thread is a deadlock in its own right, so the function rejects the situation loudly instead of freezing silently.
transfer_data receives two parameters that decide everything: from is the device we read, to is the device we write.
Remember the indices: I2c is 0, Spi is 1.
The function locks the two devices one after the other, and the whole question of this tutorial is: in which order?
First case: the thread that transfers from I2c to Spi.
It locks from first (the I2C, index 0), then to (the SPI, index 1).
So this thread locks 0, then 1.
Second case: the thread that transfers from Spi to I2c.
It also locks from first (the SPI, index 1), then to (the I2C, index 0).
So this thread locks 1, then 0.
Put the two threads side by side: one locks 0 then 1, the other locks 1 then 0.
Each one grabs its first lock, then waits for a lock the other already holds.
This is the ABBA pattern, and this is exactly what the deadlock mode does.
A one millisecond sleep between the two locks widens the collision window and makes the freeze reproducible on any machine.
The fixed mode changes one single thing: in the second case, the locks are taken in the reverse order, to first (index 0), then from (index 1).
Now both threads lock 0 first: they queue at the same door instead of crossing each other.
The reads and writes never change: from is always read, to is always written, whatever the locking order.
is_deadlock_allowed
pub fn is_deadlock_allowed(args: &[String]) -> bool
Reads the command line arguments and returns true if the first argument is the word deadlock.
Nothing more.
This tiny function exists mostly because a pure function is testable, while reading the process arguments directly is not.
create_thread
pub fn create_thread(arc_to_share: &Arc<Vec<Mutex<Device>>>, from: DeviceKind, to: DeviceKind, deadlock_mode: bool) -> JoinHandle<()>
Spawns one worker thread and returns its JoinHandle.
The function receives a reference to the shared Arc, clones it internally, and moves the clone into the thread.
The rule of thumb: the code that needs a clone makes the clone.
The caller keeps its own Arc untouched.
The thread body is a loop that calls transfer_data one thousand times.
Let’s be honest: nobody transfers the same byte a thousand times in real life.
The loop only exists to maximize the collision probability between the two threads.
One collision is enough to freeze everything.
run
pub fn run(args: &[String])
The orchestration function.
It builds the two devices, wraps them in a vector of Mutex, wraps the vector in an Arc, and prints the initial state.
Then it creates two threads with opposite directions: one transfers from I2C to SPI, the other from SPI to I2C.
These crossed directions are the fuel of the ABBA pattern.
The handles are collected and joined.
Calling the .join() method on a thread blocks main until that thread has finished.
The two joins are chained in a loop: main waits for the first thread, then waits for the second.
So when the loop ends, both threads are guaranteed to be dead: the code after the loop can read the devices safely, nobody is writing anymore.
In fixed mode, this takes a few milliseconds.
In deadlock mode, the end of the loop is never reached.
Finally, run prints the devices again.
In fixed mode you will see the usage counters at 1000 on each side, the proof that two thousand transfers completed without losing a single one.
Running the program
Normal mode
$ cargo run
The program prints the devices before and after, the counters show 1000, everybody goes home happy.
Deadlock mode
$ cargo run -- deadlock
The program prints the initial state and then freezes.
No panic, no error message, no CPU spike.
Just silence.
This is what makes deadlocks so vicious in production: the program looks merely slow.
Each one now waits for a lock that the other will never release.
The circular wait of Coffman, live on your machine.
Press CTRL and C to kill it.
The fix
The fix fits in one sentence: every thread must acquire the locks in the same global order, whatever the transfer direction.
In our code, the order is ascending device index.
Thread 1 wants I2C then SPI: already ascending, nothing to change.
Thread 2 wants SPI then I2C: the fixed branch locks I2C first, even though I2C is its destination.
Why does this prevent the deadlock?
Because both threads now knock on the same first door: the I2C Mutex.
Whoever gets it wins the round, takes the second lock without opposition (the loser is waiting empty-handed at the first door), finishes the transfer, and releases everything.
The waiting thread then gets its turn.
Waiting still happens, but it is never circular, so it always ends.
This rule scales to any number of locks: define a total order once (by index, by address, by name), and acquire in that order everywhere.
The circular wait becomes structurally impossible.
Testing the deadlock
The unit tests cover the building blocks: the Device methods, the argument parsing, the transfer in both directions, and the assert that rejects identical devices.
The integration test is the fun one: it launches the real binary in deadlock mode as a child process, polls it every ten milliseconds with try_wait, and considers the test successful if the process is still alive after a one second timeout.
The child is then killed and reaped.
In other words, the test asserts that the program freezes.
If the child exits before the timeout, the test panics: an exiting program means the deadlock did not happen, and that would be the real bug here.
To run all tests:
$ cargo test
Conclusion
The ABBA deadlock is the perfect example of concurrent code that looks correct, compiles without a warning, and stops the world at runtime.
Two threads, two locks, two opposite acquisition orders: that is all it takes.
The protection is structural, not defensive: a single global lock ordering, applied everywhere, makes the circular wait impossible by construction.
The next time a multithreaded program goes silent, you will know exactly which four conditions to look for, and which one to break.
Good job, you did it.