RUST

Comment écrire idiomatiquement en RUST a magiquement corrigé mes bugs

16 min

Lorsqu’on utilise des langages compilés, le code ne peut pas être exécuté s’il ne passe pas l’étape du compilateur, et c’est pourquoi ce dernier se met parfois en travers de votre route. Parfois, il refuse ces changements vite faits-mal faits que vous faites pour tester une idée.
Et parfois, il refuse les changements tant que vous ne les avez pas corrigés, après avoir compris le problème.
Selon le compilateur et ses messages d’erreur, cette étape est plus ou moins directe. Les développeurs C++ travaillant avec des templates savent que cela peut devenir très délicat. Tous les développeurs Rust se souviennent des difficultés rencontrées face au borrow checker (dont les messages d’erreur sont, heureusement, plus utiles que ceux de g++).

Ce processus peut être irritant, mais les compilateurs faisant obstacle à du code incorrect sont assez utiles. Par exemple, le message local variable referenced before assignment (Variable locale référencée avant affectation) en Python peut être frustrant, surtout si vous essayez quelque chose de complexe et que cette exception se produit plusieurs secondes après le démarrage de votre test. Cette erreur serait détectée par un compilateur avant même que vous ne tentiez de lancer votre code.
Plus important encore, les compilateurs peuvent détecter des erreurs plus subtiles, à première vue indécelables, ou rendre votre code infaillible et à l’épreuve du temps en évitant que les fonctions et API que vous écrivez ne fassent l’objet d’une mauvaise utilisation (par vous, par vos collègues, ou par vos utilisateurs).

Je préfère donc voir le compilateur comme un mentor et un ami, plutôt que comme un logiciel-agaçant-qui-ne-comprend-jamais-rien-à-ce-que-je-veux. Néanmoins, pour vous aider autant qu’il le peut, il a souvent besoin que le développeur lui donne des indices. En Rust, écrire de manière idiomatique peut aider à atteindre ce but.
Dans cet article, je décris deux exemples dans lesquels écrire un Rust idiomatique a aidé le compilateur à me permettre de résoudre des problèmes. Avec deux simples exemples dans le code de la crate ferrisetw, je démontre comment le compilateur a pu détecter des erreurs de programmation délicates et éviter une mauvaise utilisation de l’API.

Rust : présentation d’ETW et de la crate ferrisetw

J’ai récemment dû travailler avec ETW (Event Tracing for Windows), un framework Microsoft pour fournir et s’abonner à des évènements de traçage (tracing events). Ces évènements définis par le fournisseur peuvent être de tous ordres, mais en général le framework est alimenté par des données de suivi de performance (performance-monitoring data).

Microsoft fournit une API en C pour interagir avec ETW ainsi que des API de plus haut niveau, comme Microsoft.Diagnostics.Tracing.TraceEvent library en .NET, krabsetw library en C++, et plus récemment la crate ferrisetw en Rust.

J’ai eu la chance d’utiliser la crate ferrisetw. Celui-ci se décrit comme une « copie » de KrabsETW écrite en Rust (« Basically a rip off KrabsETW written in Rust »). En tant que tels, ses structures et modules sont conçus comme leurs équivalents en C++. Cette simple crate fonctionne très bien. Grâce à lui, j’ai rapidement pu travailler sur ETW et l’inclure dans mes projets Rust. Je remercie d’ailleurs n4r1b pour ses travaux open source.
Néanmoins, après quelques semaines à étudier ETW et ferrisetw, j’ai constaté que cette crate présentait quelques problèmes : l’API peut être mal utilisée et il existe un problème de sécurité des threads (thread-safety). Dans une certaine mesure, ces problématiques sont liées au fait qu’il suit l’architecture C++. En modifiant cette architecture pour suivre les idiomes et lignes directrices de Rust, il a été assez facile de les corriger. Ces corrections sont décrites dans les deux chapitres qui suivent.

Comment rendre (plus) difficile un mauvais usage de l’API ? Exemple de Builder pattern

L’API de Microsoft propose plusieurs moyens (voire trop) d’interagir avec des traces. Après quelques semaines de travail avec ETW, je ne pouvais toujours pas expliquer clairement comment appeler toutes ses composantes dans le bon ordre… et il semble que je ne suis pas le seul.
Microsoft offre plusieurs capacités autour d’une trace : la démarrer, l’ouvrir (soit, « s’abonner » avec un callback à une trace commencée, bien que Microsoft n’ait pas clairement expliqué la différence avec son lancement), la traiter (ce que tous auraient appelé la « démarrer »), ajouter ou supprimer des fournisseurs/providers (Microsoft utilise le terme "enabling"), en changer les paramètres, comme les filtres, noms, tailles de mémoire tampon, etc. En théorie, il est possible de faire beaucoup de choses, mais certaines options ne sont pas bien documentées, voire peu utiles (est-il possible de modifier le nom d’une trace après l’avoir arrêtée ? Que se passe-t-il si on traite la même trace deux fois ? etc.).

Ferrisetw est une crate (bibliothèque) Rust qui enveloppe cette API en C dans un niveau supérieur (« orienté objet », comme nous ne les appelons pas en Rust). En son sein, il y a une structure struct UserTrace qui abstrait une session de trace ETW.

Dans sa version 0.1, voici comment ferrisetw définit struct UserTrace :

/// Actual documentation of this struct at <https://web.archive.org/web/20221230164234/https://n4r1b.com/doc/ferrisetw/trace/struct.usertrace>
struct UserTrace {
    name: String,
    ...
}

// The actual UserTrace is more complex, and some of these methods are part of traits, but you get the idea
impl UserTrace {
    pub fn set_trace_name(&mut self, name: &str) { ... }
    /// Subscribe to a Provider. Microsoft calls this "enable"
    pub fn enable(self, provider: Provider) -> Self { ... }
    pub fn start(self) -> Result<Self, TraceError> { ... }
    pub fn process(self) -> Result<Self, TraceError> { ... }
    ...
}

En général, on veut créer une instance UserTrace, définir son nom, s’abonner à ses Providers, puis lancer et traiter la trace. La documentation de ferrisetw présente des exemples de code appelant ces fonctions dans cet ordre « logique ».
Néanmoins, rien dans le code n’empêche l’utilisateur de commencer par lancer une trace, puis modifier son nom, puis s’abonner à un Provider.
L’API Microsoft pourrait (?) le permettre, mais il apparait que ferrisetw n’a été conçu que pour faire cela de manière « logique » : appeler set_trace_name ou enable après le lancement de la trace ne donnerait donc rien.
Rien dans la documentation de ferrisetw ne dit qu’il est obligatoire d’appliquer l’ordre « logique », les utilisateurs peuvent donc être déconcertés et se demander pourquoi leurs appels à set_trace_name ou enable sont sans effet.

J’ai amélioré cela dans des commits que j’ai publiés dans la version 1.0 de ferrisetw. J’aurais pu ajouter de la documentation, mais au lieu de cela, pourquoi ne pas demander au compilateur de garantir que nous appelons les fonctions dans le bon ordre ?
Pour ce faire, j’ai présenté un Rust Builder pattern plus idiomatique.

Ferrisetw 1.0 présente désormais une structure de Builder supplémentaire :

/// The actual Builder is generic over the trace kind, but for the sake of simplicity, we'll only keep the relevant parts for this article
#[derive(Default)]
struct UserTraceBuilder {
    name: String,
    providers_to_use: Vec<Provider>,
    ...
}

impl UserTraceBuilder {
    pub fn named(mut self, name: String) -> Self {
        self.name = name;
        self
    }

    pub fn enable(mut self, provider: Provider) -> Self {
        self.providers_to_use.push(provider);
        self
    }

    /// This calls both "start" and "process"
    pub fn start(self) -> Result<UserTrace, TraceError> {
        ...
    }
}

struct UserTrace {
    name: String,
    ...
}

impl UserTrace {
    /// This generates a Builder
    pub fn new() -> UserTraceBuilder {
        UserTraceBulder::default()
    }

    // This struct now has only read-only getters
    pub fn name(&self) -> &str {
        &self.name
    }
}

fn main() {
    // This Builder can be used this way
    let trace = UserTrace::new()
        .named("my awesome trace")
        .enable(provider1)
        .enable(provider2)
        .start()
        .unwrap();

    println!("Started {}", trace.name());
}

Ce pattern assure qu’il n’existe pas de moyen de modifier le nom de la trace (ou les providers auxquels elle est abonnée) après qu’elle a été lancée.

Il s’agit d’un bon exemple pour faire en sorte que le compilateur impose une bonne utilisation de l’API.
Ce modèle de Builder n’est pas spécifique à Rust, bien que le manque de constructeurs surchargés dans ce langage le rende très pertinent. En C++, le même résultat pourrait être obtenu en ayant des constructeurs qui définissent const les membres des données. C’est plus ou moins ce que krabsetw fait ici, au moins pour le nom du membre.

Ferrisetw : d’autres exemples pour rendre difficile une mauvaise utilisation d’une API

Ferrisetw 1.0 introduit d’autres changements rendant plus difficile une mauvaise utilisation de son API publique.

Par exemple, j’ai envoyé des commits qui introduisent ce modèle de Builder pour struct Provider. Voici ce à quoi ressemblait ferrisetw 0.1:

pub struct Provider {
    pub guid: Option<GUID>,
    ...
}

// Somewhere else, in a function that handles ETW events
pub fn on_event(...) {
    ...
    let provider = ...;
    let guid = provider.guid.unwrap(); // Must have been set already
    ...
}

Le nouveau modèle de Builder vérifie que le GUID est correctement défini avant de démarrer une trace. En conséquence, guid est passé d’une Option<GUID> à un simple GUID, ce qui supprime le besoin de ce terrifiant .unwrap() !

S’assurer que la visibilité des fonctions est correctement définie sur pub, ou non, est une autre façon de faire en sorte que le compilateur oblige les utilisateurs à se servir correctement de l’API. C’est également un point sur lequel j’ai essayé d’être prudent lors de la refactorisation de ferrisetw.
Ceci n’est pas non plus spécifique à Rust : C et C++ font aussi cette différence. Python le fait dans une certaine mesure, pour les noms de fonctions qui commencent par un ou deux underscores.

Comment rendre la crate (plus) thread-safe ?

Un autre problème auquel j’ai dû faire face en expérimentant ferrisetw était lié à la sécurité des threads. Nous parlons ici d’un programme en Rust. Vous pouvez donc vous demander pourquoi cette préoccupation, puisque le compilateur Rust offre des garanties de fearless concurrency.
ferrisetw est interfacé avec une API en C. Contrairement à Rust, C est un langage qui ne garde pas trace des durées de vie et de la propriété. Le compilateur Rust ne peut donc pas le faire pour les données transmises à et depuis C.
Ainsi, comme pour chaque FFI (Foreign Function Interface), nous devons utiliser le terrifiant mot-clé Rust unsafe. Cela crée des parties dans le code où nous pouvons appeler des fonctions non sûres. Ce sont des portions de code pour lesquelles le compilateur manque de connaissances pour assurer ses garanties habituelles de gestion de la mémoire. Le développeur doit donc vérifier lui-même (correctement) la sécurité de la mémoire. Dans ferrisetw 0.1, il y avait une légère erreur que je décrirai dans ce chapitre.

Pour recevoir les évènements ETW, l’API Microsoft fournit une fonction (OpenTraceW) pour enregistrer un callback. Ce callback est invoqué par Windows à chaque fois qu’un nouvel évènement est disponible et prend un seul argument : un EVENT_RECORD* etw_event, qui contient un pointeur void* UserContext vers des données arbitraires passées lors de l’enregistrement au callback.
Il est possible d’enregistrer une fonction Rust pour ce callback :

// In this example code, safety comments are ignored for clarity. Actual code is more complex
use windows::Win32::System::Diagnostics::Etw::OpenTraceW;
use windows::Win32::System::Diagnostics::Etw::EVENT_TRACE_LOGFILEW;

fn register_callback(my_context: *mut std::ffi::c_void) {
    let log_file = EVENT_TRACE_LOGFILEW {
            LoggerName: ...,
            Anonymous2: EVENT_TRACE_LOGFILEW_1 {
                EventRecordCallback: Some(trace_callback_thunk)
            },
            Context: my_context,
            ..Default::default()
        };

    unsafe{ OpenTraceW(&mut log_file as *mut _); }
}

// the `extern "system"` keywords make it possible for this function to be called from C
unsafe extern "system" fn trace_callback_thunk(p_record: *mut Etw::EVENT_RECORD) {
    println!("Got event {:?}", *p_record);
    println!("It points to a user context at address {:x?}", (*p_record).UserContext);
}

Bien sûr, avoir un simple void* (ou comme Rust l’appelle, un std::ffi::c_void) comme contexte utilisateur n’est pas très utile.
Nous voudrions le faire pointer vers une structure Rust, comme :

struct CallbackData {
    /// How many events have been processed so far
    events_handled: usize,
    /// Other things, such as a list of Rust closures we want to execute on each incoming event
    ...
}

Et, dans trace_callback_thunk, le ferrisetw 0.1 original le récupère comme suit :

// Actually, CallbackData was called TraceData in ferrisetw 0.1. I changed it in this snippet for more consistency with the rest of the article
unsafe extern "system" fn trace_callback_thunk(event_record: *mut Etw::EVENT_RECORD) {
    let ctx: &mut CallbackData = unsafe_get_callback_ctx((*event_record).UserContext);
    println!("Got event {:?}", *p_record);
    println!("It points to this user context: {:?}", ctx);
    ctx.events_handled += 1;
}

pub unsafe fn unsafe_get_callback_ctx<'a>(ctx: *mut std::ffi::c_void) -> &'a mut CallbackData {
    &mut *(ctx as *mut CallbackData)
}

Cela pose toutefois d’importants problèmes.
Tout d’abord, nous ne faisons rien pour vérifier que TraceData n’a pas été annulée lorsque nous exécutons le callback et event_record.UserContext pourrait être inaccessible. J’ai corrigé cela dans ferrisetw 1.0.
Un problème plus intéressant est que nous castons un void* C (sans durée de vie ni propriété) en une référence &mut, sans donner d’informations au compilateur sur sa durée de vie réelle, ni sur d’éventuels alias.
Cela signifie que plusieurs threads recevant différents évènements ETW liés au même void* UserContext finiront par partager une référence mutable aux mêmes données en même temps ! 😱 Désastre en perspective, car les deux threads pourraient finir par se battre pour mettre à jour le membre events_handled.
Partager une référence mutable plusieurs fois est exactement l’une des choses que le compilateur Rust interdit pour garantir la sécurité de la mémoire. (Un code Rust sûr rendrait cela impossible ; ce problème n’est rendu possible que par notre utilisation [incorrecte] de unsafe).

J’ai amélioré cela dans la version 1.0.
Je n’ai pas pu me débarrasser des blocs unsafe, parce que nous déréférençons un pointeur brut, ce qui ne peut être fait en Rust sécurisé. Cependant, j’ai pu me débarrasser de ce &mut. Au lieu de voir unsafe_get_callback_ctx renvoyer une référence mutable, nous renvoyons une référence en lecture-seule. Cela indique au compilateur que cette référence est peut-être partagée avec d’autres utilisateurs.

Puisque le compilateur est maintenant conscient de cela, laissons-le nous guider ! Considérons-le comme notre ami, et demandons-lui de nous dire ce qui ne va pas dans notre code.
Si vous compilez cette version corrigée, il se plaindra, car nous essayons de modifier ctx.events_handled alors que ctx n’est pas mutable.
C’est tout à fait logique, mais nous voulons muter le contenu de ctx. Il s’agit là d’un cas typique d’utilisation de la mutabilité intérieure, qui peut être réalisée de plusieurs façons. Transformer le CallbackData en un Mutex<CallbackData> serait une possibilité, mais les Mutexes sont relativement coûteux. Il s’avère que remplacer events_handled: usize par events_handled: AtomicUSize est une autre solution, moins coûteuse et qui satisfait le compilateur.

Dans la crate ferrisetw, il y a plus de membres dans la structure CallbackData, et le compilateur se plaint de certains d’entre eux. Pas à pas, il nous avertit de mutations inattendues, et nous pouvons les corriger par ajout de la mutabilité intérieure à des endroits spécifiques uniquement, de manière à ne pas entraver les performances générales de traitement des évènements.

Accès concurrents, performance… et ensuite ?

La crate ferrisetw est encore plus complexe. Nous ne l’aborderons pas en détail dans cet article, mais par exemple, il utilise maintenant un Arc<CallbackData> au lieu d’un simple CallbackData pour éviter d’éventuelles situations de concurrence décrites ici  https://github.com/n4r1b/ferrisetw/issues/45.
Ce problème n’est pas détecté par le compilateur en raison de l’utilisation de unsafe. Mais de telles situations de concurrence de données n’auraient pas été possibles dans un code Rust pur et dur.

De plus, de nombreux travaux ont été réalisés pour améliorer les performances (en rendant ferrisetw zero-copy et en ajoutant des caches). Ces efforts sont listés ici et pourraient faire l’objet d’un futur billet de blog.

Conclusion : focus sur les forces de Rust et de son compilateur

L’écriture d’API simples, directes, difficilement mal utilisable et à l’épreuve du temps fait partie des choses que j’aime dans les langages compilés, et Rust excelle à cet exercice.
Les exemples présentés dans cet article ne sont pas révolutionnaires, mais ils montrent les forces de Rust et de son compilateur. Les solutions décrites ici, comme l’utilisation du Builder pattern, ou le fait de ne pas utiliser des références &mut sans raison valable, sont des moyens élégants de décharger le compilateur d’un travail fastidieux et de donner des garanties à votre code sans avoir à le vérifier manuellement.

Dans d’autres langages n’offrant pas autant de garanties, cela ne serait pas possible, et parfois seul un examen attentif du code pourrait permettre d’obtenir les mêmes résultats, pour une version spécifique du code (mais sans garantie que les futurs commits ne les endommageront pas sans que quiconque ne le remarque).