Le GC et le compilateur Rust life Décidé
Le rêve — faire garantir la durée de vie des blobs par le compilateur — se heurte à trois murs infranchissables. Mais sa version réalisable est une pépite : encoder la machine à états du GC dans les types, pour que les bugs classiques du GC deviennent des programmes impossibles à écrire. Adopté comme principe d'implémentation.
'a) par laquelle le compilateur vérifie qu'une référence en mémoire ne survit pas à ce qu'elle pointe. Borrow checker : le vérificateur qui fait respecter ces deux règles — à la compilation, avant que le programme n'existe. RAII / Drop : quand une valeur sort de portée, son code de nettoyage (Drop) s'exécute automatiquement — la porte qui se verrouille toute seule en se fermant. Typestate : l'astuce centrale de cette page — encoder l'état d'un objet dans son type, si bien que les opérations invalides pour cet état n'existent tout simplement pas.
Le rêve d’origine
L’idée de départ : puisque Rust garantit à la compilation qu’aucune référence mémoire ne survit à son propriétaire, pourrait-il garantir qu’aucune référence à un chunk ne survit — que le GC ne supprime jamais un blob encore référencé, prouvé par le compilateur lui-même ? Si c’était possible, ce serait la fin des bugs de GC par construction. Voyons honnêtement pourquoi ça ne l’est pas — puis ce qu’on sauve du rêve.
Les trois murs infranchissables
Rc<T> (reference counted — un propriétaire multiple à comptage) existe précisément parce que le compilateur ne peut pas savoir statiquement quand le dernier détenteur lâche — alors il compte au runtime. Notre GC est littéralement un Rc distribué et persistant : la partie que même Rust délègue à l'exécution.
La pépite — le typestate : l’état encodé dans le type
Concrètement, la machine à états du GC — épinglé → non-référencé → condamné → quarantaine → détruit — s’encode ainsi :
// Chaque état est un TYPE distinct. Pas de champ `status: u8` qu'on
// pourrait oublier de tester — le type EST l'état.
pub struct Pinned<'s> { addr: Addr, _session: &'s SessionGuard }
pub struct Unreferenced { addr: Addr, proof: SweepProof }
pub struct Condemned { addr: Addr, epoch: Epoch }
pub struct Quarantined { addr: Addr, since: Epoch }
pub struct Reclaimed { addr: Addr } // trace comptable, les octets sont partis
// Les « preuves-objets » (types-témoins) : on ne peut pas les fabriquer
// soi-même — seule la bonne phase du GC sait les produire.
pub struct SweepProof { /* privé : construit uniquement par le sweep */ }
pub struct GraceElapsed { /* privé : construit uniquement après la grâce */ }
// Les SEULES transitions qui existent — chacune CONSOMME l'état précédent :
pub fn condemn(c: Unreferenced, horizon: &EpochHorizon) -> Condemned;
pub fn quarantine(c: Condemned) -> Quarantined; // pas de marche arrière
pub fn reclaim(q: Quarantined, g: GraceElapsed) -> Reclaimed; // L'UNIQUE porte de sortie
// Ce qui NE COMPILE PAS — pas « échoue en test » : n'existe pas :
// reclaim(condemned, g) → erreur de type : jamais passé en quarantaine
// condemn(pinned, h) → un Pinned<'s> n'est pas un Unreferenced
// Unreferenced { addr, ?? } → impossible : SweepProof est inconstructible ici
// delete_bytes(addr) → cette fonction n'existe nulle part dans le code
Trois détails font la solidité du montage :
- Les transitions consomment.
quarantine(c)prendCondemnedpar valeur — après l'appel, l'ancienne valeur n'existe plus (la lettre est postée). Impossible de condamner un chunk et de garder l'ancienne poignée pour le re-traiter : le double-traitement ne compile pas. - Les preuves sont des objets.
SweepProofetGraceElapsedsont des types-témoins : leurs constructeurs sont privés, seule la phase légitime du GC peut les créer. Exiger unGraceElapseden paramètre, c'est exiger — à la compilation — que le code appelant soit passé par l'attente de grâce. La signature de la fonction devient le règlement. - L'absence de porte dérobée. Il n'existe dans tout le codebase qu'une fonction qui détruit des octets, et elle exige le formulaire violet. Un développeur pressé, un futur contributeur, un refactoring de 3h du matin : personne ne peut écrire le raccourci.
Le ticket de consigne devient un objet qu’on tient
Le pin de session — « présent, et je te le garde » — a sa traduction RAII côté code :
pub struct PinGuard<'s> { addr: Addr, session: &'s Session }
impl Drop for PinGuard<'_> {
fn drop(&mut self) { self.session.release(self.addr); } // rendu automatique
}
// Un handle d'écriture sur le chunk EMPRUNTE le guard :
pub fn reference_in_manifest<'p>(pin: &'p PinGuard) -> ManifestRef<'p>;
// → la ManifestRef ne peut pas survivre au PinGuard : le borrow checker
// interdit d'utiliser le chunk après avoir rendu le ticket.
Le Drop garantit que le ticket est toujours rendu (même sur un panic, même sur un retour anticipé — la porte se verrouille en se fermant), et la lifetime 'p garantit qu’aucune référence au chunk ne s’échappe au-delà du ticket. C’est le contrat du vestiaire, vérifié par le notaire.
Le scellement par consommation
Même principe pour le cycle de vie des segments : fn seal(s: OpenSegment) -> SealedSegment consomme le segment ouvert. Vouloir écrire dans un segment scellé ? La méthode append n’existe pas sur le type SealedSegment — pas de test à écrire, pas de flag à vérifier. Double-scellement ? La valeur OpenSegment a été consommée au premier appel, le compilateur refuse le second.
Le branding — là où les lifetimes servent vraiment
L’astuce avancée (popularisée par GhostCell) qui recycle ton intuition d’origine, un étage plus bas :
pub struct Session<'brand> { /* la lifetime 'brand est une MARQUE unique */ }
pub struct PinnedAddr<'brand> { addr: Addr /* frappée de la même marque */ }
La lifetime 'brand est invariante : le compilateur refuse de considérer deux marques comme interchangeables. Conséquence : un PinnedAddr de la session A ne typecheck pas avec la session B, et ne peut pas s’échapper de la portée lexicale de sa session.
Les limites honnêtes — et la doctrine des trois lignes
Ce que le typestate ne couvre pas, il faut le dire : la vérité du graphe (quels chunks sont référencés) reste une affaire de runtime — c’est le mark-and-sweep ; un crash entre deux transitions laisse un état persisté que le prochain processus doit relire (les types ne survivent pas au reboot — c’est le champ époque de l’index, et la relecture est vérifiée à la frontière) ; et les processus de versions différentes coexistent — d’où le versionnage des formats. La réponse n’est pas de renoncer, c’est d’empiler :
| Ligne de défense | Garantit | Mécanisme |
|---|---|---|
| Le compilateur | les transitions illégales sont inécrivables | typestate, consommation, preuves-objets, branding |
| Le runtime | la vérité du graphe de références | mark-and-sweep, époques, horizon |
| La conception | même un bug survivant se répare | GC auto-réparant, quarantaine, scrubbing |
Au passage, le scrubbing (de l’anglais « récurer ») : la tâche de fond qui relit périodiquement les segments et rehache chaque entrée pour vérifier que le disque n’a pas silencieusement altéré des octets — le « bit rot », la pourriture de bit : un secteur qui se dégrade physiquement sans que personne n’ait rien demandé. Nos adresses étant des hashes, la vérification est mécanique : relire, rehacher, comparer — et réparer depuis les shards de parité si ça diverge. C’est le sujet dédié de la section C.
Le compilateur garde les procédures, le runtime garde les données, l’auto-réparation garde contre nous-mêmes.
L’argument concurrentiel — personne d’autre ne peut l’écrire
Ce niveau de garantie est structurellement lié au langage : borg (Python — pas de typage statique du tout) et restic (Go — pas de généricité de consommation ni de lifetimes) ne peuvent pas exprimer ces contraintes dans leur code. « Le seul code autorisé à supprimer vos données est vérifié par le compilateur le plus strict de l’industrie » — c’est un argument technique et commercial, et il est exclusif au choix Rust fait en première page de ce document.