Vol. 8 · № 02 · Un atelier RustA Rust workshop

Le problème des fonctions colorées The colored functions problem

async contamine la signature de tout ce qui l'appelle — et la donnée tenue à travers un .await doit être Send. Le Vol 7 ressurgit en plein async. async contaminates the signature of everything that calls it — and data held across a .await must be Send. Vol 7 resurfaces mid-async.

AudienceAudience
Dev connaissant Future/.await (Vol 8 №1), Send et les threads (Vol 7) Dev who knows Future/.await (Vol 8 №1), Send and threads (Vol 7)
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●● AsyncColoringSend
SommaireContents ·
№ 01CadrageFraming3 min

Le mot-clé ne reste pas où on le pose : async remonte toute la pile d'appels.The keyword doesn't stay put: async climbs the whole call stack.

Le numéro précédent a regardé un Future seul, comme s'il vivait isolé. Il n'en est rien : appeler de l'async a une conséquence qui déborde la fonction. Pour obtenir la valeur d'un Future, on l'await ; mais .await n'est permis que dans un contexte async. Donc une fonction qui await devient elle-même async, et celle qui l'appelle aussi, et ainsi de suite jusqu'au main. C'est ce qu'on appelle le « problème des fonctions colorées » : il existe deux couleurs de fonctions, synchrone et asynchrone, et la couleur async se propage de proche en proche dès qu'on l'introduit quelque part. On ne peut pas appeler simplement une async fn depuis du code synchrone — les deux mondes ne se composent pas librement. C'est la friction promise à la fin du №1 : la couleur a un prix, et il se paie sur toute la chaîne d'appels.The previous issue looked at a Future alone, as if it lived in isolation. It doesn't: calling async has a consequence that overflows the function. To get a Future's value, you await it; but .await is only allowed in an async context. So a function that awaits becomes async itself, and its caller too, and so on up to main. This is the 'colored functions problem': there are two colors of function, synchronous and asynchronous, and the async color propagates step by step the moment you introduce it somewhere. You can't simply call an async fn from synchronous code — the two worlds don't compose freely. It's the friction promised at the end of №1: color has a price, and it's paid across the whole call chain.

La couleur async remonte, d'appelé en appelantThe async color climbs, from callee to caller
#[tokio::main] async fn mainasync
la racine : le runtimethe root: the runtime
▲
async fn demarrerasync
.await demarrer → doit être async.await demarrer → must be async
▲
async fn lire_configasync
la source : la 1ʳᵉ async fnthe source: the 1st async fn

▲ la couleur remonte : impossible de garder un appelant synchrone au-dessus d'un .await▲ the color climbs: impossible to keep a synchronous caller above an .await

Une seule async fn, et toute la pile suitOne async fn, and the whole stack follows
async fn lire_config() -> Config { /* … .await … */ }

async fn demarrer() -> Config {     // ← DOIT être async : elle .await lire_config
    let cfg = lire_config().await;
    valider(cfg)
}

#[tokio::main]
async fn main() {                   // ← async aussi, remontée jusqu'à la racine
    let cfg = demarrer().await;
}
// la couleur remonte : quiconque .await de l'async DOIT être async lui-même (ou ouvrir un runtime).

Suis la couleur de bas en haut. lire_config est async ; demarrer l'.await, donc demarrer est forcée d'être async ; main l'.await à son tour, donc main est async — et c'est #[tokio::main] qui lui donne le runtime nécessaire. Une seule fonction asynchrone au fond de la pile, et toute la chaîne au-dessus se teinte. Ce n'est pas un défaut d'implémentation : c'est structurel. Le chapitre 3 montrera pourquoi la frontière est réelle, et non un caprice du langage.Follow the color bottom-up. lire_config is async; demarrer .awaits it, so demarrer is forced to be async; main .awaits it in turn, so main is async — and #[tokio::main] gives it the necessary runtime. A single async function at the bottom of the stack, and the whole chain above takes the tint. It's not an implementation flaw: it's structural. Chapter 3 will show why the boundary is real, not a language whim.

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

« Où passe la frontière sync / async dans mon code — et qu'est-ce que ça coûte de la franchir ? » L'async n'est pas gratuit : il colore, il contraint, et il fait ressurgir Send dès qu'une tâche traverse des threads. Le bon réflexe n'est pas de tout teindre en async « au cas où », mais de savoir précisément quelle partie du programme est asynchrone, où se trouve le pont avec le monde synchrone, et ce que l'on tient à travers chaque .await.'Where does the sync / async boundary run in my code — and what does crossing it cost?' Async isn't free: it colors, it constrains, and it brings back Send the moment a task crosses threads. The right reflex isn't to tint everything async 'just in case', but to know precisely which part of the program is asynchronous, where the bridge to the synchronous world sits, and what you hold across each .await.

« Function coloring », d'où vient le terme'Function coloring', where the term comes from

L'image vient d'un billet célèbre de Bob Nystrom (« What Color is Your Function? »), écrit à propos de JavaScript. L'idée : dans un langage où l'async est un mode d'appel distinct, chaque fonction porte une « couleur », et les règles d'appel entre couleurs sont asymétriques et pénibles — le rouge ne peut pas appeler le bleu sans cérémonie. Rust hérite de ce problème, syntaxe JS oblige. La différence, c'est qu'en Rust la couleur est honnête : elle est inscrite dans le type (Future), vérifiée à la compilation, jamais silencieuse.The image comes from a famous post by Bob Nystrom ('What Color is Your Function?'), written about JavaScript. The idea: in a language where async is a distinct calling mode, each function carries a 'color', and the calling rules between colors are asymmetric and painful — red can't call blue without ceremony. Rust inherits this problem, JS syntax oblige. The difference is that in Rust the color is honest: it's written in the type (Future), checked at compile time, never silent.

№ 02La surpriseThe surprise4 min

Du code synchrone ne peut pas .await — et il n'existe qu'un pont.Synchronous code can't .await — and there's only one bridge.

La couleur ne serait qu'une gêne théorique si les deux mondes communiquaient librement. Ils ne le font pas, et c'est la surprise concrète du numéro. Dans une fonction synchrone, écrire .await est une erreur de compilation pure et simple : await n'est permis que dans un contexte async. Tu ne peux donc pas glisser un appel async dans une méthode de trait synchrone, dans une closure passée à un Iterator::map, dans un Drop — partout où l'API attend du code sync, l'async est interdit de séjour. Et dans l'autre sens, déclencher du async depuis du sync demande une machinerie explicite : ouvrir un runtime et appeler block_on. Ce pont existe, mais il bloque le thread — donc il n'a sa place qu'à la racine du programme, jamais au milieu d'une chaîne asynchrone.Color would be a mere theoretical nuisance if the two worlds talked freely. They don't, and that's the issue's concrete surprise. In a synchronous function, writing .await is a plain compile error: await is only allowed in an async context. So you can't slip an async call into a synchronous trait method, into a closure passed to Iterator::map, into a Drop — anywhere the API expects sync code, async is barred. And the other way, triggering async from sync demands explicit machinery: open a runtime and call block_on. That bridge exists, but it blocks the thread — so it belongs only at the program's root, never in the middle of an async chain.

Le mur — .await interdit hors d'un contexte asyncThe wall — .await forbidden outside an async context
fn charger() -> Config {            // fonction SYNCHRONE (non colorée)
    let cfg = lire_config().await;  // ✕ error[E0728]: `await` is only allowed inside
    cfg                             //   `async` functions and blocks
}
// et sans .await ? lire_config() rend juste un Future inerte (№1) — la config n'est
// jamais chargée. dans du sync, on ne peut NI attendre, NI déclencher proprement un Future.

L'erreur E0728 est catégorique. La fonction n'est pas colorée, donc elle n'a aucun exécuteur sous la main pour piloter le Future ; .await n'a littéralement aucun sens ici. La seule façon « propre » de lever l'erreur dans le corps même de charger serait de colorer charger en async — c'est-à-dire de propager la couleur, exactement comme au chapitre 1. C'est ce qui rend l'async collant : on ne le contient pas en l'enfermant dans une fonction, il déborde par construction.The E0728 error is categorical. The function isn't colored, so it has no executor at hand to drive the Future; .await literally makes no sense here. The only 'clean' way to lift the error in charger's very body would be to color charger as async — that is, to propagate the color, exactly as in chapter 1. That's what makes async sticky: you don't contain it by enclosing it in a function, it overflows by construction.

On entre dans l'async une fois, à la racine — et l'on n'en ressort plus : .await n'existe pas dans le monde synchrone.You enter async once, at the root — and don't leave: .await doesn't exist in the synchronous world.
№ 03La mécaniqueThe mechanics5 min

La couleur n'est pas un caprice : c'est la frontière de l'exécuteur.The color isn't a whim: it's the executor boundary.

Pourquoi ne peut-on pas simplement appeler une async fn n'importe où ? Parce que la valeur d'un Future ne s'obtient qu'en le pollant jusqu'à Ready — et poller, c'est le travail de l'exécuteur, qui n'existe que dans le monde async. Une fonction synchrone court jusqu'au bout sur sa pile : elle ne sait pas suspendre, ni rendre la main, ni reprendre plus tard. Une fonction async, elle, est une machine à états qu'un exécuteur fait avancer entre des points de suspension. Ce sont deux régimes d'exécution incompatibles, et la « couleur » n'est que le nom de cette frontière. Le runtime est ce qui la franchit, et il ne la franchit qu'en un seul sens propre : block_on, à la racine, fait entrer le thread synchrone dans le monde async. Une fois dedans, plus de frontière : .await en séquentiel, join! en concurrent, spawn pour des tâches indépendantes — tout compose.Why can't you just call an async fn anywhere? Because a Future's value is only obtained by polling it to Ready — and polling is the executor's job, which exists only in the async world. A synchronous function runs to the end on its stack: it can't suspend, yield, or resume later. An async function, by contrast, is a state machine an executor advances between suspension points. These are two incompatible execution regimes, and 'color' is just the name of that boundary. The runtime is what crosses it, and it crosses it cleanly in only one direction: block_on, at the root, brings the synchronous thread into the async world. Once inside, no more boundary: .await for sequential, join! for concurrent, spawn for independent tasks — everything composes.

Deux régimes, un seul pont : le runtimeTwo regimes, one bridge: the runtime
monde synchronesynchronous world
court jusqu'au bout sur la pileruns to the end on the stack
fn main()
fn helper()
le runtimethe runtimeblock_on ▸◂ spawn_blocking
monde asyncasync world
se suspend, reprend, composé par l'exécuteursuspends, resumes, composed by the executor
.await
join! · spawn
Pourquoi le sync ne peut pas piloter un FutureWhy sync can't drive a Future
// pourquoi la couleur existe : pour OBTENIR la valeur d'un Future, il faut le
// POLLER jusqu'à Ready (№1) — donc être piloté par un exécuteur. seul le monde async l'est.
async fn valeur() -> i32 { 42 }

// dans du sync, valeur() rend un Future inerte : aucun exécuteur pour le faire avancer.
// .await veut dire « pilote ce Future via l'exécuteur courant » → encore faut-il qu'il y en ait un.
// la frontière n'est pas un caprice : c'est la limite entre « je peux suspendre » et « je cours
// jusqu'au bout sur la pile ». le runtime est le SEUL pont entre les deux.

La frontière sépare deux façons d'exécuter, pas deux syntaxes. Le code synchrone court jusqu'au bout ; le code async peut se suspendre, et a besoin d'un exécuteur pour reprendre. On comprend alors pourquoi block_on bloque : il ne peut pas faire autrement que d'immobiliser le thread synchrone pendant qu'il pilote, lui, la machine à états async jusqu'à son terme. C'est le prix d'entrée, payé une fois.The boundary separates two ways of executing, not two syntaxes. Synchronous code runs to the end; async code can suspend, and needs an executor to resume. You then see why block_on blocks: it can do nothing but freeze the synchronous thread while it drives the async state machine to completion. It's the entry price, paid once.

Une fois dans l'async, tout composeOnce in async, everything composes
#[tokio::main]
async fn main() {
    // DANS le monde async, tout compose sans friction :
    let a = etape_un().await;               // séquentiel : on attend a avant la suite
    let (x, y) = tokio::join!(f(), g());    // concurrent : deux Futures sur LA MÊME tâche
    let h = tokio::spawn(tache());          // tâche INDÉPENDANTE, confiée au runtime
    let r = h.await.unwrap();               // spawn rend un JoinHandle — l'écho du thread (Vol 7)
}
Les deux ponts, dans les deux sens.The two bridges, both directions.

sync → async : block_on, à la racine, bloque le thread jusqu'à ce que le Future finisse. async → sync : spawn_blocking (vu au №1), qui confie un calcul bloquant à un pool de threads dédié pour ne pas geler l'exécuteur. Et tokio::spawn lance un Future comme tâche indépendante, rendant un JoinHandle — exactement le geste de thread::spawn du Vol 7, mais sur le runtime au lieu de l'OS. Trois outils, une seule idée : franchir une frontière a toujours un coût explicite, jamais implicite.sync → async: block_on, at the root, blocks the thread until the Future finishes. async → sync: spawn_blocking (seen in №1), handing a blocking computation to a dedicated thread pool so the executor doesn't freeze. And tokio::spawn launches a Future as an independent task, returning a JoinHandle — exactly Vol 7's thread::spawn gesture, but on the runtime instead of the OS. Three tools, one idea: crossing a boundary always has an explicit cost, never implicit.

Sync et async sont deux régimes d'exécution, pas deux syntaxes : l'un court jusqu'au bout, l'autre peut se suspendre. Le runtime est leur unique pont.Sync and async are two execution regimes, not two syntaxes: one runs to the end, the other can suspend. The runtime is their only bridge.
№ 04Le piègeThe trap4 min

La donnée tenue à travers un .await doit être Send — le Vol 7 ressurgit.Data held across a .await must be Send — Vol 7 resurfaces.

Voici la contrainte que la couleur fait surgir, et qui pince tout le monde une fois. Sur un runtime multi-thread, une tâche peut changer de thread à chaque point de suspension : elle est pollée sur le thread A, rend Pending au .await, et reprend plus tard sur le thread B. Conséquence directe : tout ce que la tâche tient à travers un .await traverse potentiellement une frontière de threads — et doit donc être Send, au sens exact du Vol 7. C'est pourquoi tokio::spawn exige que le Future entier soit Send. Le bug classique : tenir, à travers un .await, un MutexGuard de std::sync::Mutex, un Rc, ou un emprunt de RefCell — autant de types !Send. Le compilateur refuse alors le spawn avec un message qui parle de Send au beau milieu de ton code async. La concurrence du Vol 7 n'avait pas disparu ; elle attendait sous l'async.Here's the constraint color brings out, the one that pinches everyone once. On a multi-threaded runtime, a task can change thread at each suspension point: it's polled on thread A, returns Pending at .await, and resumes later on thread B. Direct consequence: everything the task holds across a .await potentially crosses a thread boundary — and so must be Send, in Vol 7's exact sense. That's why tokio::spawn requires the whole Future to be Send. The classic bug: holding, across a .await, a MutexGuard from std::sync::Mutex, an Rc, or a RefCell borrow — all !Send types. The compiler then refuses the spawn with a message about Send in the middle of your async code. Vol 7's concurrency hadn't vanished; it was waiting beneath async.

Le .await peut changer de thread — ce qu'on tient à travers doit traverserThe .await can change thread — what you hold across must cross
thread A
poll → la tâche tournepoll → the task runs
.await ▸suspension : l'état est sauvegardésuspension: state is saved
thread B
reprend la MÊME tâcheresumes the SAME task
String, Arc, i32…
Send → traverseSend → crosses
MutexGuard, Rc, Ref/RefMut
!Send → le Future est rejeté!Send → the Future is rejected
Le contre-exemple — un MutexGuard std tenu à travers le .awaitThe counter-example — a std MutexGuard held across the .await
use std::sync::Mutex;

async fn incrementer(etat: Arc<Mutex<i32>>) {
    let mut n = etat.lock().unwrap();    // garde un MutexGuard (std) — il n'est PAS Send
    *n += 1;
    sauvegarder().await;                 // ✕ on tient le guard À TRAVERS le .await
}                                        //   → tout le Future devient !Send

// tokio::spawn(incrementer(etat))  →  error: future cannot be sent between threads safely
//   within `incrementer`, the trait `Send` is not implemented for `MutexGuard<'_, i32>`
// et même sans spawn : tenir un verrou à travers une suspension est une invite au deadlock.
Le bon réflexe — lâcher le verrou avant de suspendreThe right reflex — drop the lock before suspending
async fn incrementer(etat: Arc<Mutex<i32>>) {
    {
        let mut n = etat.lock().unwrap();   // ✓ verrou pris dans un bloc ÉTROIT
        *n += 1;
    }                                       // ← le guard est LÂCHÉ ici, AVANT le .await
    sauvegarder().await;                    // plus rien de !Send tenu à travers : le Future est Send
}
// règle : ne tiens jamais un MutexGuard (ni un Rc, ni un emprunt de RefCell) à travers un .await.
// si tu DOIS garder le verrou pendant l'attente → tokio::sync::Mutex, dont le guard EST Send.

La correction tient en un bloc : prendre le verrou, faire la mutation, relâcher le guard avant le .await. Le Future ne tient alors plus rien de !Send à travers la suspension, et redevient Send. Le bénéfice est double, et c'est ce qui rend la règle si saine : non seulement on satisfait Send, mais on évite de tenir un verrou pendant une attente — un anti-pattern qui sérialise et invite au deadlock, exactement comme au №2 du Vol 7. Si l'attente doit vraiment se faire verrou en main, c'est le signe qu'il faut un Mutex asynchrone (tokio::sync::Mutex), dont le guard est conçu pour traverser un .await.The fix fits in one block: take the lock, do the mutation, release the guard before the .await. The Future then holds nothing !Send across the suspension, and becomes Send again. The benefit is twofold, and that's what makes the rule so healthy: not only do you satisfy Send, you avoid holding a lock during a wait — an anti-pattern that serializes and invites deadlock, exactly as in Vol 7 №2. If the wait truly must happen lock in hand, that's the sign you need an asynchronous Mutex (tokio::sync::Mutex), whose guard is designed to cross a .await.

Pourquoi le message dérouteWhy the message confuses

L'erreur ne pointe pas la ligne du .await ni celle du lock() : elle dit que le Future entier n'est pas Send, et nomme le coupable (MutexGuard) enfoui dans l'état de la machine. Pour qui n'a pas le modèle « la tâche peut migrer de thread », c'est cryptique. Avec le modèle, ça coule de source : l'état sauvegardé entre deux .await doit pouvoir voyager, donc chacun de ses champs doit être Send. Lis l'erreur comme « quelque chose de non-Send survit à une suspension » — et cherche le .await de trop.The error points neither to the .await line nor the lock() one: it says the whole Future isn't Send, and names the culprit (MutexGuard) buried in the machine's state. For someone without the 'the task can migrate threads' model, it's cryptic. With the model, it's obvious: the state saved between two .awaits must be able to travel, so each of its fields must be Send. Read the error as 'something non-Send survives a suspension' — and hunt the .await too many.

La liste des suspects !SendThe list of !Send suspects

Les mêmes qu'au Vol 7, et pour les mêmes raisons. Rc (compteur non atomique) → remplace par Arc. Un emprunt de RefCell (Ref, RefMut) ou un &RefCell / &Cell — le RefCell lui-même est Send, mais pas Sync, donc une référence vers lui ne traverse pas → termine l'emprunt avant le .await, ou repense le partage. MutexGuard de std → lâche-le avant le .await, ou passe au Mutex async. Les pointeurs bruts. Le réflexe : à chaque .await, demande-toi ce qui est encore vivant à cet instant dans la fonction — c'est précisément ce qui devra traverser, et donc être Send.The same as Vol 7, and for the same reasons. Rc (non-atomic counter) → replace with Arc. A RefCell borrow (Ref, RefMut) or a &RefCell / &Cell — the RefCell itself is Send, but not Sync, so a reference to it can't cross → end the borrow before the .await, or rethink the sharing. std's MutexGuard → drop it before the .await, or switch to the async Mutex. Raw pointers. The reflex: at each .await, ask what's still alive at that instant in the function — that's precisely what will have to cross, and so be Send.

Un .await peut changer de thread : ce que tu tiens à travers doit être Send. Lâche tes verrous avant de suspendre.A .await can change thread: what you hold across must be Send. Drop your locks before suspending.
№ 05Bilan & éditoWrap-up & editorial2 min

La couleur a un prix : elle remonte la pile, et rouvre Send en plein async.Color has a price: it climbs the stack, and reopens Send mid-async.

Ce numéro a payé la facture annoncée au №1. L'async colore : .await n'étant permis qu'en contexte async, la couleur remonte d'appelé en appelant jusqu'au main, et les deux mondes ne se composent pas librement — du code synchrone ne peut pas .await, et le seul pont propre est block_on, à la racine. Cette frontière n'est pas un caprice : c'est la limite entre un code qui court jusqu'au bout et un code qui peut se suspendre, piloté par un exécuteur que la std ne fournit pas. Et le piège a fait ressurgir le Vol 7 : sur un runtime multi-thread, une tâche migre de thread à chaque .await, donc ce qu'elle tient à travers une suspension doit être Send. Tenir un MutexGuard, un Rc ou un emprunt de RefCell à travers un .await casse tout — la discipline est de lâcher ses verrous avant de suspendre.This issue paid the bill announced in №1. Async colors: since .await is only allowed in an async context, the color climbs from callee to caller up to main, and the two worlds don't compose freely — synchronous code can't .await, and the only clean bridge is block_on, at the root. This boundary isn't a whim: it's the limit between code that runs to the end and code that can suspend, driven by an executor the std doesn't provide. And the trap brought back Vol 7: on a multi-threaded runtime, a task migrates thread at each .await, so what it holds across a suspension must be Send. Holding a MutexGuard, an Rc, or a RefCell borrow across a .await breaks everything — the discipline is to drop your locks before suspending.

La frictionThe frictionCe qu'elle imposeWhat it imposes
La couleurThe colorasync remonte : qui .await de l'async devient async, jusqu'au main.async climbs: whoever .awaits async becomes async, up to main.
sync → asyncsync → asyncblock_on, à la racine seulement : il bloque le thread courant.block_on, at the root only: it blocks the current thread.
async → bloquantasync → blockingspawn_blocking : le bloquant part sur un pool dédié (Vol 8 №1).spawn_blocking: the blocker goes to a dedicated pool (Vol 8 №1).
à travers un .awaitacross a .awaittout ce qu'on tient doit être Send : la tâche peut changer de thread.everything held must be Send: the task may change thread.
MutexGuardà lâcher avant le .await ; sinon tokio::sync::Mutex (guard Send).drop before the .await; otherwise tokio::sync::Mutex (Send guard).
L'async ne s'ajoute pas à un coin du programme : il colore la pile et réveille le Vol 7. Sache où passe la frontière avant de la franchir.Async isn't added to a corner of the program: it colors the stack and wakes Vol 7. Know where the boundary runs before crossing it.
Mot de l'éditeurFrom the editor

Chaque .await est aussi un endroit où la tâche peut s'arrêter net.Every .await is also a place where the task can stop dead.

On a vu le .await sous deux angles : un point de suspension où l'exécuteur reprend la main (№1), et un point de migration où la donnée tenue doit être Send (ce numéro). Il en reste un troisième, le plus subtil, et celui qui clôt le volume. Un .await n'est pas seulement un endroit où la tâche peut attendre ou changer de thread : c'est un endroit où elle peut être interrompue. Si l'on cesse de poller un Future — parce qu'on l'a abandonné, qu'un timeout a expiré, qu'une autre branche a gagné une course — alors la tâche s'arrête à son dernier .await, et tout ce qu'elle n'avait pas fini de faire ne sera jamais fait.We've seen .await from two angles: a suspension point where the executor takes back control (№1), and a migration point where held data must be Send (this issue). A third remains, the subtlest, and the one that closes the volume. A .await isn't only a place where the task can wait or change thread: it's a place where it can be interrupted. If you stop polling a Future — because you dropped it, a timeout expired, another branch won a race — then the task stops at its last .await, and everything it hadn't finished doing will never be done.

Vol 8 №3 : annuler, sélectionner, structurer. On verra select!, qui fait courir plusieurs Futures et garde le premier prêt — en abandonnant les autres en plein vol ; join!, qui les attend tous ; et surtout la cancellation safety : la propriété, ou son absence, qui dit si une tâche peut être interrompue à un .await sans laisser un état à moitié écrit. Drop devient une opération de concurrence : laisser tomber un Future, c'est annuler la tâche. C'est le dernier visage du .await — et le dernier réflexe du volume avant qu'on referme l'async.Vol 8 №3: cancel, select, structure. We'll see select!, which races several Futures and keeps the first ready — dropping the others mid-flight; join!, which awaits them all; and above all cancellation safety: the property, or its absence, that says whether a task can be interrupted at a .await without leaving a half-written state. Drop becomes a concurrency operation: letting a Future fall is cancelling the task. It's the last face of .await — and the volume's last reflex before we close async.