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 Paquet
s 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 :
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.