Rust Threads, Send, Sync, Arc and Locks¶
Rust threads can own independent work or share explicitly synchronized state. Send governs transfer, Sync governs shared-reference access across threads, and Arc supplies shared ownership rather than a lock. This lesson separates those responsibilities before introducing channels and asynchronous tasks.
Prerequisites and learning outcome¶
Complete Rc, Weak, Cell and RefCell. You will move owned data into a thread, borrow local data in a thread scope, join workers, and limit the lifetime of Mutex and RwLock guards. Raspberry Pi readings below are synthetic millidegree fixtures; this is not a sensor reader or a speed benchmark.
Build the complete Rust thread example¶
Keep edition = "2024" in Cargo.toml. Replace src/main.rs with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | |
Expected binary output:
The nine tests distinguish returned application errors from intentional worker panics observed through join; they do not make the normal binary panic. Output is printed by the main thread after joining workers, so it does not depend on which worker the scheduler runs first.
Ownership transfer and thread lifetime are separate¶
thread::spawn consumes a FnOnce closure whose captured environment and returned value satisfy Send + 'static. The move closure owns the String, and join transfers the returned String back. 'static here is a type bound excluding shorter-lived borrowed data; it does not force an owned String to stay allocated forever. See spawn.
move controls capture mode, not whether the captured type is thread-safe. Moving a reference also does not turn it into owned storage or lengthen its lifetime. For operating-system thread creation failures, thread::Builder::spawn provides a Result; plain spawn panics if creation fails. This small lesson does not implement a production thread pool or a retry policy.
join consumes its handle, waits for the worker and distinguishes its successful return from a panic payload. A returned Result is an additional layer: Ok(Err(error)) represents a normal worker return carrying an application error. Our report handles both layers and joins all remaining workers after an error, rather than detaching them accidentally. Dropping a JoinHandle detaches, not cancels, the worker. See JoinHandle.
Send and Sync describe type capabilities¶
Send means ownership of a value can cross thread boundaries safely. Sync means shared references can cross safely: T is Sync precisely when &T is Send. They are unsafe auto traits, normally inferred from a type's components; their contracts are not ordinary marker promises to add when code fails to compile. See Send and Sync.
String can be transferred and shared by reference. Cell
Arc shares ownership, not arbitrary mutable access¶
Arc::clone creates another owner of the same allocation, with atomic reference counting. It does not clone Summary. Arc
Arc
Scoped threads borrow local input¶
scoped_sum borrows slices backed by its caller's Vec. thread::scope joins its scoped workers before returning, so their borrows cannot escape that scope. The Vec remains owned by the caller. This replaces the need to clone or allocate owned input merely to satisfy an unscoped spawn's lifetime bound. See thread scopes.
Our helper joins both handles before inspecting their results. Panics from unjoined scoped workers are propagated by the scope; manually joining permits handling their results. A scope guarantees bounded thread lifetime, not ordered scheduling. Do not infer a speedup from splitting this tiny fixture in two.
Lock guards delimit synchronized access¶
Mutex::lock blocks until it can return a guard, or reports poison when applicable. The guard supplies access and unlocks when dropped. Our worker computes a local report first, then performs only the merge while locked. The try_lock test checks contention without deliberately hanging on another blocking lock. See Mutex.
Keep lock scopes short. Do not hold a lock while joining a worker that needs it. A forgotten guard, repeated acquisition of the same mutex, inconsistent order between multiple locks, or re-entrant callbacks can prevent progress. Safe Rust prevents data races through these APIs, not every deadlock. Sleeping is not a correctness mechanism for enforcing thread order.
RwLock permits multiple readers or one writer. Its scheduling policy depends on the underlying implementation; do not assume fairness or that it is faster than Mutex. We release both readers before requesting a writer. The tests use try_write/try_read rather than a potentially blocking recursive acquisition. See RwLock.
Poison signals a possible broken invariant¶
A panic while holding a Mutex guard can poison the mutex. lock then yields an error containing the guard, not a repaired value. Our test deliberately sets an invalid negative state, joins the failed worker, explicitly resets the known invariant, and only then clears poison. PoisonError::into_inner alone is not a validation strategy.
Poisoning is advisory and is not a soundness guarantee: not all panic situations trigger it. Our report rejects poisoned summaries rather than trusting partial counts. RwLock poisoning concerns a writer panic, not simply a reader panic. Production code must define whether state can be inspected, reconstructed or abandoned; blindly unwrapping or clearing a flag does not establish consistency.
Deliberately failing: move does not make Rc transferable¶
In a separate scratch project, replace src/main.rs with:
cargo check reports E0277 because Rc
Exercises and troubleshooting¶
- Repair the scratch program with Arc. Expect the returned byte length to be 5. Explain why no mutex is needed for this read-only payload.
- Replace the scratch payload with Arc
> and read its borrow inside the worker. Expect E0277: RefCell is not Sync. For genuinely shared mutable text, use Arc > and a scoped guard; for exclusive ownership, move the String without shared ownership. - In a scratch program, create a local String and spawn a non-move closure that reads it. Expect E0373 for the potential escaping borrow even if join follows immediately. Repair by transferring ownership or using thread::scope.
- Try to move a MutexGuard into an unscoped spawned thread. Expect E0277: the standard guard is not Send. Acquire it in the worker instead, with a suitable owner or scope.
- Change the normal report threshold to 60_001: matching becomes 0, while measured and missing are unchanged. Add empty batches or a negative measured reading; neither absence nor a numeric sign should silently change record accounting.
- Read the poison test and distinguish the worker's panic Result from the mutex's poison Result. Explain why clear_poison must follow a deliberate recovery decision rather than merely silence an error.
- Sketch the deadlock where main locks the shared report and joins a worker that tries to lock it. Do not run an indefinitely blocking example; explain the wait cycle and release-before-join repair.
Verification and next step¶
On October 10, 2026, the lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, nine debug and release tests, formatting, debug/release output comparisons and five further debug output comparisons passed. Arc, mutex, owned-capture and scoped-borrow repairs passed, as did the changed-threshold variant. Rc transfer, Arc
Next, study channels, atomics and shutdown to transfer messages, choose atomic operations and define how workers finish.