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")
    );
}
}