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-thread (threading et synchronisation)

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

Renvoi ADR-055 (no_std + spawn gardé per-cible). Depuis ADR-055 D3, air-thread est #![no_std] : la surface livrée pour la cible *-linux-air (AirMutex/AirRwLock/AirSemaphore/AirChannel sur futex(2), runtime_primitives) est no_std pure. Le spawn ergonomique AirThreadBuilder/ AirThreadHandle (thread.rs, enrobant std::thread) est gardé #[cfg(not(target_env = "air"))] : disponible sur l’hôte (gnu, sound sous glibc), non compilé pour la cible — le spawn Air-natif (closure sur TCB Air) relève d’air-runtime + allocateur réel (chantier distinct, ADR-052 D7.3). Les mentions « s’appuie sur std » ci-dessous valent donc pour l’hôte.

Position et méthode

air-thread fournit les primitives de threading (AirThread), de synchronisation (AirMutex/AirRwLock/AirSemaphore), les channels MPSC (AirChannel) et un ré-export d’atomics. S’appuie sur air-base-lib (AirError). API Rust d’abord (ABI C différée). Méthode doc-d’abord.

Pas d’async runtime ici — l’asynchrone (event loop, tâches) est le runtime Air de la couche 2 (ADR-023 : runtime maison sur io_uring, pas Tokio). air-thread = primitives synchrones de threads système et de synchronisation bloquante.

air-thread s’appuie sur std — c’est légitime. La règle « pas de libc / syscalls directs » vaut pour la couche 0. Les couches ≥ 1 utilisent std normalement. Réimplémenter les threads sur clone3 (TLS, stacks, machinerie pthread) serait déraisonnable et risqué : AirThread enrobe std::thread ; les locks enrobent std::sync. Conséquence : très peu d’unsafe interne dans cette crate (contraste avec air-memory).

Décision : backend std::sync pour la synchro

Depuis Rust 1.62, std::sync::Mutex/RwLock sur Linux sont des locks basés sur le futex kernel (chemin rapide en quelques atomiques, syscall futex en contention) — essentiellement ce qu’on écrirait à la main. On les enrobe : Air possède le type (pour l’ABI C et un nommage uniforme) ; std fournit le backend prouvé. Zéro dépendance externe, zéro manque couche 0. Écarté : parking_lot (dépendance, à contre-courant de la philo minimiser-les-deps qui a écarté Tokio).

std est un expédient de v1 — le cap est une implémentation futex maison (à graver). Adopter std::sync permet de livrer maintenant sans risque, mais ce n’est pas l’état cible. Avec std, on hérite des contraintes de la bibliothèque standard de Rust — portabilité multi-plateforme, sémantique de poisoning, choix d’équité, garanties de panic-safety — qui ne sont pas forcément les nôtres. Notre propre implémentation, sur un futex synchrone couche 0 (à produire), nous fait maîtriser exactement le comportement (équité, file d’attente, sémantique sur panique, budgets) — cohérent avec la raison d’être du projet (contrôle vertical du stack, build-own). Ce remplacement est planifié (cf. travail à reprendre), pas un simple plan B de performance. La frontière d’API air-thread est conçue pour que ce basculement std → futex-maison soit interne (l’API publique ne change pas).

(Réconciliation passe-2 : la macro-archi disait « au-dessus de parking_lot ».)

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

  • air-base-lib (erreurs) + std. Aucune dépendance externe en v1 (ni parking_lot, ni crossbeam).

Section 1 — Threads : AirThread

#![allow(unused)]
fn main() {
/// Constructeur de thread (enrobe `std::thread::Builder`).
pub struct AirThreadBuilder { /* name, stack_size, affinity */ }

impl AirThreadBuilder {
    pub fn new() -> Self;
    pub fn name(self, name: &str) -> Self;          // posé via PR_SET_NAME (std / couche 0)
    pub fn stack_size(self, bytes: usize) -> Self;
    /// Affinité CPU souhaitée (cf. **manque couche 0** ci-dessous).
    pub fn cpu_affinity(self, cpus: &[u32]) -> Self;
    /// Lance le thread. Le corps retourne `T` ; `AirThreadHandle::join` le récupère.
    /// # Errors `AirError` si la création échoue (ressources).
    pub fn spawn<F, T>(self, body: F) -> AirResult<AirThreadHandle<T>>
        where F: FnOnce() -> T + Send + 'static, T: Send + 'static;
}

/// Handle joignable d'un thread.
pub struct AirThreadHandle<T> { /* std::thread::JoinHandle<T> */ }
impl<T> AirThreadHandle<T> {
    /// Attend la fin et récupère la valeur.
    /// # Errors `AirError` (catégorie `Other`) si le thread a **paniqué** (la
    /// panique est capturée à la frontière, pas propagée silencieusement).
    pub fn join(self) -> AirResult<T>;
    pub fn is_finished(&self) -> bool;
}

/// Nom du thread courant (le cas échéant).
pub fn current_thread_name() -> Option<String>;
}

⚠️ Manque couche 0 : sched_setaffinity/sched_getaffinity (absents). cpu_affinity exige sched_setaffinitynon wrappé en couche 0. L’API est spécifiée ici, mais son implémentation est différée à une petite extension couche 0 sched (coordonnée), comme fs::inotify/epoll/privsep. Sans elle, cpu_affinity renvoie Unsupported (ou la méthode est #[cfg]-gated). name et stack_size fonctionnent dès v1 (via std/set_thread_name présent).


Section 2 — Synchronisation

#![allow(unused)]
fn main() {
/// Mutex Air (sur `std::sync::Mutex`), **sans poisoning** : une panique en section
/// critique **n'empoisonne pas** le mutex — `lock` rend toujours un guard, jamais
/// une erreur de poison. (La panique est récupérée à la frontière du thread, via
/// `join`.) Trade-off documenté : si une panique laisse la donnée incohérente,
/// les verrouilleurs suivants la voient telle quelle — à l'appelant de rétablir
/// l'invariant explicitement s'il y tient.
pub struct AirMutex<T> { /* std::sync::Mutex<T> */ }
impl<T> AirMutex<T> {
    pub fn new(value: T) -> Self;
    pub fn lock(&self) -> AirMutexGuard<'_, T>;     // pas de Result (no poisoning)
    pub fn try_lock(&self) -> Option<AirMutexGuard<'_, T>>;
    pub fn get_mut(&mut self) -> &mut T;            // accès exclusif sans lock
    pub fn into_inner(self) -> T;
}
pub struct AirMutexGuard<'a, T> { /* Deref / DerefMut */ }

/// RwLock Air (sur `std::sync::RwLock`), **sans poisoning**.
pub struct AirRwLock<T> { /* std::sync::RwLock<T> */ }
impl<T> AirRwLock<T> {
    pub fn new(value: T) -> Self;
    pub fn read(&self) -> AirRwLockReadGuard<'_, T>;     // N lecteurs simultanés
    pub fn write(&self) -> AirRwLockWriteGuard<'_, T>;   // 1 écrivain exclusif
    pub fn try_read(&self) -> Option<AirRwLockReadGuard<'_, T>>;
    pub fn try_write(&self) -> Option<AirRwLockWriteGuard<'_, T>>;
}

/// Sémaphore comptant (sur `Mutex<usize>` + `Condvar` — `std` n'en fournit pas).
pub struct AirSemaphore { /* Mutex + Condvar */ }
impl AirSemaphore {
    pub fn new(permits: usize) -> Self;
    /// Acquiert un permis (bloque si 0). Le permis est rendu au `Drop` du guard.
    pub fn acquire(&self) -> AirSemaphorePermit<'_>;
    pub fn try_acquire(&self) -> Option<AirSemaphorePermit<'_>>;
    pub fn available_permits(&self) -> usize;
}
pub struct AirSemaphorePermit<'a> { /* rend le permis au Drop */ }
}

Décision (no poisoning). Le poisoning de std (un Mutex devient Err après un panic d’un détenteur) est plus souvent une nuisance qu’une aide (c’est la raison n°1 d’adopter parking_lot). air-thread l’absorbe (unwrap_or_else(|e| e.into_inner()) en interne) : l’API ne rend jamais d’erreur de poison. Cohérent avec la gestion de panique à la frontière du thread (join rapporte la panique).


Section 3 — Channels MPSC : AirChannel

#![allow(unused)]
fn main() {
/// Canal **multi-producteurs / un consommateur** (sur `std::sync::mpsc`).
pub fn air_channel<T: Send>() -> (AirSender<T>, AirReceiver<T>);

pub struct AirSender<T> { /* Clone — chaque clone est un producteur */ }
pub struct AirReceiver<T> { /* unique consommateur */ }

impl<T: Send> AirSender<T> {
    /// # Errors `AirError` (`BrokenResource`) si le récepteur a été abandonné.
    pub fn send(&self, value: T) -> AirResult<()>;
}
impl<T: Send> AirReceiver<T> {
    /// Bloque jusqu'à un message. # Errors `BrokenResource` si **tous** les
    /// senders sont abandonnés (canal vide et fermé).
    pub fn recv(&self) -> AirResult<T>;
    /// `Ok(None)` si vide, `Err(BrokenResource)` si fermé.
    pub fn try_recv(&self) -> AirResult<Option<T>>;
    /// `Ok(None)` si le délai expire avant un message.
    pub fn recv_timeout(&self, timeout: AirDuration) -> AirResult<Option<T>>;
}
}

Section 4 — Atomics

Ré-export des types atomiques de std sous air_thread::atomic (AtomicU32/AtomicUsize/AtomicBool/… + Ordering) — pour une surface Air uniforme (macro-architecture), sans réécriture (les atomiques de core sont la référence kernel/ISA). Un développeur qui connaît std les retrouve à l’identique.


Récapitulatif air-thread

DomaineAPIBackend
ThreadsAirThreadBuilder, AirThreadHandle, current_thread_namestd::thread (+ set_thread_name)
SynchroAirMutex, AirRwLock, AirSemaphore (+ guards)std::sync (no poisoning) ; sémaphore = Mutex+Condvar
Channelsair_channel, AirSender, AirReceiverstd::sync::mpsc
Atomicsré-export air_thread::atomiccore::sync::atomic

Différé : cpu_affinity (dépend de l’ajout couche 0 sched_setaffinity) ; variantes/parités async (couche 2) ; ABI C.

Stratégie de tests

  • Couverture 100 % lignes + branches (Principe 1).
  • Concurrence : AirMutex — incréments concurrents de N threads → total exact ; no-poisoning : un panic en section critique laisse le mutex utilisable ; AirRwLock — N lecteurs simultanés, écrivain exclusif ; AirSemaphore — N permis, acquire bloque à 0 puis débloqué par un release ; AirChannel — MPSC (plusieurs senders, un receiver), recv bloque, sender abandonné → recv erreur, recv_timeout.
  • loom sur la seule structure concurrente maison : AirSemaphore (Mutex+Condvar), stdloom commuté sous cfg(loom) (même schéma que le slab io_uring). Les wrappers Mutex/RwLock (sur std, déjà prouvés) n’en ont pas besoin.
  • Miri (avec préemption) sur les chemins concurrents courts ; threads/join.
  • Threads : valeur retournée par join ; panic → join rend une erreur (capturée, non propagée).
  • Doctests : exemples compilent et passent.
  • (Pas de fuzzing : aucune ingestion de données externes.)

Décisions de fond

  1. std::thread / std::sync comme backend de v1 — cap acté vers futex maison. Air possède les types (ABI C, nommage) ; std fournit le futex-based prouvé, zéro dep, zéro manque couche 0. Mais std porte les contraintes de la std de Rust (pas les nôtres) → remplacement planifié par une implémentation futex synchrone maison (contrôle total du comportement), basculement interne (API inchangée). Voir travail à reprendre.
  2. No poisoning — l’API ne rend jamais d’erreur de poison ; panique gérée à la frontière du thread (join). Trade-off documenté.
  3. Pas d’async ici (ADR-023 ; couche 2).
  4. AirThread enrobe std::thread (pas de réimplémentation sur clone3).
  5. Ré-export d’atomics plutôt que réécriture (autorité core).
  6. cpu_affinity implémenté (PR #97) — via la couche 0 set_cpu_affinity (PR #55) ; handshake par canaux (l’enfant publie son Tid, l’appelant pose l’affinité avant de débloquer le corps ; un masque refusé fait échouer spawn avant exécution du corps).

Travail à reprendre

  • Extension couche 0 sched : sched_setaffinity/sched_getaffinity (prérequis de cpu_affinity). 5ᵉ manque couche 0, non urgent.
  • Build-natif futex des locks (AirMutex/AirRwLock/AirSemaphore) — PLANIFIÉ, pas conditionnel. Raison de fond : maîtriser le comportement (équité, file, sémantique sur panique, budgets) plutôt que d’hériter des contraintes de la std de Rust ; la performance (Principe 5) n’est qu’un bonus. Prérequis : une primitive couche 0 futex synchrone (FUTEX_WAIT/WAKE bloquants — 6ᵉ manque couche 0 à produire ; distincte du futex io_uring async déjà livré). Locks à vérifier loom. Le basculement est interne (API air-thread inchangée).
  • ABI C d’air-thread ; autres crates couche 1 (air-socket, air-crypto, air-device).

Licence du document : MPL 2.0 Statut : Spécification technique d’air-thread (couche 1). API Rust ; ABI C différée. Backend std ; synchro sans poisoning ; pas d’async (couche 2).