L'histoire de cette question remonte à la stabilisation de std::task::Waker dans Rust 1.36, qui a introduit un mécanisme standardisé permettant aux exécuteurs de notifier les futurs de leur disponibilité. Avant cela, les frameworks asynchrones s'appuyaient sur des fermetures boxées ou des traits de notification personnalisés qui imposaient un coût d'allocation et empêchaient une intégration transparente avec les bibliothèques C. L'API RawWaker a été conçue pour prendre en charge des abstractions à coût zéro en permettant aux développeurs de construire des instances de Waker à partir de pointeurs bruts et de tables de pointeurs de fonction (RawWakerVTable), imitant les tables virtuelles de C++ mais avec les exigences de sécurité de Rust.
Le problème survient parce que la construction de RawWaker contourne complètement le système de propriété et d'emprunt de Rust. Le programmeur doit garantir manuellement quatre invariants critiques : le pointeur de données doit rester valide durant la durée de tous les clones de Waker (pas seulement le clone original), les quatre fonctions vtable (clone, wake, wake_by_ref, drop) doivent être sûres pour les threads (Send et Sync) même si l'exécuteur est mono-thread, et la fonction clone doit renvoyer un nouveau RawWaker référencer le même état de tâche sous-jacent. De plus, la vtable doit utiliser l'ABI extern "C" afin de garantir une compatibilité FFI et des conventions d'appel stables entre les versions de Rust.
La solution nécessite un respect strict des invariants unsafe. Le pointeur de données doit généralement référencer des données 'static ou être encapsulé dans un Arc pour gérer la propriété partagée entre les clones. Les fonctions de la vtable doivent correctement implémenter les sémantiques de comptage de références : clone doit incrémenter le compteur, drop doit le décrémenter, et wake doit décrémenter après notification (consommant le Waker). Violer le contrat ABI – comme utiliser des conventions d'appel Rust au lieu de extern "C" – entraîne un comportement indéfini lorsque l'exécuteur invoque ces pointeurs, y compris la corruption de la pile, un désalignement des arguments ou un saut vers des adresses mémoire invalides.
use std::sync::Arc; use std::task::{RawWaker, RawWakerVTable, Waker}; struct TaskState { id: u64, } unsafe fn clone_waker(data: *const ()) -> RawWaker { let arc = Arc::from_raw(data as *const TaskState); let _ = Arc::clone(&arc); let _ = Arc::into_raw(arc); // Fuite pour éviter le drop RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Libération de l'Arc, en relâchant la référence } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Logique de réveil ici, puis fuite à nouveau let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Le drop implicite libère la mémoire } static VTABLE: RawWakerVTable = RawWakerVTable::new( clone_waker, wake_waker, wake_by_ref, drop_waker, ); fn create_waker(state: Arc<TaskState>) -> Waker { let ptr = Arc::into_raw(state) as *const (); unsafe { Waker::from_raw(RawWaker::new(ptr, &VTABLE)) } }
Considérez le développement d'un système de trading haute fréquence où un runtime asynchrone Rust doit interagir avec une bibliothèque de flux de données de marché C++ héritée. La bibliothèque C++ fournit une fonction d'enregistrement acceptant un contexte void* et un pointeur de fonction, invoquant le rappel lorsqu'une mise à jour des prix arrive. Le défi d'ingénierie nécessite de créer un Waker qui établit un pont entre les futurs Rust et ce mécanisme de rappel C++ sans introduire de coût d'allocation par message, car les exigences de latence demandent des temps de réveil sous la microseconde.
Une solution a consisté à stocker une fermeture Box<dyn Fn() + Send> comme pointeur de données du Waker. Cette approche offrait une sécurité mémoire à travers le système de propriété de Rust et une intégration facile. Cependant, elle introduisait une latence d'allocation de tas inacceptable pour chaque abonnement aux données de marché et un coût de dispatch virtuel qui violait l'architecture zéro-copie du système. En outre, la gestion de la durée de vie de la fermeture boxée à travers la frontière FFI s'est révélée dangereuse, car le nettoyage asynchrone de la bibliothèque C++ pouvait laisser des pointeurs pendants si le côté Rust supprimait le Waker avant que la bibliothèque C++ ne cesse d'invoquer le rappel.
Une approche alternative a utilisé une carte de hachage statique globale mappant des ID entiers à des poignées de tâche, passant l'ID comme contexte void*. Cela a éliminé les allocations et fourni une recherche O(1) lors des opérations de réveil. Pourtant, cela a créé un risque de fuite de mémoire si les tâches se terminaient sans se désinscrire du flux, et la carte statique nécessitait une synchronisation Mutex qui est devenue un goulot d'étranglement de contention sous un haut débit de données de marché, sérialisant effectivement les notifications de réveil à travers tous les cœurs CPU.
La solution choisie a implémenté un RawWaker personnalisé où le pointeur de données contenait un Arc<TaskState> contenant le contexte de rappel C++ et un indicateur d'achèvement. Les fonctions RawWakerVTable ont été mises en œuvre comme des thunks unsafe extern "C" qui convertissaient de manière sécurisée le void* en pointeurs Arc, garantissant un bon comptage de références à travers la frontière FFI. Ce design a éliminé les allocations par message en réutilisant la structure Arc, maintenu la sécurité des threads à travers les opérations atomiques de Arc, et assuré la sécurité mémoire en décrémentant le compteur de références uniquement lorsque le dernier clone de Waker a été supprimé. Le résultat a atteint des latences de réveil sous la microseconde tout en maintenant des garanties de sécurité mémoire à travers la frontière Rust/C++, réussissant à passer la détection de comportement indéfini de Miri et des tests de stress impliquant des millions de mises à jour de prix simultanées.
Pourquoi les fonctions RawWakerVTable doivent-elles être sûres pour les threads (Send + Sync) même si l'exécuteur est mono-thread ?
Le type Waker implémente Clone, Send, et Sync, permettant sa migration à travers les frontières de threads indépendamment du modèle de threading de l'exécuteur. Lorsqu'un futur détient un Waker et le passe à une tâche spawn_blocking ou à un canal std::sync::mpsc, le Waker peut être invoqué depuis un thread différent de celui qui l'a créé. Si les fonctions de la vtable supposent un accès mono-thread—par exemple, en utilisant Rc ou des statiques mutables non synchronisées—elles créent des courses de données lorsque wake() est appelé simultanément. De plus, les runtimes asynchrones comme Tokio ou async-std peuvent migrer des tâches entre différents threads pour équilibrer la charge, ce qui signifie que le Waker pourrait être cloné et supprimé sur des threads différents de son site de création. L'exigence de sécurité de thread garantit que le mécanisme de notification reste valide, peu importe comment le Waker est partagé à travers le programme.
Quelle défaillance catastrophique se produit si la fonction clone retourne un RawWaker avec une vtable différente de l'original ?
Le contrat Waker exige que tous les clones d'un Waker représentent la même tâche sous-jacente et se comportent de manière identique lorsqu'ils sont invoqués. Si clone retourne un RawWaker pointant vers une vtable différente—peut-être associée à une tâche différente ou contenant des pointeurs de fonction nuls—l'exécuteur peut invoquer la mauvaise logique de réveil lors de la notification de la tâche. Cela conduit soit à réveiller une tâche non liée (corruption logique) soit à sauter vers une mémoire invalide (segmentation fault). En particulier, l'exécuteur stocke généralement des clones de Waker dans des queues internes ; lorsqu'un événement se produit, il appelle wake() sur ces poignées stockées. Une vtable non correspondante signifie que le pointeur de données (contexte de tâche) est interprété à travers des signatures de fonction incorrectes, menant immédiatement à un comportement indéfini lorsque les fonctions de la vtable castent le pointeur vers un type incorrect ou accèdent à des champs à des offsets incorrects.
Pourquoi l'ABI extern "C" est-elle obligatoire pour les fonctions de la vtable plutôt que l'ABI par défaut de Rust ?
La RawWakerVTable spécifie des pointeurs de fonction extern "C" pour garantir la compatibilité FFI et la stabilité de l'ABI. L'ABI de Rust n'est pas stable entre les versions du compilateur ou les niveaux d'optimisation ; les signatures de fonction peuvent changer en fonction des internaux du compilateur, des décisions d'inlining ou des architectures cibles. L'utilisation de extern "C" garantit que la convention d'appel suit le standard C de la plate-forme, rendant la vtable compatible avec le code C et prévenant les comportements indéfinis lorsque le compilateur génère du code pour les pointeurs de fonction. De plus, l'ABI extern "C" impose des règles spécifiques d'utilisation des registres et de nettoyage de la pile qui permettent au Waker d'être passé de manière sécurisée à travers les frontières de langage. Sans cette contrainte, le lien avec des bibliothèques dynamiques ou la mise à niveau du compilateur Rust pourrait modifier la convention d'appel de fonction, provoquant la corruption de la pile ou un désalignement des arguments lorsque l'exécuteur invoque wake() ou clone().