Mutabilité interne

Nous l’avons vu, Rust encourage fortement une politique de gestion mémoire où à chaque instant, une donnée est soit partagée, soit modifiable, mais jamais les deux en même temps.

Nous avons aussi déjà rencontré plusieurs exemples de situations où cette politique est trop limitante, et nous devons à la place adopter une politique “à la C” où il est possible de modifier certaines valeurs via une référence partagée. On parle de mutabilité interne.

Dans ce chapitre, nous allons voir comment utiliser nous-même ce mécanisme de mutabilité interne dans notre code lorsque nous en avons besoin.

Initialisation paresseuse : OnceCell et OnceLock

Un cas simple de mutabilité interne que nous avons déjà rencontré, c’est l’initialisation paresseuse : une valeur doit être calculée à l’exécution, mais nous voulons qu’elle ne soit calculée que lors du première accès. Par la suite, on veut que la donnée précalculée soit stockée, et que l’accesseur retourne immédiatement une référence partagée vers la donnée précalculée. Cette référence partagée fonctionnera ensuite selon les règles usuelles (pas de mutation via &T).

En Rust, cette fonctionnalité a été implémentée initialement par des bibliothèques, d’abord lazy_static puis once_cell. Ensuite, au vu de la fréquence d’utilisation de la bibliothèque once_cell et de son niveau de maturité, elle a récemment été jugée digne d’être intégrée au sein de la bibliothèque standard. Le processus est encore en cours, mais les types OnceCell et OnceLock sont déjà disponibles dans les versions récentes du langage Rust.

Ces deux types partagent une relation similaire à Rc et Arc : le type OnceCell est destiné à être utilisé pour des références partagées au sein d’un même thread et exploite cette limitation pour implémenter les choses de façon plus efficace. Alors que le type OnceLock est destiné à être utilisé pour des références partagées entre plusieurs threads, et paye le prix requis pour en arriver là.

La principale API de mutabilité interne exposée par ces types est la méthode get_or_init(), qui prend une fonction de construction en paramètre. Si la cellule n’a pas encore été initialisée, la fonction de construction est appelée est son résultat est utilisé pour l’initialisation. Sinon, une référence partagée vers le résultat précalculé est retournée :

#![allow(unused)]
fn main() {
use std::cell::OnceCell;

fn initialisation() -> u32 {
    println!("Initialisation en cours...");
    /* ... un calcul très compliqué ... */
  42
}

fn paresseux(x: &OnceCell<u32>) -> &u32 {
    x.get_or_init(initialisation)
}

let x = OnceCell::new();
println!("{}", paresseux(&x));
println!("{}", paresseux(&x));
}

Les types OnceCell et OnceLock sont soigneusement optimisés pour que l’accès à une donnée initialisée soit presque aussi efficace que l’accès à une variable normale. Mais il faut quand même vérifier que l’initialisation a été effectuée, ce qui a un coût léger. Utilisez donc quand même l’initialisation ordinaire chaque fois que c’est possible.

Déplacement en séquentiel : Cell

Un cran de complexité cognitive au-dessus, on a Cell, un type qui permet de stocker et extraire des valeurs via une référence partagée :

#![allow(unused)]
fn main() {
use std::cell::Cell;

// Notez l'absence de mut
let x = Cell::new(123usize);

// Lecture et écriture normale
println!("{}", x.get());
x.set(456);
println!("{}", x.get());

// Lecture + écriture combinée
let old = x.replace(789);
println!("{old}");
}

Ce type n’est généralement utilisé qu’avec des types Copy, car sinon on perd la méthode get(), et tout faire avec replace() est pénible. Mais Cell a l’avantage d’avoir une implémentation triviale, qui ne fait que désactiver les optimisations de compilateur liées à la supposition que les variables partagées ne seront pas modifiées.

Cell ne peut pas être utilisée en multi-thread, car ce n’est pas une bonne idée de modifier des variables observables par d’autres threads sans synchronisation…

Emprunt dynamique en séquentiel : RefCell

Encore un cran de complexité au-dessus, lorsqu’on est dans une situation où la sémantique des références Rust convient, mais le compilateur ne parvient pas à faire la preuve que le programme la respecte, on peut transposer la vérification de la compilation à l’exécution avec le type RefCell :

#![allow(unused)]
fn main() {
use std::cell::RefCell;

// Déclaration
let x = RefCell::new(24u8);

// Emprunt dynamique en lecture
{
    let x = x.borrow();
    println!("Avant : {x}");
}

// Emprunt dynamique en écriture
{
    let mut x = x.borrow_mut();
    *x = 42;
}

// Autre emprunt dynamique en lecture
{
    let x = x.borrow();
    println!("Après : {x}");
}
}

En interne, le type RefCell fonctionne en maintenant un compteur de références partagées avec une valeur sentinelle pour l’emprunt mutable.

A chaque fois que les opérations borrow() et borrow_mut() sont appelés, l’implémentation de RefCell vérifie que l’emprunt est correct (sinon le code panique), puis retourne un objet qui se comporte comme une référence du bon type, mais avec un destructeur qui remodifie le compteur de références dans l’autre sens. D’où l’utilisation des scopes ci-dessus pour s’assurer que ce destructeur soit appelé au bon moment.

Dans l’ensemble, l’utilisation de RefCell rend le code plus difficile à comprendre et augmente le risque de panique imprévue. De plus, la gestion du compteur de références n’est pas neutre du point de vue des performances.

Je vous encourage donc fortement à n’utiliser RefCell que quand une API mal conçue ne vous laisse pas le choix, et à privilégier les emprunts vérifiés à la compilation chaque fois que c’est possible, quitte à triturer un peu le code pour contourner quelques limites connues de l’analyse statique si le code résultant est moins désagréable à lire qu’un code utilisant RefCell.

RefCell ne prend aucune précaution particulière pour synchroniser l’accès aux données, et n’est donc pas utilisable en multi-thread.

Synchronisation matérielle : std::sync::atomic

Nous avons vu comment on partage des données mutables entre différentes parties du code d’un seul thread, maintenant voyons comment on partage des données mutables entre plusieurs threads.

Tous les CPUs courants offrent des garanties minimales de synchronisation entre threads via la cohérence de cache, qui assure qu’à chaque instant tous les coeurs CPU sont d’accord sur le contenu de la mémoire. Cependant, le CPU et le compilateur pris ensemble ne garantissent pas…

  • Que votre programme va faire les accès mémoire que vous avez dit et aucun autre.
  • Que les accès mémoire qui seront exécutés seront effectués dans l’ordre au niveau CPU.
  • Que le CPU ne va pas effectuer d’autres réordonnancements des lectures et des écritures derrière, voire des spéculations sur la valeur des lectures.
  • Qu’entre une lecture de valeur par un CPU et l’écriture de la version modifiée qui suit, un autre coeur CPU n’aura pas modifié la valeur d’une façon qui invalide la modification.

…et bien sûr, Rust a aussi vocation à être portable vers d’autres matériels comme les GPUs où les caches ne sont pas cohérents et pour partager une information avec les autres coeurs il faut le demander explicitement avec des instructions coûteuses.

Pour toutes ces raisons, les accès mémoires non synchronisés sont un comportement indéfini en Rust comme en C++, et on ne peut donc pas en faire sans code unsafe en Rust.

Les opérations de synchronisation qui sont efficaces au niveau matériel sont exposées en Rust via le module std::sync::atomic, qui est fortement inspiré de l’en-tête <atomic> de C++11 mais avec quelques changements importants :

  • Si une opération n’est pas disponible au niveau matériel, elle n’est pas émulée avec des Mutex comme en C++, elle n’existe tout simplement pas en Rust. Si on veut écrire du code portable entre matériels, on doit vérifier ce qui est présent avant utilisation, et décider de sa politique de fallback quand l’opération atomique voulue n’est pas présente.
  • Il n’y a pas d’ordre SeqCst par défaut, ni d’opérations atomiques implicites cachées derrière des opérateurs. Comme le code qui utilise ces opérations est difficile à écrire, Rust le rend très explicite, ce qui facilite sa relecture par des experts.

Cela prendrait pas mal de temps d’expliquer comment on utilise bien ces opérations, et je considère que ça dépasse le cadre de ce cours introductif, donc je vous renvoie vers l’excellent cours Rust Atomics and Locks de Mara Bos. Son cours dépasse par ailleurs largement la question des opérations atomiques et offre une très bonne introduction aux fondamentaux de l’écriture de structures de données concurrentes en Rust.

En voici un exemple d’utilisation simpliste (rendez-vous au chapitre sur les threads pour plus d’explications sur le fonctionnement de std::thread::scope) :

#![allow(unused)]
fn main() {
use std::{sync::atomic::{AtomicBool, Ordering}, time::Duration};

// Variable de synchronisation
let ready = AtomicBool::new(false);
std::thread::scope(|s| {
    // Thread secondaire qui attend que "ready" passe à true
    s.spawn(|| {
        println!("[Secondaire] Attente active de ready == true...");
        while !ready.load(Ordering::Relaxed) {}
        println!("[Secondaire] Signal reçu !");
    });

    // Thread principal qui met "ready" à true après une petite temporisation
    println!("[Principal] Une petite pause...");
    std::thread::sleep(Duration::from_millis(30));
    println!("[Principal] Envoi du signal");
    ready.store(true, Ordering::Relaxed);
});
}

Transfert de valeurs : std::sync::mpsc

Avec les opérations de std::sync::atomic, on a des primitives de partage de données au plus proche du matériel. Mais souvent, on a quelque envie de quelque chose de plus haut niveau et agréable à utiliser au quotidien.

Pour partager des données par valeur, Rust fournit la file d’attente mpsc, qui permet à un ou plusieurs threads émetteurs d’envoyer des messages à un thread destinataire avec une réception dans l’ordre où les messages ont été émis (FIFO).

Inspirée par les fameuses channels du langage Go, cette file d’attente fournit par ailleurs plusieurs autres fonctionnalités dont l’expérience montre qu’on en a souvent besoin quand on utilise des files pour synchroniser des threads :

  • On peut borner le nombre de messages en attente et bloquer l’émetteur quand la limite de capacité est atteinte, pour éviter que si l’émetteur est plus rapide que le destinataire, la taille du stock de messages augmente indéfiniment.
  • Toutes les opérations qui sont bloquantes par défaut disposent d’une variante qui échoue instantanément et d’une variante qui échoue au bout d’un certain temps d’attente.
  • Si tous les threads émetteurs se sont arrêtés (ce qu’on détecte via l’implémentation Drop de la partie émettrice de la file), le thread destinataire est informé, et vice versa.

Si vous avez besoin de gérer manuellement des threads (nous verrons que pour des tâches calculatoires, ce n’est généralement pas nécessaire), je vous recommande fortement d’essayer ce paradigme de communication, il rend le code plus lisible et facile à maintenir que les alternatives que nous allons aborder dans la suite de ce chapitre.

Vous trouverez un exemple d’utilisation de mpsc dans le chapitre sur les threads.

Verrouillage exclusif : Mutex

Parfois, il n’est pas acceptable de transférer des données entre threads par valeur :

  • La création d’un message peut représenter un coût trop important pour le thread émetteur, par exemple en raison des allocations mémoire requises.
  • La synchronisation souhaitée peut bien s’exprimer sous forme de petites modifications sur une grosse structure de données commune.

Pour ce genre de cas, on retrouve au sein de la bibliothèque standard l’éternel Mutex, qui permet à plusieurs threads de partager des données en s’attendant mutuellement : tant qu’un thread a accès aux données, les autres doivent attendre qu’ils aient fini pour obtenir l’accès à leur tour.

Cependant, rappelons qu’il y a plein de problèmes connus avec cet outil :

  • Il faut que l’attente soit rare, sinon on perd tout le bénéfice d’avoir plusieurs threads (et on tournera même plus lentement qu’un code séquentiel car synchroniser des threads n’est pas gratuit). Donc il faut que les transactions soient assez grosses pour amortir le coût de synchronisation, mais pas trop pour ne pas se retrouver dans une situation d’attente.
  • On peut trop facilement se retrouver dans une situation où deux threads s’attendent mutuellement, voire où un thread s’attend lui-même, ce qui bloque le programme (deadlock).
  • Il faut gérer le cas où un thread crashe alors qu’il était en train de modifier les données, laissant celles-ci dans un état incohérent.

Contrairement à la plupart des implémentations de mutex, le Mutex de Rust gère la troisième erreur, via un mécanisme de poisoning qui signale l’erreur au moment de l’acquisition du Mutex. Pour le reste, c’est à vous de gérer.

L’utilisation de Mutex est similaire à celle de RefCell, la gestion du poisoning en plus et la gestion séparée des lectures/écritures en moins :

#![allow(unused)]
fn main() {
use std::{sync::Mutex, time::Duration};

// Variable de synchronisation
let mutex = Mutex::new([1, 2, 3, 4]);
std::thread::scope(|s| {
    // Thread secondaire qui lit deux fois avec une petite pause au milieu
    s.spawn(|| {
        println!("[Secondaire] Première lecture...");
        let valeur = *mutex.lock()
                           .expect("Empoisonné par le thread principal !");
        println!("[Secondaire] La valeur initiale est {valeur:?}");

        println!("[Secondaire] Une petite pause...");
        std::thread::sleep(Duration::from_millis(40));

        println!("[Secondaire] Relecture...");
        let valeur = *mutex.lock()
                           .expect("Empoisonné par le thread principal !");
        println!("[Secondaire] La nouvelle valeur est {valeur:?}");
    });

    // Thread principal qui fait une pause, puis rate une mise à jour
    println!("[Principal] Une petite pause...");
    std::thread::sleep(Duration::from_millis(20));
    println!("[Principal] Mise à jour en cours...");
    {
        let mut guard = mutex.lock()
                             .expect("Empoisonné par le thread secondaire !");
        for idx in 3..=4 {  // Oups, je me suis cru en Julia/Fortran !
            guard[idx] = 42;
        }
    }
    println!("[Principal] Mise à jour terminée");
});
}

Notifications d’événements : Condvar

Dans les exemples précédents, à chaque fois que deux threads devaient s’attendre mutuellement, nous avons utilisé soit de l’attente active soit des temporisations. Il va de soit qu’aucune de ces méthodes n’est acceptable en production, en-dehors de cas très particuliers.

A la place, on utilise souvent Mutex en association avec Condvar. Le fonctionnement de Condvar étant identique à celui de std::condition_variable en C++, lui-même très proche des condition variables de pthread je ne le détaillerai pas plus que ça. Voici un exemple d’utilisation :

#![allow(unused)]
fn main() {
use std::sync::{Mutex, Condvar};

let mutex = Mutex::new(0usize);
let condvar = Condvar::new();
std::thread::scope(|s| {
    // Thread secondaire qui attend une nouvelle valeur du thread principal
    s.spawn(|| {
        println!("[Secondaire] Attente du signal du thread principal...");
        let mut guard = mutex.lock().expect("Empoisonné par le thread principal !");
        guard = condvar.wait_while(guard, |valeur| *valeur == 0)
                       .expect("Empoisonné par le thread principal !");
        println!("[Secondaire] La valeur est maintenant {}", *guard);
    });

    // Thread principal qui modifie la valeur puis notifie le thread secondaire
    println!("[Principal] Ecriture de la valeur signal...");
    {
        let mut guard = mutex.lock()
                             .expect("Empoisonné par le thread secondaire !");
        *guard = 42;
    }
    println!("[Principal] Notification du thread secondaire");
    condvar.notify_one();
});
}

Si vous vous demandez pourquoi le thread secondaire doit acquérir un mutex avant d’attendre la Condvar, ou plus généralement pourquoi l’API d’une Condvar est aussi compliquée, je vous invite à vous documenter sur les problèmes de spurious wakeup et lost wakeup que cette conception d’API vise à éviter. Dans l’ensemble, les Condvar sont aussi piégeuses que les Mutex, et l’utilisation de mpsc devrait leur être préférée chaque fois que c’est possible/approprié.

Verrouillage partagé : RwLock

Il arrive souvent qu’une donnée soit beaucoup accédée en lecture, et rarement modifiée. Pour ce genre de cas, Rust fournit le type RwLock, qui fonctionne presque comme un Mutex, mais avec des APIs read() et write() séparées pour que les threads puissent indiquer si ils veulent un accès en écriture ou un accès en lecture seule.

Au prix d’un coût par transaction de synchronisation un peu plus élevé, cela permet à plusieurs threads d’avoir accès aux données en lecture seule simulatanément.

On peut donc le voir comme une généralisation multi-thread de RefCell, où l’erreur est récompensée par une deadlock plutôt qu’une panique, et avec les problèmes de Mutex en plus.

Mais on peut aussi le voir comme une forme de Mutex plus efficace dans le cas où on a beaucoup de lectures, peu d’écritures, et des transactions suffisamment grosses. Question de point de vue !

Autres mécanismes de mutabilité interne

Il y a d’autres primitives de synchronisation dans la bibliothèque standard Rust, qu’on ne pense pas en termes de partage de données mais qui en impliquent : barrières, Once

Si vous avez envie de structures de données concurrentes plus spécialisées, il existe beaucoup de bibliothèques tierces très intéressantes, mentionnons parmi d’autres…

  • crossbeam, une boîte à outils assez générale.
  • parking_lot, plus spécialisée sur la programmation par Mutex & assimilé.
  • Et quelques primitives non bloquantes écrites par l’auteur de ce cours : triple-buffer pour le partage de données et rt-history pour la gestion d’historique en flux continu.

Mentionnons pour conclure que toute forme de mutabilité interne en Rust est basée sur la primitive bas niveau UnsafeCell, qui désactive les optimisations supposant l’absence de mutation via une référence partagée, et permet un accès au contenu par le biais de pointeurs bruts.

Comme précédemment, si vous envisagez d’utiliser cette primitive directement, je vous recommande de commencer par réutiliser les bibliothèques déjà écrites par des experts si possible, et si il faut faire les choses vous-même, aller lire le Rustnomicon.