Skip to content

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

Medium Primitives

Send: what it actually guarantees

Count strings using worker threads, combine their counts, and return one (word, count) pair per distinct string, sorted by word.

pub fn word_counts(words: Vec<String>, workers: usize) -> Vec<(String, usize)>

Each input string is one word for this exercise. Do not split it, trim it or change its case. Empty strings and strings containing spaces are valid keys. Sort using Rust's ordinary string ordering, not a locale-specific collation.

For ["b", "a", "b"], return [("a", 1), ("b", 2)], regardless of the worker count. Empty input returns an empty vector. Treat zero workers as one.

For nonempty input, calculate the chunk size as words.len().div_ceil(workers.max(1)). Process each nonempty chunk in a worker and merge its counts. There can be fewer chunks than requested workers; do not create empty workers just to reach that number.

Follow the error to the captured value

The starter puts the vector in Rc and moves a cloned handle into each thread::spawn closure. The compiler reports E0277 because Rc<Vec<String>> does not implement Send.

Read the diagnostic as a chain: spawning requires a Send closure; the closure captures an Rc; that captured value is not Send. The repair concerns how the input is shared, not the spelling of the closure.

What Send guarantees

Send means a value can be transferred to another thread safely. It does not promise that the value can be accessed concurrently through shared references; Sync describes that separate capability.

Send is an auto trait. The compiler normally infers it from a type's components, subject to trait implementations and restrictions. It is also unsafe to implement manually: the implementation must justify every way transferring the value could affect memory safety. An incorrect unsafe implementation can allow undefined behavior that safe callers cannot prevent.

Common types that are not Send include:

Type Reason
Rc<T> Its shared reference count is not synchronized for concurrent access.
*const T, *mut T Their presence does not establish a safe cross-thread ownership or access contract.
NonNull<T> A non-null pointer alone does not establish that contract either.
std::sync::MutexGuard<'_, T> The standard guard preserves platforms' requirement to unlock on the thread that locked.

These restrictions do not mean every wrapper containing a raw pointer must always be non-Send. A carefully implemented abstraction can establish the necessary invariants and provide its own unsafe implementation. That is different from adding unsafe impl Send just to bypass this exercise's error.

Choose shared ownership or scoped borrowing

Arc<Vec<String>> supports shared ownership across threads. For the usual Arc<T> with the default allocator, sending and sharing it requires T: Send + Sync. Atomic reference counting protects ownership bookkeeping; it does not make arbitrary operations on T thread-safe.

Arc::clone creates another handle to the same value rather than cloning the vector's strings. If you take this route, also fix the starter's worker loop: cap the effective worker count at the nonempty input length, or otherwise spawn only for nonempty ranges. Replacing Rc with Arc alone leaves unnecessary threads when workers exceed words.

The reference solution uses thread::scope instead. Workers borrow slices of words, and the scope joins them before returning. This fits work that must finish within this call, while the input vector remains alive.

Borrowing avoids an additional reference-counted owner and lets each local map use &str keys. It does not make the algorithm allocation-free: maps, handle/result vectors and the final owned strings still need storage, and spawning threads has costs. Neither approach is universally faster.

Both shared ownership and scoped borrowing can solve the task. Keep the choice tied to the lifetime and ownership requirements of the surrounding program.

Keep borrowed keys alive until conversion

In the scoped solution, each HashMap<&str, usize> refers to strings owned by the original words vector. Those maps can be returned from their worker threads because the strings outlive the scope. The vector must stay alive while the partial maps are merged and their keys are used.

Convert each distinct key to an owned String when building the final result. The returned vector then owns its strings and does not borrow from the input that will be dropped when the function returns.

Make the output order explicit

HashMap iteration order is unspecified. Collecting its entries directly would not meet the sorted-output contract. Sort the final pairs by word; each word appears only once, so sorting the pairs also gives the required ordering.

The reference joins handles and merges partial maps in chunk order, not completion order. Thread scheduling can change when work finishes, but it does not change the counts. Sorting removes the remaining map-order variation from the observable output.

Passing requires compilation, correct counts and ordering, and Clippy with warnings treated as errors. Output tests alone cannot prove that an implementation used worker threads.