Pipes
Comme le C et en accord avec la conception générale d’Unix, Rust permet de
manipuler les flux d’entrée/sortie standard stdin
, stdout
et stderr
comme
si c’étaient des fichiers : ils implémentent Read
et Write
comme tous les
flux d’octets.
Si l’on souhaite écrire du code générique qui peut utiliser ces flux standards
parmi d’autres flux d’octets, on peut y avoir accès via les fonctions
std::io::stdin()
,
stdout()
et
stderr()
. C’est en
particulier la seule façon d’utiliser stdin
en Rust : il n’y a pas
d’équivalent du scanf()
de C et du std::cin
de C++ dans la bibliothèque
standard Rust.
Mais ces flux ont aussi quelques spécificités qui justifient un chapitre dédié dans ce cours :
- Ils peuvent ou non être connectés à un terminal, ce qui affecte un peu leurs propriétés.
- Ce sont des ressources globales qui peuvent être utilisées depuis plusieurs threads, et leur utilisation naïve est donc soumise à une synchronisation implicite.
Détection du terminal
La gestion du terminal de la bibliothèque standard Rust est très sommaire et se restreint à la réponse à une seule question : est-ce que les flux standard sont, ou non, connectés à un terminal ?
Il est important de connaître la réponse à cette question pour deux raisons :
- Sous Windows, quand ces flux sont connectés à un terminal, ils ne peuvent échanger que du texte. Tenter d’y écrire des octets arbitraires déclenchera une erreur d’entrée/sortie. C’est donc une pratique non portable que l’on doit éviter.
- Tous les terminaux usuels supportent des fonctionnalités plus complexes que les entrées/sorties texte simple : coloration du texte, modification d’un texte déjà émis, gestion de la souris… La façon d’utiliser ces fonctionnalités dépend de l’OS utilisé, mais dans tous les cas, une application qui les utilise doit gérer correctement le cas où les entrées/sorties standard ne sont pas connectées à un terminal, mais redirigées vers un fichier.
La bibliothèque standard Rust fournit donc un trait
IsTerminal
,
implémenté par les flux standard ainsi que par le type File
et ses équivalents
spécifiques à chaque système d’exploitation. Il permet de répondre à cette
question importante, à la manière du
isatty()
de POSIX.
#![allow(unused)] fn main() { use std::io::IsTerminal; println!("stdin est un terminal : {}", std::io::stdin().is_terminal()); println!("stdout est un terminal : {}", std::io::stdout().is_terminal()); println!("stderr est un terminal : {}", std::io::stderr().is_terminal()); }
Pour toute utilisation plus complexe d’un terminal (coloration du texte,
formatage, etc), on se tournera vers des bibliothèques dédiées comme
termion
(facile, mais spécifique au
monde Unix) et
crossterm
(supporte aussi
Windows, mais plus complexe à cause de l’abstraction ajoutée).
Synchronisation entre threads
Les flux d’entrée/sortie standard sont des ressources globales qui peuvent être
utilisées par tous les threads. Il faut donc gérer la possibilité d’accès
concurrents. Rust le gère comme le C : toutes les opérations sur ces flux
exposées par la bibliothèque standard sont implémentées en verrouillant un
Mutex
global, en effectuant l’opération, puis en relâchant le Mutex
.
Cette politique par défaut a deux inconvénients :
- Si on fait de nombreuses opérations sur les entrées/sorties standard, le coût
en performances associé à la manipulation du
Mutex
global peut devenir important. - Parfois la granularité de synchronisation par défaut est trop faible, et on
voudrait pouvoir acquérir le
Mutex
pour une période plus prolongée afin d’éviter les collisions entre threads.
Pour cette raison, les types
Stdin
,
Stdout
et
Stderr
retournés par les fonctions stdin()
, stdout()
et stderr()
fournissent tous
une méthode lock()
qui permet d’acquérir un contrôle exclusif temporaire du
flux d’entrée/sortie associé.
Ces fonctions fonctionnent un peu comme un Mutex
, mais sans poisoning :
elles retournent directement un type
StdinLock
,
StdoutLock
ou
StderrLock
qui donne l’accès exclusif au flux, et rétablissent la sémantique normal d’accès
partagé quand ils sortent du scope :
#![allow(unused)] fn main() { use std::io::prelude::*; // Exemple d'utilisation d'une fonction pour alléger la gestion des erreurs fn rataxes() -> std::io::Result<()> { // Acquisition du contrôle exclusif de stdout let mut stdout = std::io::stdout().lock(); // Aucun autre thread ne peut afficher du texte tant que je détiens le // StdoutLock, donc ces lignes de texte resteront groupées dans la sortie. writeln!(&mut stdout, "Vive Rataxès !")?; writeln!(&mut stdout, "Notre seul maître !")?; writeln!(&mut stdout, "Le roi du mooooonde !") } rataxes().expect("Echec d'écriture"); // Les autres threads peuvent à nouveau écrire sur stdout ici }
Il faut cependant être prudent quand on utilise cette fonctionnalité, car il
existe un risque de deadlock. Par exemple, on ne doit pas utiliser
println!()
lorsqu’on possède une StdoutLock
, car l’implémentation de
println!()
tenterait implicitement d’acquérir un deuxième exemplaire de la
StdoutLock
, ce qui causerait un bloquage permanent du thread actif (et à
terme de tous les threads qui essaient d’utiliser stdout
à leur tour).
L’utilisation de StdinLock
a l’avantage supplémentaire de donner accès au
tampon global d’entrée du programme, ce qui permet d’avoir accès à l’interface
BufRead
là où
Stdin
n’expose qu’une interface
Read
. Cependant,
dans le cas courant où on souhaite un accès à ce tampon global pour lire des
lignes de texte depuis l’entrée standard, on peut aussi utiliser directement les
raccourcis Stdin::read_line()
et Stdin::lines()
prévus à cet effet :
#![allow(unused)] fn main() { for ligne in std::io::stdin().lines() { println!( "Lu une ligne de stdin : {}", ligne.expect("Echec de lecture depuis stdin") ); } }