Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Le lexique Rust en cinq mots — pour lire cette page sans prérequis Ownership : en Rust, chaque valeur a exactement un propriétaire ; passer la valeur à une fonction peut la consommer — comme poster une lettre : une fois dans la boîte, elle n'est plus dans votre main, et le compilateur refuse que vous la relisiez. Lifetime : l'annotation ('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

Mur 1 — Les lifetimes meurent à la compilation Une lifetime est une annotation de vérification : le borrow checker s'en sert pour prouver des propriétés sur les références en mémoire, puis elle est effacée — dans le binaire compilé, il n'en reste rien. Nos blobs vivent des années sur disque, à travers des milliers d'exécutions du programme : une lifetime ne peut pas enjamber deux lancements de processus — c'est constitutif, pas contournable.
Mur 2 — Le compilateur prouve des propriétés du code, pas des données « Quand le chunk X peut-il mourir ? » dépend du graphe de références au runtime — quels snapshots le référencent, ce que la rétention décide, ce que les utilisateurs font demain. C'est une propriété des données vivantes, inconnaissable à la compilation. La preuve par l'absurde est dans Rust lui-même : 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.
Mur 3 — Le store est multi-processus Frontaux, workers GC, trois datacenters, et des versions différentes de l'agent qui cohabitent (rollout progressif) : le borrow checker raisonne sur un programme à la fois. Personne ne « possède » un blob au sens ownership à travers un cluster.
En clair — le notaire, pas le gardien Le compilateur est un notaire : il certifie, avant l'ouverture de l'entrepôt, que toutes les procédures écrites sont légales et cohérentes entre elles. Puis il rentre chez lui — il n'est pas le gardien qui fait des rondes la nuit. Demander aux lifetimes de garantir la vie des blobs, c'est demander au notaire de surveiller les cartons. En revanche — et c'est toute la suite — un notaire qui refuse d'enregistrer une procédure illégale, c'est déjà énorme : aucune ronde ne rattrapera jamais une procédure que personne n'a pu écrire.

La pépite — le typestate : l’état encodé dans le type

En clair — les guichets à formulaires de couleur Imagine une préfecture stricte : chaque guichet rend un formulaire d'une couleur différente, et chaque guichet n'accepte que la couleur du guichet précédent. Le guichet « destruction » n'accepte que le formulaire violet « quarantaine purgée » — pas le bleu, pas le vert, et il n'existe aucun autre guichet de destruction dans le bâtiment. Tricher n'est pas « interdit et sanctionné » : c'est physiquement impossible, la fente du guichet n'a pas la forme de ton formulaire. Le typestate, c'est ça : l'état d'un objet est son type, et le compilateur est la fente du guichet.

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) prend Condemned par 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. SweepProof et GraceElapsed sont des types-témoins : leurs constructeurs sont privés, seule la phase légitime du GC peut les créer. Exiger un GraceElapsed en 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.
Pourquoi « ne compile pas » vaut mieux que « testé » Un test attrape le bug s'il pense à le chercher, sur les chemins qu'il exerce, dans les états qu'il a simulés. Une erreur de compilation interdit le bug sur tous les chemins, y compris ceux qu'on n'a pas imaginés — celui du correctif urgent de 3h du matin inclus. Pour la pièce du système dont les bugs détruisent des données (le GC est l'unique code autorisé à supprimer quoi que ce soit), c'est la différence entre une relecture attentive et une faute d'orthographe impossible dans la langue.

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.

En clair — le bracelet du festival Chaque session de backup distribue des bracelets à sa propre couleur, infalsifiables. Les vigiles (le compilateur) refusent le bracelet d'un autre festival, et le bracelet ne franchit pas la sortie de l'enceinte. Les lifetimes ne gardent donc pas les blobs — elles gardent les portées du code qui manipule les blobs : un handle épinglé ne fuit jamais hors de sa session, prouvé à la compilation. Le rêve d'origine retombe sur ses pieds — pas là où on l'attendait.

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éfenseGarantitMécanisme
Le compilateurles transitions illégales sont inécrivablestypestate, consommation, preuves-objets, branding
Le runtimela vérité du graphe de référencesmark-and-sweep, époques, horizon
La conceptionmême un bug survivant se répareGC 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.