Gnirehtet réécrit en Rust
Il y a quelques mois, j’ai présenté Gnirehtet, un outil de reverse tethering pour Android que j’ai écrit en Java.
Depuis, je l’ai réécrit en Rust.
Et il est également open source ! Téléchargez-le, branchez un téléphone ou une tablette Android, et exécutez :
./gnirehtet run
(adb doit être installé)
Pourquoi Rust?
À Genymobile, nous voulions que Gnirehtet ne nécessite pas d’environnement d’exécution Java (JRE), donc le besoin principal était de compiler l’application vers un binaire exécutable natif.
Par conséquent, j’ai d’abord pensé la réécrire en C ou C++. Mais à ce moment-là (début mai), apprendre Rust m’intéressait, après avoir vaguement entendu parler de ses fonctionnalités:
Cependant, je n’avais jamais écrit une ligne de Rust ni entendu parler de son système de possession, d’emprunt ou de durées de vie.
Mais je suis convaincu que le meilleur moyen d’apprendre un langage de programmation est de travailler à plein temps sur un projet dans ce langage.
J’étais motivé, donc après avoir vérifié que ça pouvait convenir (en gros, j’ai écrit un exemple utilisant la bibliothèque d’I/O asynchrone mio, et je l’ai exécuté à la fois sur Linux et Windows), j’ai décidé de réécrire Gnirehtet en Rust.
Apprendre Rust
Pendant la réécriture, j’ai dévoré successivement le Rust book, Rust by example et le Rustonomicon. J’ai beaucoup appris, et j’aime énormément ce langage. Beaucoup de ses fonctionnalités me manquent maintenant quand je travaille sur un projet C++ :
- inférence de type avancée,
- enums,
- patterns,
- trait bounds,
Option<T>
(commestd::optional<T>
en C++17, mais tirant bénéfice des enums et des patterns),- macros hygiéniques,
- l’absence de fichiers d’en-têtes,
- le (si simple) système de build, et bien sûr
- la garantie de sûreté mémoire.
À propos de l’apprentissage, Paul Graham a écrit:
Reading and experience train your model of the world. And even if you forget the experience or what you read, its effect on your model of the world persists. Your mind is like a compiled program you’ve lost the source of. It works, but you don’t know why.
Pour les non-anglophones, ma propre traduction :
La lecture et l’expérience entraînent votre modèle du monde. Et même si vous oubliez l’expérience ou ce que vous avez lu, son effet sur votre modèle du monde persiste. Votre esprit est comme un programme compilé dont vous auriez perdu le code source. Ça fonctionne, mais vous ne savez pas pourquoi.
Certains des concepts de Rust (comme les durées de vie ou la sémantique de mouvement par défaut) m’ont fourni un jeu de données significativement différent, qui a sans aucun doute affecté mon modèle du monde (de la programmation).
Je ne vais pas présenter toutes ces fonctionnaliés (cliquez sur les liens de la documentation si ça vous intéresse). À la place, je vais essayer d’expliquer où et pourquoi Rust a resisté au design que je voulais implémenter, et comment repenser les problèmes dans le périmètre des contraintes de Rust.
La partie suivant nécessite une certaine connaissance de Rust. Vous pourriez vouloir la passer pour aller directement aux stats.
Difficultés
Je trouvais la conception de l’application Java plutôt réussie, donc je voulais reproduire l’architecture globale dans la version Rust (avec d’éventuelles adaptations pour la rustifier).
Mais j’ai lutté sur les détails, en particulier pour satisfaire le borrow checker. Les règles sont simples:
First, any borrow must last for a scope no greater than that of the owner. Second, you may have one or the other of these two kinds of borrows, but not both at the same time:
- one or more references (
&T
) to a resource,- exactly one mutable reference (
&mut T
).
En français :
Premièrement, aucun emprunt ne doit avoir une portée plus grande que celle de son propriétaire. Deuxièmement, vous pouvez avoir l’un ou l’autre de ces types d’emprunts, mais pas les deux à la fois:
- une ou plusieurs références (
&T
) vers une ressource,- exactement une référence mutable (
&mut T
).
Cependant, il m’a fallu un peu de temps pour réaliser comment elles entrent en conflit avec certains principes ou modèles de conception.
Voici donc mes retours. J’ai sélectionné 4 sujets qui sont suffisamment généraux pour être indépendants de ce projet particulier :
- les conflits avec l’encapsulation ;
- le design pattern observateur ;
- comment partager des données mutables ;
- un retour rapide sur les limitations ennuyeuses du compilateur.
Encapsulation
Les règles d’emprunt contraignent l’encapsulation. C’est la première conséquence que j’ai réalisée.
Voici un exemple canonique :
Nous créons juste une nouvelle instance de Data
, puis associons à des
variables locales des références mutables vers les tableaux header
et
payload
, en passant par des accesseurs.
Cependant, cela ne compile pas :
$ rustc sample.rs
error[E0499]: cannot borrow `data` as mutable more than once at a time
--> sample.rs:21:19
|
25 | let header = data.header();
| ---- first mutable borrow occurs here
26 | let payload = data.payload();
| ^^^^ second mutable borrow occurs here
27 | }
| - first borrow ends here
Le compilateur ne peut pas faire l’hypothèse que header()
et payload()
retournent des références vers des données disjointes dans la structure Data
.
Par conséquent, chacun emprunte la structure data
entièrement. Vu que les
règles d’emprunt interdisent d’obtenir deux références mutables vers la même
ressource, il rejette le second appel.
Parfois, nous faisons face à des limitations temporaires parce que le
compilateur n’est pas (encore) assez malin. Ce n’est pas le cas ici :
l’implémentation de header()
pourrait très bien retourner une référence vers
payload
, ou écrire dans le tableau payload
, enfreignant ainsi les règles
d’emprunt. Et la validité d’un appel d’une méthode ne peut pas dépendre de
l’implementation de la méthode.
Pour corriger le problème, le compilateur doit être capable de savoir que les
variables locales header
et payload
référencent des données disjointes,
par exemple en accédant aux champs directement :
ou en exposant une méthode fournissant les deux références simultanément :
De même, dans l’implémentation d’une structure, les règles d’emprunt empêchent de factoriser du code dans une méthode privée facilement. Prenons cet exemple (artificiel) :
Ici, le champ buf
est un tableau stockant un préfixe et un contenu de manière
contiguë.
Nous voulons factoriser la manière dont nous récupérons la slice content
,
pour que les méthodes update_*()
n’aient pas à se préoccuper des détails.
Essayons :
Malheureusement, cela ne compile pas :
error[E0506]: cannot assign to `self.sum` because it is borrowed
--> facto2.rs:11:9
|
10 | let content = self.content();
| ---- borrow of `self.sum` occurs here
11 | self.sum = content.iter().cloned().map(u32::from).sum();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ assignment to borrowed `self.sum` occurs here
error[E0506]: cannot assign to `self.port` because it is borrowed
--> facto2.rs:16:9
|
15 | let content = self.content();
| ---- borrow of `self.port` occurs here
16 | self.port = (content[2] as u16) << 8 & content[3] as u16;
|
Comme dans l’exemple précédent, récupérer une référence à travers une méthode
emprunte la structure complète (ici, self
).
Pour contourner le problème, nous pouvons expliquer au compilateur que les champs sont disjoints :
Ça compile, mais cela va totalement à l’encontre de la factorisation : l’appelant doit fournir les champs nécessaires.
Comme alternative, nous pouvons utiliser une macro pour inliner le code :
Mais c’est loin d’être idéal.
Je pense que nous devons juste l’accepter : l’encapsulation entre parfois en conflit avec les règles d’emprunt. Après tout, ce n’est pas si surprenant : imposer les règles d’emprunt nécessite de suivre chaque accès concret aux ressources, alors que l’encapsulation vise à les abstraire.
Observateur
Le design pattern observateur est utile pour enregistrer des événements sur un objet.
Dans certains cas, ce pattern pose des difficultés d’implémentation en Rust.
Pour faire simple, considérons que les événements sont des valeurs u32
. Voici
une implémentation possible :
Par commodité, implémentons notre trait EventListener
pour les closures :
Ainsi, son utilisation est simple :
Cela affiche :
notifying...
received [42]
Jusqu’ici, tout va bien.
Cependant, les choses se compliquent si nous voulons modifier un état sur la réception d’un événement. Par exemple, implémentons une structure pour stocker tous les événements reçus :
Pour pouvoir remplir ce Storage
sur chaque événement reçu, nous devons d’une
manière ou d’une autre le passer avec l’event listener, qui sera stocké dans
le Notifier
. Par conséquent, nous avons besoin qu’une instance de Storage
soit partagée entre le code appelant et le Notifier
.
Avoir deux références mutables vers le même objet enfreint évidemment les règles d’emprunt, donc nous avons besoin d’un pointeur à compteur de références.
Cependant, un tel pointeur est en lecture seul, donc nous avons également besoin
d’un RefCell
pour la mutabilité intérieure.
Ainsi, nous allons utiliser une instance de Rc<RefCell<Storage>>
. Cela peut
sembler trop verbeux, mais utiliser Rc<RefCell<T>>
(ou Arc<Mutex<T>>
pour
la thread-safety) est très courant en Rust. Et il y a pire.
Voici ce que donne le code client :
De cette manière, le Storage
est correctement modifié à partir de l’event
listener.
Tout n’est pas résolu pour autant. Dans cet exemple, c’était facile, nous avions
accès à l’instance Rc<RefCell<Storage>>
. Comment faire si nous avons seulement
accès au Storage
, par exemple si nous voulons que le Storage
s’enregistre
lui-même à partir de l’une de ses méthodes, sans que l’appelant n’ait à fournir
l’instance Rc<RefCell<Storage>>
?
Nous devons trouver un moyen de récupérer le Rc<RefCell<Storage>>
à partir du
Storage
.
Pour cela, l’idée consiste à rendre Storage
conscient de son pointeur à
compteur de références. Bien sûr, cela n’a du sens que si Storage
est
construit dans un Rc<RefCell<Storage>>
.
C’est exactement ce que enable_shared_from_this
fournit en C++, donc nous
pouvons nous inspirer de son fonctionnement : juste
stocker un Weak<RefCell<…>>
, downgradé à partir du
Rc<RefCell<…>>
, dans la structure elle-même. De cette manière, nous pouvons
l’utiliser pour récupérer une référence &mut Storage
à partir de l’event
listener :
Voici comment l’utiliser :
Il est donc possible d’implémenter le design pattern observateur en Rust, mais c’est un peu plus difficile qu’en Java ;-)
Lorsque c’est possible, il est probablement préférable de l’éviter.
Partage de données mutables
Mutable references cannot be aliased.
En français :
Les références mutables ne peuvent pas être aliasées.
Comment partager des données mutables, alors ?
Nous avons vu que nous pouvions utiliser Rc<RefCell<…>>
(ou Arc<Mutex<…>>
),
qui impose les règles d’emprunt à l’exécution. Cependant, ce n’est pas toujours
désirable :
- cela force une nouvelle allocation sur le tas,
- chaque accès a un coût à l’exécution,
- l’emprunt concerne toujours la ressource entière.
Au lieu de cela, nous pourrions utiliser des pointeurs bruts manuellement dans du code non-sûr, mais alors ce serait non-sûr.
Et il y a une autre solution, qui consiste à exposer des vues temporaires d’emprunt d’un objet. Laissez-moi expliquer.
Dans Gnirehtet, un paquet contient une référence vers les données brutes (stockées dans un buffer quelque part) ainsi que les valeur des champs des en-têtes IP et TCP/UDP (parsées à partir du tableau d’octets brut). Nous aurions pu utiliser une structure à plat pour tout stocker :
Le Packet
aurait fourni des setters pour tous les champs d’en-têtes
(modifiant à la fois les champs du paquet et le tableau d’octets). Par exemple :
Mais cette conception ne serait pas terrible (surtout que les champs d’en-têtes TCP et UDP sont différents).
À la place, nous voudrions extraire les en-têtes d’IP et de transport vers des structures séparées, gérant leur propre partie du tableau d’octets :
Vous avez immédiatement repéré le problème : il y a plusieurs références vers
la même ressource, le tableau d’octets raw
, en même temps.
Remarquez que diviser le tableau n’est pas une possibilité ici, vu
que les slices de raw
se chevauchent : nous avons besoin d’écrire le paquet
complet en une seule fois vers la couche réseau, donc le tableau raw
dans
Packet
doit inclure les headers.
Nous avons besoin d’une solution compatible avec les règles d’emprunt.
Voici celle à laquelle je suis parvenu :
- stocker les données des en-têtes séparément, sans les slices de
raw
, - créer des structures de vues pour les en-têtes d’IP et de transport, liées à une durée de vie,
- exposer des méthodes de
Packet
retournant des instances de vues.
Et voici une simplification de l’implémentation réelle :
Les setters sont implémentés sur les vues, où ils détiennent une référence mutable vers le tableau brut :
De cette manière, les règles d’emprunt sont respectées, et l’API est élégante :
Limitations du compilateur
Rust est un langage jeune, et le compilateur a quelques problèmes ennuyeux.
Le pire, d’après moi, est lié aux durées de vie non-lexicales, qui provoque des erreurs inattendues :
error[E0499]: cannot borrow `self.vec` as mutable more than once at a time
--> sample.rs:14:9
|
11 | if let Some(x) = self.find(v) {
| ---- first mutable borrow occurs here
...
14 | self.vec.push(v);
| ^^^^^^^^ second mutable borrow occurs here
15 | self.vec.last_mut().unwrap()
16 | }
| - first borrow ends here
Heureusement, cela devrait être corrigé prochainement.
La fonctionnalité d’Impl Trait, permettant aux fonctions de retourner des types abstraits non-boxés, devrait aussi améliorer l’expérience (il y a aussi une proposition étendue).
Le compilateur produit généralement des messages d’erreur très utiles. Mais quand ce n’est pas le cas, ils peuvent être très déroutants.
Sûreté et pièges
Le premier chapitre du Rustonomicon dit :
Safe Rust is For Reals Totally Safe.
[…]
Safe Rust is the true Rust programming language. If all you do is write Safe Rust, you will never have to worry about type-safety or memory-safety. You will never endure a null or dangling pointer, or any of that Undefined Behavior nonsense.
En français :
La partie Sûre de Rust est Réellement Totallement Sûre.
[…]
Le Rust Sûr est le vrai langage de programmation Rust. Si vous n’écrivez que du Rust Sûr, vous n’aurez jamais à vous inquiétez de la sûreté des types ou de la mémoire. Vous n’aurez jamais à supporter un pointeur null ou dangling, ou l’un de ces comportements indéfinis insensés.
C’est le but. Et c’est presque vrai.
Leakpocalypse
Dans le passé, il a été possible d’écrire du code Rust sûr accédant à de la mémoire libérée.
Cette “leakpocalypse” a conduit à la clarification des guaranties de sûreté : ne pas exécuter un destructeur est maintenant considéré sûr. En d’autres termes, la sûreté mémoire ne peut plus reposer sur RAII (en fait, elle n’a jamais pu, mais cela n’a été remarqué que tardivement).
En conséquence, std::mem::forget
est maintenant sûr, et JoinGuard
a
été déprécié et supprimé de la bibliothèque standard (il a été déplacé vers un
crate séparé).
Les autres outils s’appuyant sur RAII (comme Vec::drain()
) doivent prendre
des précautions particulières pour garantir que la mémoire
ne sera pas corrompue.
Ouf, la sûreté mémoire est (maintenant) sauvée.
Infinité indéfinie
En C et C++, les boucles infinies sans effets de bords sont un cas d’undefined behavior. À cause de cela, il est possible d’écrire des programmes qui, de façon inattendue, réfutent le dernier théorème de Fermat.
En pratique, le compilateur Rust s’appuie sur LLVM, qui (actuellement) applique ses optimisations en faisant l’hypothèse que les boucles infinies sans effets de bords ont un comportement indéfini. En conséquence, de tels undefined behaviors se produisent également en Rust.
Voici un exemple minimal pour l’observer :
Quand on l’exécute sans optimisations, il se comporte comme “attendu” :
$ rustc ub.rs && ./ub
^C (infinite loop, interrupt it)
Mais activer les optimisations fait paniquer le programme :
$ rustc -O ub.rs && ./ub
thread 'main' panicked at 'assertion failed: c.borrow().is_none()', /checkout/src/libstd/sys_common/thread_info.rs:51
note: Run with `RUST_BACKTRACE=1` for a backtrace.
Nous pouvons aussi produire des résultats inattendus sans plantage :
$ rustc ub.rs && ./ub
^C (infinite loop, interrupt it)
Mais avec optimisations :
$ rustc -O ub.rs && ./ub
end
C’est un cas particulier, qui sera probablement corrigé dans le futur. En pratique, les garanties de sûreté de Rust sont très fortes (au prix d’être contraignantes).
Erreur de segmentation
Cette section a été ajoutée après la publication.
Il y a d’autres sources d’undefined behaviors (voir les issues taggées I-unsound).
Par exemple, caster une valeur flottante ne pouvant pas être représentée dans le type cible est un undefined behavior, qui peut être propagé pour provoquer une erreur de segmentation :
rustc -O ub.rs && ./ub
Erreur de segmentation
Stats
C’est tout pour mes retours sur le langage lui-même.
En supplément, comparons les versions Java et Rust du serveur relais.
Nombre de lignes
$ cloc relay-{java,rust}/src
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 29 687 655 4506
Java 37 726 701 2931
-------------------------------------------------------------------------------
(tests included)
Le projet Rust est significativement plus gros, pour plusieurs raisons :
- il y a beaucoup de classes de vues d’emprunt ;
- la version Rust définit sa propre classe de selecteur d’I/O asynchrone,
encapsulant
Poll
de plus bas niveau, alors que la version Java utilise leSelector
standard ; - la gestion d’erreur pour l’analyse de la ligne de commande est plus verbeuse.
La version Java contient plus de fichiers car les tests unitaires sont séparés, alors qu’en Rust ils se trouvent dans le même fichier que les classes qu’ils testent.
Juste pour information, voici les résultats pour le client Android :
$ cloc app/src
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Java 15 198 321 875
XML 6 7 2 76
-------------------------------------------------------------------------------
SUM: 21 205 323 951
-------------------------------------------------------------------------------
Taille des binaires
--------------------------------------------
Java gnirehtet.jar 61K
--------------------------------------------
Rust gnirehtet 3.0M
after "strip -g gnirehtet" 747K
after "strip gnirehtet" 588K
--------------------------------------------
Le binaire Java lui-même est bien plus petit. La comparaison n’est pas juste cependant, vu qu’il nécessite l’environnement d’exécution Java :
$ du -sh /usr/lib/jvm/java-1.8.0-openjdk-amd64/
156M /usr/lib/jvm/java-1.8.0-openjdk-amd64/
Utilisation mémoire
Avec une seule connection TCP ouvert, voici la consommation mémoire pour le serveur relais en Java :
$ sudo pmap -x $RELAY_JAVA_PID
Kbytes RSS Dirty
total kB 4364052 86148 69316
(résultat filtré)
Et pour le serveur relais en Rust :
$ sudo pmap -x $RELAY_RUST_PID
Kbytes RSS Dirty
total kB 19272 2736 640
Regardez la valeur RSS, qui indique la mémoire réellement utilisée.
Comment on pouvait s’y attendre, la version Java consomme plus de mémoire (86Mo) que la version Rust (moins de 3Mo). De plus, sa valeur est instable à cause de l’allocation de petits objets et leur garbage collection, qui génère aussi davantage de dirty pages. La valeur pour Rust, quant à elle, est très stable : une fois la connection créée, il n’y a plus d’allocations mémoire du tout.
Utilisation CPU
Pour comparer l’utilisation CPU, voici mon scénario : un fichier de 500Mo est
hébergé par Apache sur mon ordinateur, je démarre le serveur relais avec perf
stat
, puis je télécharge le fichier à partir de Firefox sur Android. Dès que le
fichier est téléchargé, je stoppe le serveur relais (Ctrl+C).
Voici les résultats pour la version Java :
$ perf stat -B java -jar gnirehtet.jar relay
Performance counter stats for 'java -jar gnirehtet.jar relay':
11805,458302 task-clock:u (msec) # 0,088 CPUs utilized
0 context-switches:u # 0,000 K/sec
0 cpu-migrations:u # 0,000 K/sec
28 618 page-faults:u # 0,002 M/sec
17 908 360 446 cycles:u # 1,517 GHz
13 944 172 792 stalled-cycles-frontend:u # 77,86% frontend cycles idle
18 437 279 663 instructions:u # 1,03 insn per cycle
# 0,76 stalled cycles per insn
3 088 215 431 branches:u # 261,592 M/sec
70 647 760 branch-misses:u # 2,29% of all branches
133,975117164 seconds time elapsed
Et pour la version Rust :
$ perf stat -B ./gnirehtet relay
Performance counter stats for 'target/release/gnirehtet relay':
2707,479968 task-clock:u (msec) # 0,020 CPUs utilized
0 context-switches:u # 0,000 K/sec
0 cpu-migrations:u # 0,000 K/sec
1 001 page-faults:u # 0,370 K/sec
1 011 527 340 cycles:u # 0,374 GHz
2 033 810 378 stalled-cycles-frontend:u # 201,06% frontend cycles idle
981 103 003 instructions:u # 0,97 insn per cycle
# 2,07 stalled cycles per insn
98 929 222 branches:u # 36,539 M/sec
3 220 527 branch-misses:u # 3,26% of all branches
133,766035253 seconds time elapsed
Je ne suis pas un expert pour analyser les résultats, mais de ce que je
comprends de la valeur task-clock:u
, la version Rust consomme 4× moins de
temps CPU.
Conclusion
Réécrire Gnirehtet en Rust a été une formidable expérience, où j’ai appris un super langage et de nouveaux concepts de programmation. Et maintenant, nous avons une application native avec de meilleures performances.
Bon reverse tethering !
Discussions sur reddit et Hacker News.