Безопасность отмены в разных Rust Async-рантаймах
От Никита Бишонен • 7 минуты чтения •
Для более глубокого понимания я рекомендую своим дорогим друзьям прочитать или посмотреть отличный тред, сделанный Рейном, где она описывает её отношение к “безопасности отмены” в Rust с асинхронностью.
TL;DR для тех, кто предпочитает хакать, а не читать:
git clone https://gitlab.com/blogging1/cancellation-safety;cargo test --no-fail-fast --workspace(или ваш рантайм по выбору с-p cancel_%RUNTIME%вместо--workspace);- Читайте ошибки и тесты;
- Читайте
lib.rsдля рантаймов, которые вас интересуют; - Играйте с кодом, чтобы исправить все тесты.
Проблема
“Безопасность отмены” обычно означает отсутствие неожиданных “эффектов”, когда последовательность асинхронных операций прерывается до того, как она достигнет финального состояния. Я могу представить panic! посередине синхронной последовательности операций как аналогию.
Её фундаментальные принципы связаны с трейтом Future и деталями реализации асинхронного рантайма. Здесь я хотел бы провести эксперимент, чтобы найти общие паттерны и различия в том, как Tokio, smol и glommio обрабатывают отмены.
Future::poll— ключевой аспект здесь, так как на этапе компиляции каждая реализацияFutureпредставляет собой конечный автомат, представляющий последовательность операций, “отмена” означает, что Runtime (Реактор, Event Loop или планировщик) перестанет опрашивать этот конечный автомат и (надеюсь) уничтожит его состояние;PinиUnpinимеют свои места, так как типы!Unpinдолжны обеспечивать дополнительные гарантии, чтобы считаться “безопасными” (снижение “эффектов”);- Реализация
Wakerможет играть большую роль в обработке запросов об отмене.
Пример
В нашем приключении мы присоединимся к нашим сильным и гордым друзьям, живущим в Мории:
pub async fn mine_with_tool<F, FT>(dwarf: &mut Dwarf, mut pickaxe: F) ... {
let mut bag: Bag = Vec::new();
for i in 0..MAX_ALLOWED_SHIFTS {
pickaxe().await;
...
println!("Здесь у ворот король ОЖИДАЕТ");
...
bag.push(Ferrum::Dirty);
}
dwarf.bag = Some(bag);
}так наш асинхронный процесс будет имитировать работу гнома в шахтах. Сам mine_with_tool является композитной реализацией trait Future, внутри которой выполняется MAX_ALLOWED_SHIFTS шагов для достижения своего финального (завершенного) состояния. Простыми словами, строка 4: pickaxe().await; будет точкой, где конечный автомат опрашивается и продвигается вперед. Это означает, что именно в этом месте может быть использовано для “отмены” выполнения.
Как это выглядит на высокоуровневом промежуточном представлении (код очищен для большей лаконичности, пожалуйста, запустите cargo +nightly rustc -- -Z unpretty=hir внутри папки moria, чтобы увидеть полный вывод):
async fn mine_with_tool<F, FT>(dwarf: &'_ mut Dwarf, pickaxe: F) -> /*impl Trait*/
where F: FnMut() -> FT, FT: std::future::Future |mut _task_context: ResumeTy|
{
...
let mut bag: Bag = Vec::new();
{
...
loop {
match next(&mut iter) {
None {} => break,
Some { 0: i } => {
match into_future(pickaxe()) {
mut __awaitee =>
loop {
match unsafe {
poll(new_unchecked(&mut __awaitee),
get_context(_task_context))
} {
Ready { 0: result } => break result,
Pending {} => { }
}
_task_context = (yield ());
},
};
...
bag.push(Ferrum::Dirty);
}
}
},
...
dwarf.bag = Some(bag);
...
}почти никакой “async” магии больше, у нас есть loop, unsafe и yield ().
Давайте перейдем к “безопасности”. По моему скромному мнению, если нет unsafe кода и компилятор Rust доволен, операция безопасна. Однако это не означает, что программа ведет себя так, как ожидают от неё программисты.
... fn mine_with_tool<...>(dwarf: &mut Dwarf ... {
let mut bag: Bag = Vec::new();
...
bag.push(Ferrum::Dirty);
...
dwarf.bag = Some(bag);
}попробуйте сами понять, что делает эту реализацию trait Future названной “небезопасной отменой” (я предпочитаю небезопасная, так как unsafe важно, но является отдельным термином Rust).
Ответ кроется в том, как эта асинхронная операция хранит свое состояние и отслеживает свой прогресс. Обе эти вещи происходят внутренне, при этом удерживая изменяемую ссылку на состояние, предоставляемую вызывающей стороной dwarf: &mut Dwarf. Давайте посмотрим на два примера использования этой функции внутри рантайма smol, чтобы понять, почему такая реализация может давать сюрпризы вызывающей стороне, если произойдет отмена.
pub async fn work(dwarf: &mut Dwarf) {
super::mine(dwarf)
.or(async {
Timer::after(HALF_SHIFT).await;
})
.await;
}Гном здесь работает половину смены времени и сделал некоторый прогресс. Но когда мы идем смотреть, как запускается тест:
let mut dwallin = Dwarf::new(Name::Dwalin);
timeout::work(&mut dwallin).await;
// Сумка пуста, но я слышал песню!
assert!(dwallin.bag.is_some());мы видим, что проверка того, что работа была проделана, не проходит. Причина в том, что таймаут наступил до того, как Двалину удалось завершить свою работу, и всё, что он положил в “сумку” во внутреннем состоянии конечного автомата, было отброшено, как только мы отменили операцию в первом опросе после таймаута. (Попробуйте изменить Timer::after(HALF_SHIFT) на использование FULL_SHIFT и посмотрите, поможет ли это 😉). Так это то, что мы можем назвать небезопасной отменой (или некорректной, как предложила Рейн) реализацией асинхронной операции, которая приводит к поведению, которого мы не ожидаем.
Более интересный (и более близкий к реальности) сценарий может произойти, если сделать нашу реализацию Future более сложной и “грязной”:
pub async fn work(dwarf: &Mutex<Dwarf>) {
let mut dwarf_guard = dwarf.lock().await;
let mut old_bag = dwarf_guard.bag.take().unwrap();
timeout(HALF_SHIFT, super::mine(&mut dwarf_guard))
.await
.err()
.unwrap();
if let Some(new_bag) = dwarf_guard.bag.as_mut() {
new_bag.append(&mut old_bag)
}
}Я знаю, гномы не любят асинхронное программание, но это иллюстрирует проблему очень хорошо. Наша операция извлекает состояние из входных данных, держит его внутренне, затем накапливает с собственными результатами вычислений и возвращает его обратно.
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ ┌────────────┐ ┌──────────────┐ ┌───────┐ ┌─────────────┐ ┌──────────────┐│
│ │ Lock Mutex │ -> │ Take Old Bag │ -> │ Mine │ -> │Take New Bag │ -> │ Merge Bags ││
│ └────────────┘ └──────────────┘ └───────┘ └─────────────┘ └──────────────┘│
│ ┌────────────┐ ┌──────────────┐ │
│->│Release Lock│ -> │ End │ │
│ └────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────┘Проблема в том, что с точки зрения мьютекса операция “безопасна”, так как мы знаем, что никто больше не изменит состояние dwarf. Однако она “некорректна”, если мы отменим super::mine до того, как она полностью завершится. old_bag будет отброшен, так как new_bag не будет существовать (dwarf_guard.bag.as_mut() равно None). Так как реализация будущего work является композицией самой себя с реализацией будущего super::mine и реализацией будущего timeout, наша логика ломается, так как оба будущего ведут себя некорректно с точки зрения системы (хотя это полностью ожидаемо, валидно и безопасно Rust-код по моему мнению).
Сравнение
Обзор архитектуры рантайма
Tokio: Отраслевой стандарт
Tokio представляет собой самый широко используемый асинхронный рантайм в экосистеме Rust. Его архитектура фокусируется на:
- Многопоточный пул исполнителей для вычислительно-тяжелых задач;
- Модель завершения на основе Completer для неблокирующих операций;
- Надежная отмена задач с явными возможностями прерывания;
- Комплексная экосистема поддерживающих библиотек;
Ключевые характеристики:
- Встроенные токены отмены через
tokio_util::sync::CancellationToken; tokio::spawn()для создания задач;tokio::select!для одновременных операций;- Встроенные утилиты таймаута;
smol: Минималистичный подход
smol использует радикально другой подход с:
- Однопоточный исполнитель по умолчанию;
- Упрощенный API, фокусирующийся на основных асинхронных операциях;
- Собственного рантайма нет — он предоставляет существующие исполнители;
- Легковесные зависимости и минимальные накладные расходы;
Ключевые характеристики:
smol::spawn()для создания задач;smol::Timerдля асинхронных задержек;smol::channelдля передачи сообщений;- Нет явных токенов отмены в основном API;
Glommio: Фокус на производительности I/O
Glommio представляет собой специализированный рантайм, разработанный для высокопроизводительных нагрузок I/O:
- Локальная модель исполнителя с подходом “share-nothing-first”;
- Оптимизированный для I/O с архитектурой thread-per-core;
- Нет общей памяти между потоками по умолчанию;
- Локальные только futures для лучшей локальности кэша;
Ключевые характеристики:
glommio::LocalExecutorдля однопоточного выполнения;glommio::spawn_local()для задач;glommio::timerдля задержек;glommio::channels::local_channelдля локальной только коммуникации;
Анализ сценариев отмены
Большинство сценариев ведут себя похоже в разных рантаймах:
- Простая отмена через Drop: используйте явные механизмы drop для futures, и все рантаймы показывают частичную потерю работы, когда futures отбрасываются неожиданно;
- Отмена через таймаут: все предоставляют явную обработку таймаута, и тесты показывают, что таймаут не сохраняет частичные результаты;
- Операции, защищенные мьютексом:
tokio::sync::Mutex,smol::lock::Mutex,glommio::sync::RwLockимеют схожую семантику, в то время как тесты демонстрируют паттерны удержания блокировок и очистка работает так, как должно, хотя мы все равно теряем прогресс; - Коммуникация на основе каналов: очень похоже, только с нюансами локальности;
Но некоторые из них имеют нюансы:
- Макросы вроде
select!и одновременные операции с токенами отмены специфичны для экосистемы рантаймаTokio, что кажется не хорошим или плохим делом. Как описала Рейн в своём треде и что я слышал от других разработчиков, такие макросы иногда полностью запрещаются в проектах из-за их неявного характера (и например заменяются на futures_concurrency); - “Явная” отмена:
- в
Tokioделается черезJoinHandle::abort, и отбрасывание хендла не вызывает отмену,nahdle.awaitвернет ошибкуJoinErrorесли реализация будущего была отменена (или нормальный результат, если она завершилась), также стоит отметить, что задачиspawn_blockingне являются “отменяемыми”, так как они не асинхронны (но это предотвратит запуск задачи, если она еще не началась!); - в
smolможно вызватьTask::cancelи дождаться отмены (которая может вернутьSome, если завершилась), это похоже на отбрасывание реализации будущего; - в
glommioподход также идентиченsmolс тем, что возвращаетсяOptionпри ожидании отмены;
- в
Со стороны документации эта тема покрыта только в документации API Tokio, smol имеет одну маленькую (ха-ха) заметку на этот счет:Примечание, что отмена задачи на самом деле разбудит её и перепланирует в последний раз. Затем исполнитель может уничтожить задачу, просто отбросив её Runnable или вызвав run()., в то время как в glommio я не смог найти упоминаний о безопасности отмены или корректности. Я думаю, есть два фактора, почему это так:
- У Tokio гораздо большая популярность и использование, хотя и больше ресурсов для добавления документации;
- У Tokio гораздо более “опасный” API и внутренняя модель исполнителя, что делает его легче встретить проблемы безопасности отмены при его использовании;
Надеюсь, вы нашли что-то новое в этом блог-посте, пишите свои мысли в комментариях и проверьте дополнительные ресурсы, если хотите.
Дополнительные ресурсы и источники вдохновения
- Отличный тред Рейна talk;
- Текущий комментарий о state of the book section о безопасности отмены;
- Документация Tokio на эту тему
- Structured Concurrency lib
(Конец файла - всего 266 строк)
Комментарии
Вы можете оставить комментарий к этому блог-посту, публично ответив на него с помощью аккаунта Mastodon или другого аккаунта ActivityPub/Fediverse. Известные неприватные ответы отображены ниже.