Historique : La gestion de la mémoire de Rust a évolué d'une interface d'allocateur global unique (GlobalAlloc, stabilisée dans Rust 1.28) vers un système plus flexible et conscient des types (Allocator, actuellement instable mais disponible dans std::alloc). GlobalAlloc sert de pont de bas niveau vers les primitives de mémoire du système d'exploitation (par exemple, malloc, VirtualAlloc), opérant exclusivement sur des pointeurs bruts et des tailles en octets sans informations de type.
Le problème survient car GlobalAlloc expose la manipulation de mémoire brute que le compilateur ne peut pas vérifier. Les implémenteurs doivent appliquer manuellement des invariants critiques : garanties d'alignement, appariement allocation/désallocation, et interdiction des double libérations. Puisque GlobalAlloc sous-tend Box, Vec, et Rc, toute violation propage un comportement indéfini à travers tout le programme, nécessitant le marqueur unsafe impl pour signaler que le programmeur assume la responsabilité de ces contrats de sécurité.
La solution implique un respect strict du contrat Layout. La méthode alloc doit retourner un pointeur satisfaisant Layout::align(), et dealloc ne doit être appelé qu'avec la même disposition utilisée pour l'allocation. Furthermore, l'allocateur doit s'assurer que la mémoire n'est pas récupérée tant qu'elle est encore référencée par des abstractions sûres. Le trait Allocator atténue ces risques en fournissant une interface générique et sécurisée qui gère les calculs de Layout en interne, déléguant les opérations non sécurisées aux implémentations sous-jacentes de GlobalAlloc.
use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; struct CountingAllocator { bytes_allocated: AtomicUsize, } unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let ptr = System.alloc(layout); if !ptr.is_null() { self.bytes_allocated.fetch_add(layout.size(), Ordering::SeqCst); } ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { System.dealloc(ptr, layout); self.bytes_allocated.fetch_sub(layout.size(), Ordering::SeqCst); } } #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator { bytes_allocated: AtomicUsize::new(0), };
Une équipe développant un moteur de trading haute fréquence a observé que l'allocateur de la bibliothèque standard introduisait un bruit de latence inacceptable en raison de la contention des verrous dans le tas global. Ils avaient besoin d'un allocateur personnalisé préalloué à partir d'une huge page pour garantir un accès mémoire déterministe et local à NUMA pour les mises à jour de carnet de commandes sur la voie chaude.
Plusieurs solutions ont été évaluées. La première approche a envisagé d'enrober l'allocateur système avec un pool protégé par mutex, mais cela n'a fait que déplacer la contention et violer les exigences de latence. La deuxième approche consistait à utiliser l'API Allocator instable avec Rust de nuit, créant une arène typée pour des structures d'ordre spécifiques ; cependant, cela nécessitait une refonte approfondie des usages de Vec et Box à travers le code et faisait face à des problèmes de stabilité pour le déploiement en production.
La troisième solution, finalement sélectionnée, a implémenté GlobalAlloc pour intercepter toutes les allocations dynamiques au sein du fil de trading, les acheminant à travers un allocateur en proie locale soutenu par des régions mmap. Cette implémentation nécessitait unsafe impl car l'allocateur en proie gérait des pointeurs bruts et devait garantir que les pointeurs retournés maintenaient l'alignement pour des limites de ligne de cache jusqu'à 64 octets. L'équipe a choisi cette voie car elle offrait une intervention à l'échelle du système sans modifier les types de collections existants, bien qu'elle exigeait des tests rigoureux avec Miri pour valider que le Layout passé à dealloc correspondait toujours à l'allocation originale. Le résultat a été une réduction de 40 % de la latence p99, bien que l'équipe ait maintenu un protocole d'audit strict pour les blocs de code unsafe afin de prévenir les fuites de mémoire pendant une volatilité exceptionnelle du marché.
Pourquoi le Layout passé à dealloc doit-il exactement correspondre à celui donné à alloc, et que se passe-t-il si la taille diffère mais l'alignement est correct ?
Le contrat GlobalAlloc exige une identité bit à bit entre le Layout utilisé pour l'allocation et la désallocation car de nombreux allocateurs (comme jemalloc ou dlmalloc) intègrent des métadonnées dans le bloc alloué ou maintiennent des listes de tailles classées séparément. Passer une taille différente, même plus petite, amène l'allocateur à chercher dans la mauvaise bin ou à calculer un offset incorrect pour la fusion, ce qui conduit à une corruption du tas ou à des vulnérabilités de double libération. Cela diffère de free de C, qui exige généralement seulement le pointeur, rendant l'exigence de Rust plus stricte mais nécessaire pour l'agnosticisme des allocateurs.
Comment GlobalAlloc interagit-il avec Box::new lorsque la boîte est ensuite supprimée, et pourquoi est-il problématique d'implémenter Drop pour l'allocateur lui-même ?
Lorsque Box::new est invoqué, il appelle GlobalAlloc::alloc via le statique #[global_allocator]. Lors de la suppression de la Box, le compilateur insère un appel à GlobalAlloc::dealloc avec le Layout de type calculé automatiquement. Les candidats manquent souvent de noter que l'implémentation GlobalAlloc elle-même doit être 'static et thread-safe (implémentant Sync), mais elle ne doit pas conserver d'état qui fait référence à la mémoire allouée qu'elle gère, car cela crée une dépendance circulaire où la suppression de l'allocateur nécessiterait d'y accéder, ce qui pourrait causer un usage après libération lors du démontage du programme.
Qu'est-ce qui distingue les exigences de sécurité de GlobalAlloc::alloc_zeroed de alloc, et pourquoi l'implémentation ne peut-elle pas simplement appeler alloc suivie de std::ptr::write_bytes ?
Bien que alloc_zeroed puisse théoriquement être implémenté comme alloc plus le zéro, la bibliothèque standard le fournit comme une méthode distincte pour permettre aux allocateurs de tirer parti des optimisations de pages prézeroisées spécifiques au système d'exploitation (par exemple, MAP_ANONYMOUS sur Linux retourne des pages prézeroisées). D'un point de vue de sécurité, alloc_zeroed doit garantir que la mémoire retournée contient des octets nuls, ce qui est une condition de post-condition plus forte que alloc (qui retourne de la mémoire non initialisée). Si une implémentation prétend faussement à l'initialisation à zéro mais retourne des ordures, du code sûr supposant une initialisation à zéro (critique pour les contextes sensibles à la sécurité) lirait des données non initialisées, violant les garanties de sécurité de Rust.