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

Spec couche 1 — air-memory (allocateurs spécialisés + comptabilité mémoire)

Spécification technique — Version 1.0. Couche 1 « Primitives système ».

Position et méthode

air-memory fournit des allocateurs spécialisés — arène (bump), pool d’objets, slab — et une comptabilité mémoire par composant (au service du Principe 9 : tenir les budgets mémoire sur matériel modeste, Pi 4). Elle s’appuie sur air-base-lib (AirError/AirResult). API Rust d’abord (ABI C différée). Méthode doc-d’abord.

La crate couche 1 la plus « unsafe interne ». Un allocateur manipule de la mémoire brute (pointeur de bump, MaybeUninit, alignement, réutilisation de slots). Conséquences non négociables : aucune fonction unsafe exposée (frontière couche 1), // SAFETY: sur chaque bloc unsafe, Miri obligatoire (détection d’UB sur les internes), property-based sur les séquences alloc/reset, couverture 100 % (Principe 1).

air-memory v1 ne consomme PAS la couche 0. Backing heap par défaut (allocateur global Rust) → pure-Rust, aucun syscall. Le backing MmapRegion (couche 0) est enfichable mais différé (cf. §5). C’est donc une crate couche 1 qui ne touche pas encore au journal de dette doc couche 0.

Conventions

  • Mono-thread par défaut (décision) : les allocateurs allouent via &mut self (exclusivité garantie par le borrow checker, zéro coût de synchro). Le partage entre threads est à la charge de l’appelant (Mutex, ou une instance par thread). Des variantes Sync (lock / free-list atomique) sont différées à un besoin réel (cf. §7).
  • Comptabilité opt-in : un allocateur peut être rattaché à un AirMemoryTracker ; sans tracker, zéro surcoût.
  • Nommage ADR-029, arithmétique défensive (Principe 2 : checked_* sur tout calcul de taille/offset/alignement), aucune dépendance externe en v1.

Section 1 — Comptabilité mémoire par composant

#![allow(unused)]
fn main() {
/// Compteur de mémoire d'un **composant** (nommé). Handle **partageable** et
/// interrogeable depuis un autre thread (compteurs atomiques) — même si les
/// allocateurs qui l'alimentent sont mono-thread.
#[derive(Clone)]
pub struct AirMemoryTracker { /* Arc<{ name, in_use: AtomicUsize, peak, live }> */ }

impl AirMemoryTracker {
    pub fn new(component: &str) -> Self;
    pub fn name(&self) -> &str;
    /// Instantané atomique de la consommation courante.
    pub fn usage(&self) -> AirMemoryUsage;
    // Internes (`pub(crate)`), appelés par les allocateurs :
    // fn record_alloc(&self, bytes: usize);  // in_use += ; peak = max ; live += 1
    // fn record_free(&self, bytes: usize);    // in_use -= ; live -= 1
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AirMemoryUsage {
    pub bytes_in_use: usize,
    pub bytes_peak: usize,
    pub live_allocations: usize,
}
}

Décision. La comptabilité est per-composant par construction : elle mesure exactement « combien tient ce composant via les allocateurs Air » (la question budget du Principe 9), pas le heap total du process. Coût : un +atomic par alloc/free seulement si un tracker est attaché. Un allocateur sans tracker ne paie rien. (Un éventuel allocateur global traçant exhaustif relève d’un outil distinct, hors v1 — cf. §7.)

Tests. record_alloc/free mettent à jour in_use/peak/live correctement (y compris peak monotone) ; usage() cohérent ; interrogeable depuis un autre thread pendant qu’un allocateur mono-thread l’alimente (property-based concurrent lecture seule côté tracker).


Section 2 — Arène (bump allocator) : AirArena

#![allow(unused)]
fn main() {
/// Arène à **pointeur de bump** : allocation O(1) par avancement d'un offset ;
/// **libération en masse** via `reset` (rembobine l'offset). Mono-thread.
pub struct AirArena { /* backing + offset + tracker optionnel */ }

impl AirArena {
    /// Arène **heap** d'une capacité donnée.
    /// # Errors `OutOfMemory` si l'allocation backing échoue.
    pub fn with_capacity(capacity: usize) -> AirResult<Self>;
    /// Arène sur un backing **enfichable** (cf. §5).
    pub fn with_backing(backing: AirBacking) -> AirResult<Self>;
    /// Rattache un tracker (builder).
    pub fn tracked(self, tracker: &AirMemoryTracker) -> Self;

    /// Alloue une valeur dans l'arène ; rend `&mut T` (durée de vie liée à `&mut self`).
    /// # Errors `OutOfMemory` si capacité dépassée.
    pub fn alloc<T>(&mut self, value: T) -> AirResult<&mut T>;
    /// Alloue et copie une tranche `Copy`.
    pub fn alloc_slice_copy<T: Copy>(&mut self, src: &[T]) -> AirResult<&mut [T]>;
    /// Réserve une tranche **non initialisée** (l'appelant l'initialise).
    pub fn alloc_uninit_slice<T>(&mut self, len: usize) -> AirResult<&mut [MaybeUninit<T>]>;

    /// **Libère tout** d'un coup (rembobine le bump à 0). Le `&mut self` garantit
    /// qu'aucune référence allouée ne survit (borrow checker).
    pub fn reset(&mut self);
    pub fn bytes_used(&self) -> usize;
    pub fn capacity(&self) -> usize;
}
}

Contrat des destructeurs (à graver, précondition documentée). AirArena applique une sémantique de libération en masse : reset/Drop de l’arène n’exécute PAS le Drop des valeurs allouées (rembobinage d’offset, pas de destruction individuelle). Conséquence : n’allouer dans une arène que des types dont sauter le Drop est acceptableCopy/POD, ou données de travail sans ressource possédée. Pour des types qui doivent être détruits (possèdent un FD, un buffer heap…), utiliser AirObjectPool (qui respecte Drop) ou les gérer à la main. Ce contrat est documenté en tête de chaque méthode d’allocation (pas de surprise silencieuse).

Sûreté. Alignement calculé avec align_offset/checked_* (jamais d’arithmétique de pointeur nue non vérifiée) ; // SAFETY: sur l’écriture dans la mémoire réservée et la fabrication des références (la mémoire est dans les bornes, alignée, exclusive via &mut self). Miri sur alloc/reset, alignements hétérogènes, types de tailles variées.

Performance. alloc = quelques instructions (comparer offset, avancer). Cas canonique : mémoire de travail par tâche/par frame, libérée d’un coup.


Section 3 — Pool d’objets : AirObjectPool<T>

#![allow(unused)]
fn main() {
/// Pool recyclant des instances de `T` (évite alloc/free répétés d'un type
/// fréquent). **Respecte `Drop`** : les `T` retenus sont détruits au `Drop` du
/// pool ; au retour d'un objet, il est conservé (et réinitialisé si fourni).
/// Handle **mono-thread, clonable** (partage interne `Rc` + `RefCell`).
#[derive(Clone)]
pub struct AirObjectPool<T> { /* Rc<RefCell<{ free: Vec<T>, factory, reset, tracker }>> */ }

impl<T> AirObjectPool<T> {
    /// Fabrique appelée quand le pool est vide.
    pub fn new(factory: impl Fn() -> T + 'static) -> Self;
    pub fn with_capacity(capacity: usize, factory: impl Fn() -> T + 'static) -> Self;
    /// Fonction de réinitialisation appliquée au retour d'un objet (builder).
    pub fn with_reset(self, reset: impl Fn(&mut T) + 'static) -> Self;
    pub fn tracked(self, tracker: &AirMemoryTracker) -> Self;

    /// Emprunte un objet (recyclé, sinon fabriqué). Le guard le **rend au pool**
    /// à son `Drop`. `&self` (interior mutability) → **plusieurs objets** peuvent
    /// être sortis simultanément.
    pub fn acquire(&self) -> AirPooled<T>;
    pub fn available(&self) -> usize;
}

/// Garde RAII : `Deref`/`DerefMut` vers `T` ; rend l'objet au pool au `Drop`.
pub struct AirPooled<T> { /* Rc<inner> + Option<T> */ }
impl<T> core::ops::Deref for AirPooled<T> { type Target = T; /* … */ }
impl<T> core::ops::DerefMut for AirPooled<T> { /* … */ }
}

Décision. Le pool utilise Rc<RefCell<…>> (mono-thread, pas Arc/Mutex — zéro coût atomique) ; acquire(&self) permet N objets sortis en même temps (contrairement à un &mut self qui n’en autoriserait qu’un). C’est le design utilisable d’un pool. Le guard ne tient qu’un Rc (pas un emprunt du pool).

Sûreté / tests. acquire quand le pool est vide fabrique ; quand non vide recycle (vérifier l’identité d’objet recyclé) ; reset appliqué au retour ; le Drop du pool détruit bien tous les objets retenus (Miri : pas de fuite, pas de double-drop) ; emprunts RefCell jamais en conflit (un seul emprunt à la fois en interne, court). Property-based : séquences acquire/release arbitraires → invariant available + sortis = total fabriqué.


Section 4 — Slab : AirSlab<T>

#![allow(unused)]
fn main() {
/// Stockage à **slots**, indices **stables** et **anti-ABA** (clé = index +
/// génération). Même schéma éprouvé que le slab S1 d'io_uring (Temps 1). Mono-thread.
pub struct AirSlab<T> { /* Vec<Entry<T>> + free-list d'index + générations */ }

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AirSlabKey { /* index: u32, generation: u32 (opaque) */ }

impl<T> AirSlab<T> {
    pub fn new() -> Self;
    pub fn with_capacity(capacity: usize) -> Self;
    /// Insère ; rend une clé stable (réutilisation de slot avec génération++).
    pub fn insert(&mut self, value: T) -> AirSlabKey;
    /// Retire ; `None` si la clé est périmée (slot réutilisé → génération ≠).
    pub fn remove(&mut self, key: AirSlabKey) -> Option<T>;
    pub fn get(&self, key: AirSlabKey) -> Option<&T>;
    pub fn get_mut(&mut self, key: AirSlabKey) -> Option<&mut T>;
    pub fn len(&self) -> usize;
    pub fn is_empty(&self) -> bool;
}
}

Décision. La génération par slot empêche le bug ABA (une clé d’un slot libéré puis réutilisé est rejetée — remove/get rendent None). Drop du slab détruit les valeurs vivantes restantes (respecte Drop). Débordement de génération géré par wrapping_add documenté (probabilité de collision négligeable, ou saturation selon décision d’implémentation — à fixer, cf. slab io_uring).

Tests. insert/get/remove ; clé périmée rejetée (test ABA : insert→remove→ insert sur le même slot→ ancienne clé None) ; Drop détruit les vivants (Miri) ; property-based (suite d’insert/remove → cohérence len / clés valides).


Section 5 — Backing enfichable : AirBacking

#![allow(unused)]
fn main() {
/// Source de mémoire brute d'une `AirArena`. **Enfichable** : heap par défaut ;
/// d'autres backings s'ajoutent **sans casser l'API** (type opaque + constructeurs).
pub struct AirBacking { /* enum interne #[non_exhaustive] : Heap(Box<[u8]>) | … */ }

impl AirBacking {
    /// Backing **heap** (bloc `Box<[u8]>` de l'allocateur global).
    /// # Errors `OutOfMemory`.
    pub fn heap(capacity: usize) -> AirResult<Self>;
    pub fn len(&self) -> usize;
    // pub fn mmap(region: MmapRegion) -> Self;   // DIFFÉRÉ (cf. §7)
}
}

Décision. v1 = heap uniquement, mais l’API est enfichable dès maintenant (AirBacking opaque, variantes internes #[non_exhaustive]) : ajouter un backing MmapRegion (couche 0 — aligné page, munmap déterministe, io_uring-enregistrable au Temps 3a) plus tard ne cassera pas l’API d’AirArena.


Récapitulatif air-memory

DomaineAPI principale
ComptabilitéAirMemoryTracker, AirMemoryUsage (per-composant, opt-in, atomique)
ArèneAirArena (bump, libération en masse ; ne run pas Drop)
Pool d’objetsAirObjectPool<T>, AirPooled<T> (recycle, respecte Drop, Rc/RefCell)
SlabAirSlab<T>, AirSlabKey (slots stables, anti-ABA, respecte Drop)
BackingAirBacking (heap ; mmap différé, API non-cassante)

Différé : backing MmapRegion, variantes Sync (thread-safe), allocateur global traçant exhaustif, surface ABI C.

Dépendances (règle des 80 %, ADR-024)

  • air-base-lib (erreurs). Aucune dépendance externe en v1 (allocateur global Rust + core/alloc). Pas de MmapRegion tant que le backing mmap est différé.

Stratégie de tests

  • Couverture 100 % lignes + branches (Principe 1).
  • Miri (impératif, c’est du code mémoire bas niveau) : AirArena (alloc/ alignement/reset), AirObjectPool (pas de fuite/double-drop), AirSlab (slots/ générations/Drop). Aucun UB.
  • Property-based (proptest) : séquences alloc/reset (arène) ; acquire/release (pool, invariant de conservation) ; insert/remove (slab, clés valides, ABA).
  • Unitaires : arithmétique de taille/alignement aux bords (checked_*, capacité dépassée → OutOfMemory), peak monotone, clé périmée rejetée.
  • Doctests : exemples compilent et passent.
  • (Pas de fuzzing dédié en v1 : pas de décodage de données externes — les entrées sont des tailles/alignements, couverts par proptest. À ajouter si un backing ingère des données externes.)

Décisions de fond

  1. Comptabilité per-composant (atomique, opt-in) — répond à la question budget du Principe 9 ; pas un allocateur global (qui mesurerait le total mais attribuerait mal). Zéro coût sans tracker.
  2. Mono-thread par défaut (&mut self, zéro synchro) ; Sync différé (Principe 5 : ne pas payer l’atomique pour le cas courant des arenas).
  3. Arène = libération en masse, Drop non exécuté — contrat documenté (pas de surprise) ; pour les types à détruire → AirObjectPool (qui respecte Drop).
  4. Pool Rc/RefCell — mono-thread mais N objets sortables (interior mutability), pas Arc/Mutex.
  5. Slab anti-ABA (clé = index + génération) — schéma éprouvé du slab io_uring.
  6. Backing enfichable — heap en v1, MmapRegion ajoutable sans casser l’API.

Travail à reprendre

  • Backing MmapRegion (AirBacking::mmap) — grandes arenas alignées page, munmap déterministe, buffers io_uring-enregistrables (Temps 3a). Première consommation couche 0 d’air-memory.
  • Variantes Sync des allocateurs (lock / free-list atomique loom-vérifiée) si un besoin de pool partagé apparaît.
  • Surface ABI C d’air-memory.
  • Autres crates couche 1 : air-process, air-socket, air-crypto, air-device, air-thread ; et air-base-lib (ABI C).

Licence du document : MPL 2.0 Statut : Spécification technique d’air-memory (couche 1). API Rust ; ABI C différée. Backing heap (mmap enfichable différé) ; mono-thread (Sync différé).