Vol. 7 · № 01 · Un atelier RustA Rust workshop

Threads & closures move Threads & move closures

Le move qui dit cette donnée part avec toi : transférer la possession dans un thread. La même règle aliasing XOR mutation, appliquée aux threads — et le thread scopé qui prête au lieu de déplacer. The move that says this data leaves with you: transferring ownership into a thread. The same aliasing XOR mutation rule, applied to threads — and the scoped thread that lends instead of moving.

AudienceAudience
Dev maîtrisant la possession et les emprunts (Vol 1, Vol 2) Dev fluent in ownership and borrowing (Vol 1, Vol 2)
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ ConcurrenceThreadsmove
SommaireContents ·
№ 01CadrageFraming3 min

thread::spawn lance un fil qui peut survivre à la fonction qui l'a créé.thread::spawn launches a thread that can outlive the function that created it.

On ouvre le Vol 7 sur la promesse la plus citée de Rust : la « concurrence sans peur ». Le slogan cache une vérité simple — ce n'est pas un nouveau mécanisme, c'est la règle du Vol 1 (aliasing XOR mutation) projetée sur un nouveau terrain : plusieurs threads. thread::spawn prend une closure et la lance sur un fil d'exécution séparé, qui tourne en parallèle du reste. Mais ce fil a une propriété déstabilisante : il peut continuer à tourner après que la fonction qui l'a lancé soit revenue. Tout ce qu'il touche par référence risque alors de pointer vers une donnée déjà détruite — le use-after-free du Vol 2, mais entre threads. C'est pourquoi la closure d'un thread doit, le plus souvent, emporter ses données avec elle : le mot-clé move.We open Vol 7 on Rust's most-quoted promise: 'fearless concurrency'. The slogan hides a simple truth — it's not a new mechanism, it's Vol 1's rule (aliasing XOR mutation) projected onto new ground: several threads. thread::spawn takes a closure and runs it on a separate thread of execution, in parallel with the rest. But that thread has an unsettling property: it can keep running after the function that launched it has returned. Anything it touches by reference then risks pointing at already-destroyed data — Vol 2's use-after-free, but across threads. That's why a thread's closure must, most often, take its data with it: the move keyword.

Un thread, sa closure move, et le join qui l'attendA thread, its move closure, and the join that waits for it
use std::thread;

let salutation = String::from("bonjour");

let handle = thread::spawn(move || {        // `move` : la closure EMPORTE `salutation`
    println!("{salutation} depuis le thread");
});                                         // le thread tourne EN PARALLÈLE du main

handle.join().unwrap();                     // …et on ATTEND sa fin avant de continuer.
// sans ce join(), main pourrait se terminer le premier — et tout le programme s'arrêter,
// thread compris, avant qu'il n'ait rien affiché.
Pourquoi le thread est dangereux par défautWhy the thread is dangerous by default
main()
spawn → exécutionspawn → running
bloqué sur join()blocked on join()
thread
tourne EN PARALLÈLE → finruns IN PARALLEL → end

⚠ sans join(), main pourrait franchir la fin de scope pendant que le thread tourne — toute référence empruntée pointerait alors vers du vide.⚠ without join(), main could cross its scope end while the thread runs — any borrowed reference would then point at nothing.

Le fil lancé vit sa propre vie. Le main (ou n'importe quelle fonction) peut atteindre la fin de son scope — et détruire ses variables locales — pendant que le thread tourne encore. Si la closure du thread tenait une référence vers l'une de ces variables, cette référence pointerait vers du vide. Le compilateur ne peut pas prouver l'ordre des deux fins : il doit donc refuser tout emprunt qu'il ne peut garantir. La solution par défaut n'est pas de garder la référence — c'est de transférer la possession dans le thread, pour qu'il n'y ait plus aucune référence à faire survivre.The launched thread lives its own life. main (or any function) can reach the end of its scope — and destroy its locals — while the thread is still running. If the thread's closure held a reference to one of those variables, that reference would point at nothing. The compiler can't prove the order of the two endings: so it must refuse any borrow it can't guarantee. The default solution isn't to keep the reference — it's to transfer ownership into the thread, so there's no reference left to keep alive.

Le réflexe du numéroThe issue's reflex

« Cette donnée traverse-t-elle un thread — et qu'est-ce que move lui transfère exactement ? » Avant de lancer un fil, on se demande qui possédera ce qu'il touche. La règle « aliasing XOR mutation » ne s'arrête pas à la frontière du thread : c'est elle qui décide ce qui peut être déplacé, prêté, ou partagé — et à quel prix. Le data race n'est pas un bug qu'on attrape par un test : c'est un programme qui ne compile pas.'Does this data cross into a thread — and what exactly does move transfer to it?' Before launching a thread, ask who will own what it touches. The 'aliasing XOR mutation' rule doesn't stop at the thread boundary: it's what decides what may be moved, lent, or shared — and at what price. A data race isn't a bug you catch with a test: it's a program that doesn't compile.

join, ou le thread orphelinjoin, or the orphan thread

thread::spawn rend un JoinHandle. L'ignorer détache le thread : il tourne sans qu'on l'attende, et meurt brutalement si main se termine avant lui. handle.join() bloque jusqu'à ce que le thread finisse et récupère ce que sa closure a retourné (ou l'erreur s'il a paniqué). Retenir le handle et le joindre, c'est reprendre la main sur l'ordre des fins — exactement ce que le compilateur nous demandait de garantir.thread::spawn returns a JoinHandle. Ignoring it detaches the thread: it runs unawaited, and dies abruptly if main ends first. handle.join() blocks until the thread finishes and recovers what its closure returned (or the error if it panicked). Holding the handle and joining it means regaining control over the order of endings — exactly what the compiler asked us to guarantee.

№ 02La surpriseThe surprise4 min

Une closure qui emprunte refuse de compiler dès qu'on la lance dans un thread.A closure that borrows refuses to compile the moment you launch it in a thread.

Hors d'un thread, une closure capture ce dont elle a besoin de la façon la plus légère possible : par référence si lire suffit, par &mut si elle mute, par valeur seulement si elle y est forcée. C'est le comportement par défaut, et il est commode. Mais passez cette même closure à thread::spawn, et le compilateur durcit brutalement ses exigences : il refuse toute capture par référence avec un E0373 sans appel — « closure may outlive the current function ». La raison est celle du chapitre précédent : le thread peut survivre au scope, donc une référence capturée pourrait pendre. La capture la plus légère devient la plus dangereuse. Le mot move force alors la closure à tout capturer par valeur — à emporter ses données plutôt que les pointer.Outside a thread, a closure captures what it needs in the lightest way possible: by reference if reading suffices, by &mut if it mutates, by value only when forced. That's the default behaviour, and it's convenient. But pass that same closure to thread::spawn, and the compiler abruptly hardens its demands: it refuses any capture by reference with an unappealable E0373 — 'closure may outlive the current function'. The reason is the previous chapter's: the thread can outlive the scope, so a captured reference could dangle. The lightest capture becomes the most dangerous. The move keyword then forces the closure to capture everything by value — to take its data along rather than point at it.

Le mur — la closure emprunte, le compilateur ne peut rien garantirThe wall — the closure borrows, the compiler can guarantee nothing
use std::thread;

let donnees = vec![1, 2, 3];

let handle = thread::spawn(|| {             // closure SANS move → capture par référence
    println!("{:?}", donnees);              // ✕ error[E0373]: closure may outlive the current
});                                         //   function, but it borrows `donnees`, which is
                                            //   owned by the current function
handle.join().unwrap();
// le compilateur ne PEUT PAS prouver que le thread finit avant que `donnees` soit détruite.
// une référence qui survivrait à sa donnée, c'est précisément le dangling du Vol 2.

L'erreur ne parle pas de threads ni de data races — elle parle de durées de vie. C'est tout le Vol 2 qui ressurgit : une référence ne doit jamais survivre à sa donnée. Ici, le compilateur n'a aucune preuve que le thread se termine avant la fin du scope de donnees ; faute de preuve, il refuse. Remarquez la finesse : le problème n'est pas que le code provoque un bug, c'est qu'il ne peut pas prouver son absence. En Rust, l'absence de preuve est un refus.The error doesn't mention threads or data races — it mentions lifetimes. All of Vol 2 resurfaces: a reference must never outlive its data. Here, the compiler has no proof the thread ends before donnees's scope does; lacking proof, it refuses. Note the subtlety: the problem isn't that the code causes a bug, it's that it can't prove its absence. In Rust, absence of proof is a refusal.

Le compilateur ne refuse pas un bug — il refuse une absence de preuve : le thread pourrait survivre à la donnée, et il ne peut pas prouver le contraire.The compiler doesn't refuse a bug — it refuses an absence of proof: the thread might outlive the data, and it can't prove otherwise.
№ 03La mécaniqueThe mechanics5 min

move est le transfert du Vol 1 — franchissant la frontière d'un thread.move is Vol 1's transfer — crossing a thread's boundary.

move ne fait rien de magique ni de spécifique aux threads. Il force la closure à capturer chaque variable utilisée par valeur : pour un type possédé (String, Vec), c'est un move au sens exact du Vol 1 — la possession se transfère, l'original devient inaccessible ; pour un type Copy (un i32, un bool), c'est une copie, et l'original reste utilisable. La seule nouveauté, c'est où va la valeur : de l'autre côté d'une frontière d'exécution, dans un fil qui tournera seul. Et parce que ce fil peut vivre indéfiniment, la signature de spawn impose une borne que tout ce qui traverse doit respecter : 'static. Comprendre ces deux faces — le move classique, et la borne 'static — c'est comprendre tout ce qui se passe quand une donnée entre dans un thread.move does nothing magical or thread-specific. It forces the closure to capture every used variable by value: for an owned type (String, Vec), that's a move in Vol 1's exact sense — ownership transfers, the original becomes inaccessible; for a Copy type (an i32, a bool), it's a copy, and the original stays usable. The only novelty is where the value goes: across an execution boundary, into a thread that will run on its own. And because that thread may live indefinitely, spawn's signature imposes a bound everything crossing must satisfy: 'static. Understanding these two faces — the classic move, and the 'static bound — is understanding everything that happens when data enters a thread.

La possession franchit la frontière — l'original devient inaccessibleOwnership crosses the boundary — the original becomes inaccessible
main()
donnees: Vec<i32>
déplacée → inaccessiblemoved → inaccessible
frontière du threadthread boundarymove ▸'static
thread
donnees: Vec<i32>
nouvelle propriétairenew owner
Déplacée dedans, donc partie dehorsMoved in, hence gone outside
use std::thread;

let donnees = vec![1, 2, 3];

let handle = thread::spawn(move || {
    let somme: i32 = donnees.iter().sum();   // le thread POSSÈDE `donnees`
    somme                                    // une closure peut RENDRE une valeur
});

println!("{:?}", donnees);                   // ✕ error[E0382]: borrow of moved value: `donnees`
//   `donnees` a été déplacée dans la closure : c'est le move du Vol 1, à l'identique —
//   mais cette fois la valeur a traversé la frontière d'un thread.

let total = handle.join().unwrap();          // join() rend ce que la closure a retourné : 6

L'erreur E0382 est exactement celle du Vol 1 : « borrow of moved value ». Rien de propre aux threads — donnees a simplement changé de propriétaire, et l'ancien n'a plus le droit d'y toucher. La seule façon de récupérer un résultat est de le faire ressortir par join() : la closure retourne une valeur, le thread la porte jusqu'à sa fin, et join() la rend au parent. La possession est entrée, du calcul a eu lieu, une valeur est ressortie — un aller-retour propre, sans aucune référence partagée.The E0382 error is exactly Vol 1's: 'borrow of moved value'. Nothing thread-specific — donnees simply changed owner, and the old one no longer has the right to touch it. The only way to get a result back is to make it come out through join(): the closure returns a value, the thread carries it to its end, and join() hands it back to the parent. Ownership went in, computation happened, a value came out — a clean round-trip, with no shared reference at all.

La borne 'static, inscrite dans la signature de spawnThe 'static bound, written into spawn's signature
// pourquoi spawn exige 'static : la signature elle-même le dit
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,       // la closure (et tout ce qu'elle capture)
    T: Send + 'static;                        // doit pouvoir vivre AUSSI LONGTEMPS qu'il faut

// 'static ne veut PAS dire « éternel » : il veut dire « ne dépend d'aucun emprunt
// à durée limitée ». une valeur possédée (String, Vec) est 'static ; une référence
// empruntée &donnees ne l'est pas — d'où le refus du chapitre précédent.
'static n'est pas « éternel ».'static doesn't mean 'forever'.

C'est le malentendu le plus tenace du débutant. 'static ne dit pas « cette donnée vit jusqu'à la fin du programme » ; il dit « cette donnée ne dépend d'aucune référence à durée limitée — elle peut donc vivre aussi longtemps que nécessaire ». Une String possédée satisfait 'static : elle se porte elle-même. Un &str emprunté d'une variable locale ne le satisfait pas. C'est pourquoi move (qui transforme les emprunts en possessions) débloque spawn : il rend 'static ce qui ne l'était pas.It's the beginner's most stubborn misunderstanding. 'static doesn't say 'this data lives until the program ends'; it says 'this data depends on no time-limited reference — so it can live as long as needed'. An owned String satisfies 'static: it carries itself. A &str borrowed from a local variable doesn't. That's why move (which turns borrows into ownership) unlocks spawn: it makes 'static what wasn't.

Entrer dans un thread, c'est le move du Vol 1 plus une borne : tout ce qui traverse doit être 'static — porté par soi, lié à aucun emprunt.Entering a thread is Vol 1's move plus a bound: everything crossing must be 'static — self-carried, tied to no borrow.
№ 04Le piègeThe trap4 min

Dégainer Arc pour prêter une donnée locale — qu'un thread scopé emprunterait sans rien copier.Reaching for Arc to lend local data — that a scoped thread would borrow without copying a thing.

Le chapitre précédent a posé une vérité dure : spawn exige 'static, donc on ne peut pas lui prêter une référence vers une variable locale. Le réflexe immédiat — surtout quand on a vu Arc au Vol 6 — est d'emballer la donnée dans un Arc et d'en cloner le pointeur pour chaque thread. Ça compile, ça marche. Mais réfléchissez à ce qu'on vient de payer : une allocation sur le tas, un compteur atomique incrémenté et décrémenté à chaque clone et chaque drop, et la contrainte 'static — tout ça pour une donnée qui ne sort jamais de la fonction, que les threads ne font que lire, et qui sera encore là quand ils auront fini. On a pris l'outil du partage permanent pour un simple prêt temporaire. La réponse de la std s'appelle thread::scope.The previous chapter laid down a hard truth: spawn requires 'static, so you can't lend it a reference to a local variable. The immediate reflex — especially once you've met Arc in Vol 6 — is to wrap the data in an Arc and clone the pointer for each thread. It compiles, it works. But consider what you just paid: a heap allocation, an atomic counter bumped up and down on every clone and every drop, and the 'static constraint — all for data that never leaves the function, that the threads only read, and that will still be there when they're done. You reached for the tool of permanent sharing to do a simple temporary loan. The std's answer is thread::scope.

Le contre-exemple — Arc + clone pour un simple prêt en lectureThe counter-example — Arc + clone for a simple read loan
use std::sync::Arc;
use std::thread;

let donnees = Arc::new(vec![1, 2, 3, 4]);   // on EMBALLE dans un Arc juste pour partager

let d1 = Arc::clone(&donnees);              // un clone du compteur (atomique) par thread
let h1 = thread::spawn(move || {            // chaque thread EMPORTE son Arc
    println!("somme = {}", d1.iter().sum::<i32>());
});
let d2 = Arc::clone(&donnees);
let h2 = thread::spawn(move || {
    println!("max = {:?}", d2.iter().max());
});
h1.join().unwrap();
h2.join().unwrap();
// Arc + comptage atomique + 'static — pour une donnée qui ne quitte JAMAIS la fonction.
Le bon réflexe — thread::scope prête la donnée, sans la déplacer ni la copierThe right reflex — thread::scope lends the data, without moving or copying it
use std::thread;

let donnees = vec![1, 2, 3, 4];             // une simple Vec, possédée localement

thread::scope(|s| {                         // un scope qui GARANTIT le join avant d'en sortir
    s.spawn(|| {                            // pas de move : la closure EMPRUNTE `donnees`
        println!("somme = {}", donnees.iter().sum::<i32>());
    });
    s.spawn(|| {                            // un second thread l'emprunte AUSSI
        println!("max = {:?}", donnees.iter().max());   // deux lecteurs → aliasing autorisé
    });
});                                         // ◂ ici, les deux threads ont DÉJÀ joint
// `donnees` est restée locale : prêtée par & à deux threads, sans Arc, sans clone, sans 'static.
spawn + Arc::clone
thread::scope
allocation tasheap allocation
oui (le Arc)yes (the Arc)
aucunenone
coût par threadcost per thread
clone + compteur atomiqueclone + atomic counter
un emprunt &one & borrow
borne 'static'static bound
exigéerequired
levéelifted
vérifié à la compilationchecked at compile time
ouiyes
ouiyes

thread::scope renverse la contrainte 'static en apportant une garantie que spawn n'avait pas : tous les threads lancés dans le scope sont joints avant que scope ne retourne. Le compilateur le sait, donc il sait qu'aucun thread ne peut survivre à donnees — et il autorise l'emprunt par &, sans 'static. Deux threads qui lisent partagent un emprunt immuable : c'est « aliasing XOR mutation » dans toute sa lettre — autant de & qu'on veut, tant que personne ne mute. Pas d'Arc, pas de clone, pas d'allocation : la donnée reste là où elle est, simplement prêtée.thread::scope reverses the 'static constraint by bringing a guarantee spawn lacked: all threads launched in the scope are joined before scope returns. The compiler knows this, so it knows no thread can outlive donnees — and it allows the borrow by &, with no 'static. Two threads that read share an immutable borrow: that's 'aliasing XOR mutation' to the letter — as many & as you like, as long as nobody mutates. No Arc, no clone, no allocation: the data stays where it is, simply lent.

Quand Arc est vraiment justifiéWhen Arc is truly justified

Le verdict n'est pas « jamais ». Arc devient indispensable dès que le partage survit au scope : un thread détaché qu'on ne joint pas, une tâche poussée dans un pool, une donnée stockée dans une structure qui vivra au-delà. Là, aucune borne de scope ne peut garantir l'ordre des fins — il faut un comptage de références qui décide à l'exécution. thread::scope est l'outil du parallélisme borné (« calcule ceci en parallèle, puis rends-moi la main ») ; Arc, celui du partage qui dure.The verdict isn't 'never'. Arc becomes essential as soon as the sharing outlives the scope: a detached thread you don't join, a task pushed into a pool, data stored in a structure that will live beyond. There, no scope bound can guarantee the order of endings — you need a reference count deciding at runtime. thread::scope is the tool of bounded parallelism ('compute this in parallel, then return to me'); Arc, the tool of sharing that lasts.

Le test mentalThe mental test

« Les threads finissent-ils avant la donnée qu'ils touchent ? » Si oui — et c'est le cas de tout calcul parallèle borné, où l'on attend les résultats avant de continuer — thread::scope suffit, et tu gardes l'emprunt vérifié à la compilation. Si non — le thread part vivre sa vie, sa fin n'est pas ordonnée par rapport à la donnée — alors Arc gagne son prix. La question n'est pas « est-ce partagé ? » mais « le partage survit-il au scope ? ».'Do the threads finish before the data they touch?' If yes — and that's the case for any bounded parallel computation, where you await the results before continuing — thread::scope suffices, and you keep the borrow checked at compile time. If no — the thread goes off to live its life, its end unordered relative to the data — then Arc earns its price. The question isn't 'is it shared?' but 'does the sharing outlive the scope?'.

Avant d'emballer dans Arc, demande si le thread finit avant la donnée : si oui, un thread scopé la prête — sans tas, sans compteur, sans 'static.Before wrapping in Arc, ask whether the thread finishes before the data: if so, a scoped thread lends it — no heap, no counter, no 'static.
№ 05Bilan & éditoWrap-up & editorial2 min

La concurrence sans peur, c'est le Vol 1 qui franchit la frontière d'un thread.Fearless concurrency is Vol 1 crossing a thread's boundary.

Ce numéro ouvre le Vol 7 sans rien inventer : il reprend la possession du Vol 1 et les durées de vie du Vol 2, et les projette sur un fil d'exécution séparé. Un thread peut survivre à la fonction qui l'a lancé ; donc une closure qui emprunte une variable locale est refusée — la référence pourrait pendre. move résout cela en transférant la possession dans le thread : c'est le move du Vol 1, franchissant une frontière, avec une borne en plus — tout ce qui traverse doit être 'static. Et le piège ferme la boucle : emballer dans Arc pour un simple prêt en lecture, c'est payer une allocation et un compteur atomique là où thread::scope prête la donnée par référence, vérifié à la compilation, parce qu'il garantit que les threads joignent avant la fin du scope.This issue opens Vol 7 without inventing anything: it takes Vol 1's ownership and Vol 2's lifetimes, and projects them onto a separate thread of execution. A thread can outlive the function that launched it; so a closure borrowing a local variable is refused — the reference could dangle. move solves this by transferring ownership into the thread: it's Vol 1's move, crossing a boundary, with one extra bound — everything crossing must be 'static. And the trap closes the loop: wrapping in Arc for a simple read loan means paying an allocation and an atomic counter where thread::scope lends the data by reference, checked at compile time, because it guarantees threads join before the scope ends.

L'outilThe toolCe qu'il fait traverser le threadWhat it sends across the thread
move ||La possession : la donnée part dans le thread, l'original devient inaccessible.Ownership: the data leaves into the thread, the original becomes inaccessible.
'staticLa borne : seul ce qui ne dépend d'aucun emprunt peut entrer dans spawn.The bound: only what depends on no borrow may enter spawn.
JoinHandleLe retour : join() attend la fin et rend la valeur produite (ou le panic).The return: join() awaits the end and hands back the produced value (or the panic).
thread::scopeUn prêt : emprunt par & sans 'static, car les threads joignent avant la fin du scope.A loan: borrow by & with no 'static, since threads join before the scope ends.
Un thread reçoit une donnée déplacée, ou prêtée le temps d'un scope — mais jamais partagée et mutée : cette dernière porte attend encore sa clé.A thread receives data moved, or lent for a scope — but never shared and mutated: that last door still awaits its key.
Mot de l'éditeurFrom the editor

On sait déplacer et prêter. Reste la porte fermée : partager et muter.We can move and lend. One door stays shut: share and mutate.

Regardez ce qu'on a obtenu, et ce qu'on n'a pas. move donne une donnée à un seul thread : transfert, pas partage. thread::scope la prête à plusieurs threads — mais en lecture seule, parce que « aliasing XOR mutation » interdit que deux threads la tiennent pendant que l'un écrit. C'est précisément le cas qui manque : plusieurs threads qui veulent muter la même donnée. Un compteur partagé, un cache, un état de jeu. La règle du Vol 1 dit que c'est interdit — et elle a raison : deux écritures concurrentes sur la même donnée, c'est la définition même du data race.Look at what we got, and what we didn't. move gives data to a single thread: transfer, not sharing. thread::scope lends it to several threads — but read-only, because 'aliasing XOR mutation' forbids two threads holding it while one writes. That's exactly the missing case: several threads wanting to mutate the same data. A shared counter, a cache, a game state. Vol 1's rule says it's forbidden — and it's right: two concurrent writes to the same data is the very definition of a data race.

Vol 7 №2 : Send, Sync et Arc<Mutex>. On verra d'abord les deux traits auto-dérivés qui décident, à la compilation, ce qui a le droit de traverser un thread (Send) et ce qui peut être partagé par référence entre threads (Sync) — la mécanique invisible derrière le refus du chapitre 2. Puis la combinaison qui rouvre enfin la porte : Arc pour partager la possession entre threads, Mutex pour rétablir l'exclusivité de la mutation — l'un parce que l'autre ne suffit pas. Et le rappel honnête que Rust nous doit : il empêche le data race, pas le deadlock. La règle « un seul écrivain » tient toujours ; elle change seulement de gardien.Vol 7 №2: Send, Sync and Arc<Mutex>. We'll first meet the two auto-derived traits that decide, at compile time, what may cross a thread (Send) and what may be shared by reference between threads (Sync) — the invisible machinery behind chapter 2's refusal. Then the combination that finally reopens the door: Arc to share ownership between threads, Mutex to restore exclusivity of mutation — one because the other isn't enough. And the honest reminder Rust owes us: it prevents the data race, not the deadlock. The 'single writer' rule still holds; it only changes guardian.