Skip to content

← Fearless Concurrency: Threads, Channels, Shared State step 8 of 24

Hard Framework

unsafe impl Send, and the disjoint-capture trap

Scatter a list of (index, value) writes into a zeroed buffer, using several threads that all write through the same raw pointer.

pub fn scatter_unsafe(len: usize, writes: Vec<(usize, i64)>, workers: usize) -> Vec<i64>

The contract

  • Start from vec![0i64; len]. Every position that no write touches stays 0.
  • Every index in writes is less than len. Values can be any i64, including negative ones.
  • Several writes may target the same index. The last one in input order wins. For example, with len = 4 and writes [(1, 10), (1, 20), (2, 3)] the result is [0, 20, 3, 0].
  • len == 0 returns an empty vector (and writes is then empty).
  • Treat workers == 0 as 1. workers can be larger than len, up to usize::MAX. Do not spawn a thread for an empty range; with a very large workers value that would mean trying to spawn an enormous number of threads.
  • Return the buffer after all threads have finished.

The intended approach

Split 0..len into disjoint half-open ranges (each start..end includes start and excludes end), one per worker. A balanced split works well: with base = len / workers and extra = len % workers, the first extra ranges have base + 1 indices and the rest have base. Any split into disjoint ranges that together cover 0..len gives the same output.

Give every worker a copy of the same *mut i64 (the address of the buffer's first element). Each worker scans the whole writes list in input order and performs only the writes whose index falls in its own range. Because each index belongs to exactly one worker, and that worker sees the writes in input order, the last write to an index is the one that remains.

Why this exercise uses unsafe

Writing through a raw pointer requires an unsafe block, so the compiler does not check that the threads stay out of each other's way. You have to make sure of that yourself. If two ranges overlapped, two threads could write the same i64 at the same time without synchronization. That is a data race, and a data race is undefined behavior: Rust makes no promise about what the program does, not merely a promise of "some wrong number". Ordinary tests cannot reliably detect it. A racy program may produce the expected output on every run here and still be incorrect.

The same computation can be written without unsafe by splitting the buffer with chunks_mut, which hands each thread its own &mut [i64] and lets the borrow checker prove the pieces are disjoint. This exercise shows what you take on when you give up that proof.

Why a wrapper type is needed

thread::Scope::spawn (like thread::spawn) requires the closure to be Send, meaning it is safe to move to another thread. A closure is Send only if what it captures is suitable for that: for a move closure, every captured value must be Send. Raw pointers (*mut T and *const T) are never Send, because the type says nothing about who else can reach the memory they point to. A closure that captures a *mut i64 is therefore rejected.

The usual workaround is a wrapper type with a manual implementation:

struct SendPtr(*mut i64);
unsafe impl Send for SendPtr {}

unsafe impl means "I promise this is sound, for reasons the compiler cannot check". Write the reasons down in a // SAFETY: comment. The official solution's comment names the two facts the promise depends on: the ranges are disjoint, and thread::scope joins every worker before the buffer is read or returned, so no thread writes after the buffer's memory is freed. If you cannot state facts like these, the unsafe impl is not justified.

Why the starter still fails

The starter already contains SendPtr and its unsafe impl Send, yet compiling it under edition 2024 fails with an error that never mentions SendPtr:

error[E0277]: `*mut i64` cannot be sent between threads safely
  = help: within `{closure@...}`, the trait `Send` is not implemented for `*mut i64`

The reason is how closures capture variables. Since edition 2021, a closure captures the specific places it uses (a variable, or a field path inside one), not necessarily the whole variable. This rule comes from RFC 2229, "disjoint closure captures". The closure body uses only p.0, so the move closure captures the field p.0, which is a bare *mut i64. No SendPtr value is captured, so the unsafe impl Send for SendPtr never applies.

Under edition 2018, closures captured whole variables, and the same starter compiles. The newer rule lets closures use different fields of one struct independently, but it changes which types matter for Send checks in cases like this one.

The fix is to make the closure use the whole SendPtr. One way is a single statement at the start of the closure body:

s.spawn(move || {
    let p = p; // uses all of `p`, so the closure captures the whole SendPtr
    // ... the loop, writing through `p.0` ...
});

Another way is a method such as fn get(&self) -> *mut i64 { self.0 } and writing through p.get() instead of p.0. Calling a method on p uses the variable p itself, so the move closure captures the whole SendPtr. Making the field private (by putting SendPtr in its own module) additionally stops code outside that module from reaching .0 directly, but privacy is not what fixes the capture. Either fix works.

What Clippy does and does not check here

The grading gate runs Clippy's default lints. Two optional lints are related to this code, and neither replaces your own reasoning:

  • clippy::non_send_fields_in_send_ty (in the off-by-default nursery group) looks for unsafe impl Send on types with fields that are not Send, such as an Rc. It deliberately allows raw-pointer fields, so it reports nothing for SendPtr, even when enabled.
  • clippy::undocumented_unsafe_blocks (in the off-by-default restriction group) reports unsafe blocks and unsafe impls that lack a // SAFETY: comment. It checks that a comment exists, not that the argument in it is correct.