La historia de esta pregunta se remonta a la estabilización de std::task::Waker en Rust 1.36, que introdujo un mecanismo estandarizado para que los ejecutores notificaran a los futuros sobre su disponibilidad. Antes de esto, los marcos asíncronos dependían de cierres en caja o rasgos de notificación personalizados que imponían sobrecarga de asignación y impedían una integración fluida con bibliotecas C. La API de RawWaker fue diseñada para soportar abstracciones de costo cero al permitir a los desarrolladores construir instancias de Waker a partir de punteros en bruto y tablas de punteros de función (RawWakerVTable), reflejando las tablas virtuales de C++ pero con los requisitos de seguridad de Rust.
El problema surge porque la construcción de RawWaker evade por completo el sistema de propiedad y préstamo de Rust. El programador debe asegurarse manualmente de cuatro invariantes críticas: el puntero de datos debe permanecer válido durante toda la vida de todos los clones de Waker (no solo el original), las cuatro funciones de la vtable (clone, wake, wake_by_ref, drop) deben ser seguras para hilos (Send y Sync) incluso si el ejecutor es de un solo hilo, y la función clone debe devolver un nuevo RawWaker que haga referencia al mismo estado de tarea subyacente. Además, la vtable debe utilizar el ABI extern "C" para garantizar la compatibilidad FFI y convenciones de llamadas estables a través de las versiones de Rust.
La solución requiere una estricta adherencia a las invariantes unsafe. El puntero de datos debe generalmente hacer referencia a datos 'static o estar envuelto en Arc para gestionar la propiedad compartida entre clones. Las funciones de la vtable deben implementar correctamente la semántica de conteo de referencias: clone debe incrementar el conteo, drop debe decrementarlo, y wake debe decrementarlo después de la notificación (consumiendo el Waker). Violar el contrato ABI, como usar las convenciones de llamada de Rust en lugar de extern "C", resulta en comportamiento indefinido cuando el ejecutor invoca estos punteros, incluyendo corrupción de pila, desajuste de argumentos o saltos a direcciones de memoria no válidas.
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); // Filtrar de vuelta para evitar liberar RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Liberar el Arc, liberando la referencia } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Lógica de despertar aquí, luego filtrar de vuelta let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Liberar implícitamente la memoria } 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)) } }
Considere el desarrollo de un sistema de trading de alta frecuencia donde un runtime asíncrono de Rust debe interactuar con una biblioteca de alimentación de datos de mercado heredada en C++. La biblioteca de C++ proporciona una función de registro que acepta un contexto void* y un puntero de función, invocando el callback cuando llegan actualizaciones de precios. El desafío de ingeniería requiere crear un Waker que conecte los futuros de Rust con este mecanismo de callback en C++ sin introducir sobrecarga de asignación por cada mensaje, ya que los requisitos de latencia exigen tiempos de despertar por debajo de un microsegundo.
Una solución implicó almacenar un cierre Box<dyn Fn() + Send> como el puntero de datos del Waker. Este enfoque ofreció seguridad de memoria a través del sistema de propiedad de Rust e integración sencilla. Sin embargo, introdujo una latencia de asignación en el montón inaceptable para cada suscripción de datos de mercado y una sobrecarga de despacho virtual que violaba la arquitectura de copia cero del sistema. Además, gestionar la vida útil del cierre en caja a través del límite FFI resultó peligroso, ya que la limpieza asíncrona de la biblioteca de C++ podría dejar punteros colgantes si el lado de Rust liberaba el Waker antes de que la biblioteca de C++ dejara de invocar el callback.
Un enfoque alternativo utilizó un mapa hash estático global que mapea IDs enteros a controladores de tareas, pasando el ID como el contexto void*. Esto eliminó asignaciones y proporcionó una búsqueda O(1) durante las operaciones de despertar. Sin embargo, esto creó un peligro de fuga de memoria si las tareas se completaban sin desregistrarse de la alimentación, y el mapa estático requería sincronización de Mutex que se convirtió en un cuello de botella de contención bajo un alto rendimiento de datos de mercado, effectively serializando las notificaciones de despertar a través de todos los núcleos de CPU.
La solución elegida implementó un RawWaker personalizado donde el puntero de datos contenía un Arc<TaskState> que albergaba el contexto del callback de C++ y un flag de finalización. Las funciones de RawWakerVTable fueron implementadas como thunks unsafe extern "C" que transformaban de manera segura el void* de regreso a punteros Arc, asegurando un conteo de referencias adecuado a través del límite FFI. Este diseño eliminó las asignaciones por mensaje al reutilizar la estructura Arc, mantuvo la seguridad de hilos a través de las operaciones atómicas de Arc, y garantizó la seguridad de memoria al decrementar el conteo de referencias solo cuando se eliminó el último clon de Waker. El resultado logró latencias de despertar por debajo de un microsegundo mientras mantenía garantías de seguridad de memoria a través del límite Rust/C++, pasando con éxito la detección de comportamiento indefinido de Miri y pruebas de estrés que involucraban millones de actualizaciones de precios concurrentes.
¿Por qué deben ser seguras para hilos las funciones de RawWakerVTable (Send + Sync) incluso si el ejecutor es de un solo hilo?
El tipo Waker implementa Clone, Send y Sync, lo que le permite migrar a través de los límites de hilo independientemente del modelo de subprocesos del ejecutor. Cuando un futuro tiene un Waker y lo pasa a una tarea spawn_blocking o a un canal std::sync::mpsc, el Waker puede ser invocado desde un hilo diferente al que lo creó. Si las funciones de la vtable suponen un acceso de un solo hilo, por ejemplo, al usar Rc o mutable estático no sincronizado, crean condiciones de carrera cuando se llama a wake() de forma concurrente. Además, los runtimes asíncronos como Tokio o async-std pueden migrar tareas entre hilos de trabajo para equilibrar la carga, lo que significa que el Waker podría ser clonado y eliminado en hilos diferentes de su sitio de creación. El requisito de seguridad de hilos asegura que el mecanismo de notificación siga siendo válido independientemente de cómo se comparta el Waker a lo largo del programa.
¿Qué falla catastrófica ocurre si la función clone devuelve un RawWaker con una vtable diferente a la original?
El contrato de Waker requiere que todos los clones de un Waker representen la misma tarea subyacente y se comporten de manera idéntica cuando se invoquen. Si clone devuelve un RawWaker que apunta a una vtable diferente, quizás asociada a otra tarea o que contiene punteros de función nulos, el ejecutor puede invocar la lógica de despertar incorrecta al notificar a la tarea. Esto resulta en despertar una tarea no relacionada (corrupción lógica) o saltar a memoria inválida (falla de segmentación). Específicamente, el ejecutor típicamente almacena clones de Waker en colas internas; cuando ocurre un evento, llama a wake() en estos manejadores almacenados. Una vtable desajustada significa que el puntero de datos (contexto de tarea) se interpreta a través de las firmas de función incorrectas, llevando a un comportamiento indefinido inmediato cuando las funciones de la vtable convierten el puntero a un tipo incorrecto o acceden a campos en desplazamientos incorrectos.
¿Por qué es obligatorio el ABI extern "C" para las funciones de la vtable en lugar del ABI por defecto de Rust?
La RawWakerVTable especifica punteros de función extern "C" para garantizar compatibilidad FFI y estabilidad ABI. El ABI de Rust no es estable a través de las versiones del compilador o niveles de optimización; las firmas de función podrían cambiar en función de los internos del compilador, decisiones de inline, o arquitecturas de destino. Usar extern "C" asegura que la convención de llamada siga el estándar C de la plataforma, haciendo la vtable compatible con código C y previniendo comportamiento indefinido cuando el compilador genera código para los punteros de función. Además, el ABI extern "C" obliga a un uso específico de registros y reglas de limpieza de pila que permiten que el Waker se pase de manera segura a través de fronteras de lenguajes. Sin esta restricción, vincular contra bibliotecas dinámicas o actualizar el compilador Rust podría cambiar la convención de llamada de función, causando corrupción de pila o desajuste de argumentos cuando el ejecutor invoque wake() o clone().