Présentation

Je suis ravi d’annoncer que Redox a été sélectionné parmi les 45 projets bénéficiant de nouvelles subventions NGI Zero, avec moi en tant que développeur principal pour le projet de signaux POSIX de Redox ! L’objectif de ce projet est de mettre en œuvre une gestion appropriée des signaux POSIX et de la gestion des processus, en le réalisant dans l’espace utilisateur autant que possible. Cette subvention est évidemment très avantageuse pour Redox et me permettra de consacrer beaucoup plus de temps au développement du noyau Redox et des composants associés pendant un an.

Cette annonce est survenue environ une semaine après le début de RSoC, période durant laquelle j’ai préparé le noyau pour de nouveaux changements IPC, en investissant du temps pour modifier le format des paquets de schéma, ce qui a amélioré à la fois les performances et la gamme possible de messages IPC.

Depuis lors, je travaille à remplacer l’implémentation actuelle des signaux par une version principalement basée sur l’espace utilisateur, en maintenant initialement le même niveau de support sans ajouter de nouvelles fonctionnalités. Cette mise à jour est presque finalisée.

Amélioration du protocole utilisateur et IO sans état

comme annoncé dans le rapport de juin, un format de paquet de schéma amélioré et deux nouveaux appels système ont permis d’augmenter les performances de copie de RedoxFS de 63 % !

Le noyau Redox met en œuvre des appels système IO, tels que SYS_READ, en mappant directement les plages de mémoire concernées dans le processus gestionnaire et en mettant en file d’attente des Paquets contenant des métadonnées de ces appels de schéma. La structure Paquet existe depuis 2016 sans aucune modification de son format, définie comme suit :

#[repr(packed)]
struct Paquet {
    id: u64, // identifiant unique (parmi les requêtes en cours)
    pid: usize, // identifiant de contexte de l'appelant
    uid: u32, // uid effectif de l'appelant
    gid: u32, // gid effectif de l'appelant
    a: usize, // SYS_READ
    b: usize, // fd
    c: usize, // buf.as_mut_ptr()
    d: usize, // buf.len()
    // 56 octets sur les plateformes 64 bits
}

Bien que cette structure soit suffisante pour la mise en œuvre de la plupart des appels système, la limitation évidente à trois arguments maximum a entraîné une accumulation de dettes techniques parmi de nombreux composants de Redox. Par exemple, comme pread nécessite au moins quatre arguments, presque tous les schémas précédemment mis en œuvre contenaient un code standard similaire à :

fn chercher(&mut self, fd: usize, pos: isize, whence: usize) -> Resultisize> {
    let handle = self.handles.get_mut(&fd).ok_or(Error::new(EBADF))?;
    let fichier = self
        .filesystem
        .files
        .get_mut(&handle.inode)
        .ok_or(Error::new(EBADFD))?;

    let ancien = handle.offset;
    handle.offset = match whence {
        SEEK_SET => cmp::max(0, pos),
        SEEK_CUR => cmp::max(
            0,
            pos + isize::try_from(handle.offset).or(Err(Error::new(EOVERFLOW)))?,
        ),
        SEEK_END => cmp::max(
            0,
            pos + isize::try_from(fichier.data.size()).or(Err(Error::new(EOVERFLOW)))?,
        ),
        _ => return Err(Error::new(EINVAL)),
    } as usize;
    Ok(handle.offset as isize) // pourquoi isize???
}

De plus, tous les schémas doivent stocker le curseur de fichier pour tous les handles, ce qui, sur GNU Hurd, est également considéré comme un choix de conception « discutable » dans la critique. Malheureusement, ce curseur ne peut pas être stocké dans l’espace utilisateur sans une coordination complexe, car POSIX permet aux descripteurs de fichiers d’être partagés par un nombre arbitraire de processus, après par exemple des forks ou des transferts SCM_RIGHTS (bien que ce cas d’utilisation soit très rare, il n’est pas totalement impossible que cet état soit déplacé vers l’espace utilisateur).

Le nouveau format, similaire à io_uring, est désormais défini comme suit :

#[repr(C)]
struct Sqe {
    opcode: u8,
    sqe_flags: SqeFlags,
    _rsvd: u16, // TODO: priorité
    tag: u32,
    args: [u64; 6],
    caller: u64,
}
#[repr(C)]
struct Cqe {
    flags: u8, // bits 3:0 sont CqeOpcode
    extra_

Améliorations des Appels Système dans Redox

Les entrées de la file d'attente de soumission (SQEs) et de complétion (CQEs) sont des éléments essentiels dans le traitement des appels système. Les schémas traitent les SQEs et renvoient les CQEs correspondants au noyau. Ces nouvelles structures sont conçues pour s'intégrer efficacement dans un cache, avec des champs superflus réduits pour optimiser l'espace. De plus, les appels système SYS_PREAD2 et SYS_PWRITE2 ont été intégrés à l'API, permettant désormais de transmettre à la fois des décalages et des indicateurs spécifiques à chaque appel (comme RWF_NONBLOCK).

Le membre args dépend de l'opcode, et pour SYS_PREAD2, il est configuré de la manière suivante :

// { ... }
let inner = self.inner.upgrade().ok_or(Error::new(ENODEV))?;
let address = inner.capture_user(buf)?;
let result = inner.call(Opcode::Read, [file as u64, address.base() as u64, address.len() as u64, offset, u64::from(call_flags)]);
address.release()?;
// { ... }

Actuellement, le dernier élément de args contient l'UID et le GID de l'appelant, mais cela sera remplacé par une interface plus propre à l'avenir. Le noyau simule ces nouveaux appels système en utilisant lseek suivi des appels read/write pour les anciens schémas. Cependant, pour les nouvelles implémentations, lseek peut être omis si l'application utilise des API plus modernes. Par exemple, dans redoxfs :

// Interface de disque, regroupant les octets en blocs logiques de 4096.
// L'interface ne prend pas en charge les tailles et décalages IO au niveau des octets, car les pilotes de disque sous-jacents ne le permettent pas.

unsafe fn read_at(&mut self, block: u64, buffer: &mut [u8]) -> Result {
--  try_disk!(self.file.seek(SeekFrom::Start(block * BLOCK_SIZE)));
--  let count=try_disk!(self.file.read(buffer));
--  Ok(count)
++  self.file.read_at(buffer, block * BLOCK_SIZE).or_eio()
}

unsafe fn write_at(&mut self, block: u64, buffer: &[u8]) -> Result {
--  try_disk!(self.file.seek(SeekFrom::Start(block * BLOCK_SIZE)));
--  let count=try_disk!(self.file.write(buffer));
--  Ok(count)
++  self.file.write_at(buffer, block * BLOCK_SIZE).or_eio()
}

Jeremy Soller a précédemment utilisé l'outil de copie de fichiers dd comme référence pour optimiser la taille de bloc la plus efficace, en tenant compte des coûts de commutation de contexte et de mémoire virtuelle. Le débit pour la lecture d'un fichier de 277 MiB avec dd et une taille de tampon de 4 MiB a ainsi été amélioré, passant de 170 MiB/s à 277 MiB/s grâce à la nouvelle interface, soit une amélioration d'environ 63%. Bien que d'autres facteurs puissent influencer les performances, cette optimisation est clairement perceptible.

En comparaison, l'exécution de la même commande sur Linux, avec une configuration de machine virtuelle identique, atteint un débit d'environ 2 GiB/s, ce qui représente une différence significative. Il est donc nécessaire d'améliorer à la fois RedoxFS (qui est actuellement entièrement séquentiel) et les performances de commutation de contexte. (La copie directe de disques se fait à 2 GiB/s sur Linux contre 0,8 GiB/s sur Redox).

À faire

  • De nombreux schémas utilisent encore l'ancien format de paquet. Ils devront être convertis pour permettre au noyau de supprimer le surcoût lié à l'ancien format.
  • La structure Event peut également être améliorée.
  • Les SQEs et événements des schémas devraient être accessibles aux gestionnaires à partir d'un tampon circulaire (comme io_uring), plutôt que par le mécanisme actuel où ils sont lus comme des messages via SYS_READ. Bien que le surcoût des appels système soit plus rapide que la commutation de contexte, il reste perceptible, ce qui justifie l'existence de io_uring sur Linux.

Gestion des Signaux

En mars dernier, l'implémentation interne des signaux du noyau a été améliorée pour corriger des lacunes importantes. Cependant, même après ces modifications, le support des signaux reste limité, manquant par exemple de fonctionnalités comme sigprocmask, sigaltstack et la plupart des options de sigaction.

Les Défis

Au cours de l'année écoulée, j'ai principalement travaillé à la migration de la plupart des composants de Redox pour les faire passer de redox_syscall, notre interface d'appel système directe, à libredox, une API plus stable. libredox fournit les interfaces OS communes normalement présentes dans POSIX, tout en permettant de déplacer une grande partie de la fonctionnalité en espace utilisateur, avec une implémentation en Rust (ce qui est actuellement réalisé par relibc, qui implémente également la bibliothèque standard C).

Cette migration est désormais presque achevée. En général, les noyaux monolithiques exposent un ABI d'appel système stable, parfois garanti (comme Linux), et souvent stable en pratique (FreeBSD), à l'exception notable d'OpenBSD. Cela est logique pour les noyaux monolithiques, car ils sont suffisamment grands pour « supporter » la compatibilité avec les anciennes interfaces, et parce qu'une grande partie de la pile critique en termes de performances fonctionne en mode noyau, évitant ainsi le coût de transition entre l'utilisateur et le noyau.

En revanche, un micro-noyau doit être aussi minimal que possible, et comme l'interface d'appel système sur la plupart des micro-noyaux réussis diffère de celle des noyaux monolithiques, cela signifie que notre implémentation POSIX devra gérer davantage de logique POSIX en espace utilisateur. L'exemple principal est actuellement le chargeur de programme, qui, avec fork(), a été entièrement déplacé en espace utilisateur lors de RSoC 2022. Cela ouvre également des opportunités d'optimisation significatives, ce qui justifie notre politique d'ABI stable introduite l'année dernière, où la frontière de l'ABI stable sera présente en espace utilisateur plutôt qu'à l'ABI d'appel système.

L'architecture initiale sera approximativement la suivante :


Diagramme de technologie Redox ABI

Un exemple simple de ce que relibc délègue à l'espace utilisateur est le répertoire de travail actuel (modifié lors de mon RSoC 2022). Cela nécessite que relibc entre dans une section critique sigprocmask pour verrouiller le CWD lors de l'implémentation de open(3) de manière sécurisée pour les signaux asynchrones.

Dans des cas particuliers, des solutions de contournement existent, mais en général, ces sections critiques sont indispensables :

// relibc/src/platform/redox/path.rs
pub fn canonicalize(path: &str) -> ResultString> {
    // appelle sigprocmask pour désactiver les signaux
    let _siglock = SignalMask::lock();
    let cwd  =  CWD.lock();
    canonicalize_using_cwd(cwd.as_deref(), path).ok_or(Error::new(ENOENT))
    // sigprocmask est appelé à nouveau lorsque _siglock sort de la portée
}

Si davantage d'états du noyau sont transférés vers relibc, comme les bits O_CLOEXEC et O_CLOFORK (ajoutés dans POSIX 2024), ou si certains types de descripteurs de fichiers empruntent des raccourcis dans relibc (comme les tuyaux utilisant des tampons circulaires), le coût de deux appels système sigprocmask entourant chaque section critique ralentira inutilement de nombreuses API POSIX. Par conséquent, il serait avantageux de pouvoir désactiver rapidement les signaux dans l'espace utilisateur, en utilisant de la mémoire partagée avec le noyau.

Signaux dans l'Espace Utilisateur

La solution actuellement envisagée consiste à mettre en œuvre sigaction, sigprocmask et la livraison de signaux (y compris sigreturn) uniquement à l'aide d'accès à la mémoire atomique partagée. L'astuce consiste à utiliser deux ensembles de bits AtomicU64 (même i686 le prend en charge via CMPXCHG8B) stockés dans le TCB, l'un pour les signaux standards et l'autre pour les signaux en temps réel, où les 32 bits inférieurs représentent les bits en attente, et les 32 bits supérieurs représentent les bits autorisés (négation logique du masque de signal). Cela permet, pour les signaux dirigés vers des threads, de modifier le masque de signal tout en vérifiant simultanément quels étaient les bits en attente à ce moment-là, rendant sigprocmask sans attente (si fetch_add l'est).

Tous les détails techniques n'ont pas encore été finalisés, mais un RFC préliminaire a été proposé. Les signaux ciblant des processus entiers ne sont pas encore mis en œuvre, car le noyau de Redox ne fait pas encore la distinction entre processus et threads. Une fois ce problème résolu, le travail se poursuivra pour implémenter siginfo_t pour les signaux réguliers et en file d'attente, ainsi que pour ajouter l'API sigqueue pour les signaux en temps réel.

Cette proposition d'implémentation se concentre principalement sur l'optimisation des API de signaux liées à la réception, contrairement à kill/pthread_kill et sigqueue, qui nécessitent un accès exclusif (ce qui ne changera probablement pas), actuellement maintenu dans le noyau. Un gestionnaire de processus en espace utilisateur a également été proposé, où les appels système kill et (futurs) sigqueue peuvent être convertis en appels IPC vers ce gestionnaire. L'idée est que toute autorité d'environnement POSIX, telle que les chemins absolus, UID/GID/PID, soit représentée à l'aide de descripteurs de fichiers (capabilités). Cela constitue une partie du travail nécessaire pour prendre en charge pleinement le sandboxing.

Synthèse

Jusqu'à présent, le projet sur les signaux progresse comme prévu, et l'on espère que le support POSIX pour les signaux sera principalement achevé d'ici la fin de l'été, avec des améliorations du noyau concernant la gestion des processus. Par la suite, le travail sur le gestionnaire de processus en espace utilisateur commencera, incluant potentiellement de nouvelles améliorations de performance et/ou de fonctionnalité du noyau pour faciliter cela.

Show Comments (0)
Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *