История этого вопроса восходит к стабилизации std::task::Waker в Rust 1.36, который ввел стандартизированный механизм для исполнителей, чтобы уведомлять фьючи о готовности. Ранее асинхронные фреймворки полагались на упакованные замыкания или пользовательские трейты уведомлений, что накладывало накладные расходы на выделение памяти и предотвращало бесшовную интеграцию с C-библиотеками. API RawWaker был разработан для поддержки абстракций с нулевыми затратами, позволяя разработчикам создавать экземпляры Waker из сырых указателей и таблиц указателей функций (RawWakerVTable), что отражает виртуальные таблицы C++, но с требованиями безопасности Rust.
Проблема возникает, потому что создание RawWaker полностью обходит систему владения и заимствования Rust. Программист должен вручную обеспечить выполнение четырех критических инвариантов: указатель на данные должен оставаться действительным в течение всего времени жизни всех клонов Waker (не только оригинала), функции четырех виртуальных таблиц (clone, wake, wake_by_ref, drop) должны быть потокобезопасными (Send и Sync), даже если исполнитель однопоточный, и функция clone должна возвращать новый RawWaker, ссылающийся на то же состояние задачи. Кроме того, виртуальная таблица должна использовать ABI extern "C" для обеспечения совместимости FFI и стабильных соглашений о вызовах между версиями Rust.
Решение требует строгого соблюдения инвариантов unsafe. Указатель на данные должен обычно ссылаться на данные 'static или быть завернутым в Arc, чтобы управлять совместным владением между клонами. Функции виртуальной таблицы должны правильно реализовывать семантику подсчета ссылок: clone должен увеличивать подсчет, drop должен уменьшать его, а wake должен уменьшать его после уведомления (потребляя Waker). Нарушение контракта ABI — например, использование соглашений о вызовах Rust вместо extern "C" — приводит к неопределенному поведению, когда исполнитель вызывает эти указатели, включая повреждение стека, неправильное выравнивание аргументов или переход к недопустимым адресам памяти.
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); // Утечка обратно, чтобы избежать drop RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Удалить Arc, освободив ссылку } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Логика пробуждения здесь, затем утечка обратно let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Неявное удаление освобождает память } 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)) } }
Рассмотрим разработку высокочастотной торговой системы, где асинхронный интерфейс Rust должен взаимодействовать с устаревшей C++ библиотекой для получения рыночных данных. C++ библиотека предоставляет функцию регистрации, принимающую void* контекст и указатель на функцию, вызывая обратный вызов, когда приходят обновления цен. Инженерная задача требует создания Waker, который связывает фьючи Rust с этой механизмом обратного вызова C++ без введения накладных расходов на выделение памяти на каждое сообщение, так как требования по задержке требуют пробуждений менее микросекунды.
Одно из решений включало хранение замыкания Box<dyn Fn() + Send> в качестве указателя на данные Waker. Этот подход обеспечивал безопасность памяти через систему владения Rust и простую интеграцию. Однако он ввел недопустимую задержку выделения памяти кучи для каждой подписки на рыночные данные и накладные расходы на виртуальную диспетчеризацию, что нарушало нулевую архитектуру копирования системы. Более того, управление временем жизни упакованного замыкания через границу FFI оказалось рискованным, так как асинхронная очистка C++ библиотеки могла оставить висячие указатели, если сторона Rust удаляла Waker до того, как C++ библиотека перестала вызывать обратный вызов.
Альтернативный подход использовал статическую глобальную хеш-таблицу, отображающую целочисленные идентификаторы на дескрипторы задач, передавая идентификатор в качестве void* контекста. Это устранило выделения и обеспечило O(1) поиск во время операций пробуждения. Тем не менее, это создало риск утечки памяти, если задачи завершились, не отменив регистрацию на ленте, и статическая карта требовала синхронизации Mutex, которая становилась узким местом контента при высокой пропускной способности данных о рынке, эффективно сериализуя уведомления о пробуждении между всеми ядрами ЦП.
Выбранное решение реализовало пользовательский RawWaker, где указатель на данные содержал Arc<TaskState>, содержащий контекст обратного вызова C++ и флаг завершения. Функции RawWakerVTable были реализованы как unsafe extern "C" трансферы, которые безопасно преобразовали void* обратно в указатели Arc, обеспечивая правильный подсчет ссылок через границу FFI. Этот дизайн устранил выделения на каждое сообщение, повторно используя структуру Arc, поддерживал потокобезопасность через атомарные операции Arc и обеспечивал безопасность памяти, уменьшая подсчет ссылок только тогда, когда последний клон Waker был удален. В результате были достигнуты задержки пробуждения менее микросекунды, сохранив гарантии безопасности памяти через границу Rust/C++, успешно пройдя тестирование на определение неопределенного поведения Miri и стресс-тесты с участием миллионов одновременных обновлений цен.
Почему функции RawWakerVTable должны быть потокобезопасными (Send + Sync), даже если исполнитель однопоточный?
Тип Waker реализует Clone, Send и Sync, позволяя ему перемещаться через границы потоков независимо от модели потоков исполнителя. Когда фьюча содержит Waker и передает его в задачу spawn_blocking или канал std::sync::mpsc, Waker может быть вызван из другого потока, чем тот, который его создал. Если функции виртуальной таблицы предполагают однопоточную доступность — например, пытаться использовать Rc или несинхронизированный статический mut — тогда они создают гонки данных, когда wake() вызывается одновременно. Более того, асинхронные среды выполнения, такие как Tokio или async-std, могут перемещать задачи между рабочими потоками для балансировки нагрузки, что означает, что Waker может быть склонирован и удален в потоках, отличных от места его создания. Требование потокобезопасности гарантирует, что механизм уведомления остается действительным независимо от того, как Waker делится по всей программе.
Какой катастрофический сбой происходит, если функция clone возвращает RawWaker с другой виртуальной таблицей, чем оригинал?
Контракт Waker требует, чтобы все клоны Waker представляли одну и ту же основную задачу и вели себя идентично при вызове. Если clone возвращает RawWaker, указывающий на другую виртуальную таблицу — возможно, связанную с другой задачей или содержащую нулевые указатели функций — тогда исполнитель может вызвать неправильную логику пробуждения, уведомляя задачу. Это приводит к тому, что либо пробуждается нерелевантная задача (логическая ошибка), либо происходит переход к недопустимой памяти (сегментационная ошибка). В частности, исполнитель обычно хранит клоны Waker во внутренних очередях; когда происходит событие, он вызывает wake() на этих хранящихся дескрипторах. Неправильно сопоставленная виртуальная таблица означает, что указатель на данные (контекст задачи) интерпретируется через неправильные сигнатуры функций, что приводит к немедленному неопределенному поведению, когда функции виртуальной таблицы преобразуют указатель в неправильный тип или обращаются к полям по неправильным смещениям.
Почему ABI extern "C" является обязательным для функций виртуальной таблицы, а не стандартного ABI Rust?
RawWakerVTable указывает указатели функций extern "C" для обеспечения совместимости FFI и стабильности ABI. ABI Rust не стабилен между версиями компилятора или уровнями оптимизации; сигнатуры функций могут изменяться в зависимости от внутренностей компилятора, решений по инлайн-коду или целевых архитектур. Использование extern "C" гарантирует, что соглашение о вызове соответствует стандарту C платформы, делая виртуальную таблицу совместимой с кодом C и предотвращая неопределенное поведение, когда компилятор генерирует код для указателей функций. Кроме того, ABI extern "C" требует специфического использования регистров и правил очистки стека, которые позволяют безопасно передавать Waker через языковые границы. Без этого ограничения связывание с динамическими библиотеками или обновление компилятора Rust может изменить соглашение о вызове функции, что приводит к повреждению стека или неправильному выравниванию аргументов, когда исполнитель вызывает wake() или clone().