Безопасность отмены в разных Rust Async-рантаймах

От Никита Бишонен7 минуты чтения



Для более глубокого понимания я рекомендую своим дорогим друзьям прочитать или посмотреть отличный тред, сделанный Рейном, где она описывает её отношение к “безопасности отмены” в Rust с асинхронностью.

TL;DR для тех, кто предпочитает хакать, а не читать:

Проблема

“Безопасность отмены” обычно означает отсутствие неожиданных “эффектов”, когда последовательность асинхронных операций прерывается до того, как она достигнет финального состояния. Я могу представить panic! посередине синхронной последовательности операций как аналогию.

Её фундаментальные принципы связаны с трейтом Future и деталями реализации асинхронного рантайма. Здесь я хотел бы провести эксперимент, чтобы найти общие паттерны и различия в том, как Tokio, smol и glommio обрабатывают отмены.

Пример

В нашем приключении мы присоединимся к нашим сильным и гордым друзьям, живущим в Мории:

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. Его архитектура фокусируется на:

Ключевые характеристики:

smol: Минималистичный подход

smol использует радикально другой подход с:

Ключевые характеристики:

Glommio: Фокус на производительности I/O

Glommio представляет собой специализированный рантайм, разработанный для высокопроизводительных нагрузок I/O:

Ключевые характеристики:

Анализ сценариев отмены

Большинство сценариев ведут себя похоже в разных рантаймах:

Но некоторые из них имеют нюансы:

Со стороны документации эта тема покрыта только в документации API Tokio, smol имеет одну маленькую (ха-ха) заметку на этот счет:Примечание, что отмена задачи на самом деле разбудит её и перепланирует в последний раз. Затем исполнитель может уничтожить задачу, просто отбросив её Runnable или вызвав run()., в то время как в glommio я не смог найти упоминаний о безопасности отмены или корректности. Я думаю, есть два фактора, почему это так:

  1. У Tokio гораздо большая популярность и использование, хотя и больше ресурсов для добавления документации;
  2. У Tokio гораздо более “опасный” API и внутренняя модель исполнителя, что делает его легче встретить проблемы безопасности отмены при его использовании;

Надеюсь, вы нашли что-то новое в этом блог-посте, пишите свои мысли в комментариях и проверьте дополнительные ресурсы, если хотите.

Дополнительные ресурсы и источники вдохновения

(Конец файла - всего 266 строк)

Комментарии

Вы можете оставить комментарий к этому блог-посту, публично ответив на него с помощью аккаунта Mastodon или другого аккаунта ActivityPub/Fediverse. Известные неприватные ответы отображены ниже.

Открыть Пост