Асинхронная отмена I

2021-11-10 Yoshua Wuyts Источник

  • Задачи и future
  • Отмена future
  • Отмена задач
  • Распространение отмены для future
  • Распространение отмены для задач
  • Исправление распространения отмены
  • Структурированная конкурентность
  • Отмена группы задач
  • Устойчивость к останову (halt-safety)
  • Должны ли задачи быть отсоединяемыми?
  • Асинхронный трейт, который нельзя отменить?
  • Промежуточное сопоставление при отмене?
  • Блоки defer?
  • Отмена и io_uring
  • Заключение

Иногда мы начинаем что-то делать, но решаем в середине процесса, что предпочли бы этого не делать. Этот процесс иногда называют отменой. Скажем, мы случайно нажали «загрузить» на большой файл в браузере. У нас должен быть способ сказать компьютеру прекратить его загрузку.

Когда рабочая группа по основам асинхронности (async foundations WG) исследовала пользовательские сценарии ранее в этом году, асинхронная отмена возникала repeatedly. Это одна из тех вещей, которые важно иметь, но о которых может быть сложно рассуждать. Этому не способствует тот факт, что об этом мало написано, поэтому я подумал, что могу попытаться восполнить этот пробел, написая глубокое погружение в тему.

В этом посте мы рассмотрим примитивы асинхронного Rust и расскажем, как отмена работает для этих примитивов сегодня. Затем мы перейдем к способам, с помощью которых мы можем гарантировать, что не останемся с висящими ресурсами. И, наконец, мы взглянем на то, что текущее направление развития асинхронного Rust означает для асинхронной отмены. Звучит как план? Хорошо, давайте погрузимся!

Задачи и Future

Для целей этого поста нам нужно различать два типа асинхронных примитивов в Rust: future и задачи1.

  • Future представляют собой основной строительный блок асинхронного Rust и существуют как часть языка Rust и стандартной библиотеки (stdlib). Future — это нечто, что при .await позже становится значением. По дизайну он ничего не делает, если его не .await, и должен быть доведен до завершения каким-либо другим внешним циклом (пример такого цикла).

  • Задачи еще не являются частью языка Rust или стандартной библиотеки и представляют собой управляемый2 фрагмент асинхронного выполнения, поддерживаемый асинхронной средой выполнения (runtime)3. Задачи часто позволяют асинхронному коду становиться многопоточным: обычно планировщики (executors) распределяют задачи по нескольким потокам и даже перемещают их между потоками после того, как они начали выполняться. Задачу не нужно .await, чтобы она начала выполняться, а future, который она возвращает, — это просто способ получить возвращаемое значение задачи после ее завершения.

Многие языки, включая JavaScript, используют эквиваленты задач Rust, а не future Rust, в качестве своих основных строительных блоков4. Это удобно, потому что предоставляется только один тип асинхронного строительного блока, и оптимизаторы времени выполнения языка могут при необходимости выяснить, как ускорить работу. Но в Rust мы, к сожалению, не можем на это полагаться, поэтому мы вручную различаем неуправляемые (future) и управляемые (задачи) примитивы.

Отмена Future

Отмена позволяет future прекратить работу досрочно, когда мы знаем, что нас больше не интересует его результат. Future в Rust могут быть отменены в одной из двух точек. Для простоты большинство наших примеров в этом посте будут использовать операции сна и печати; тогда как в реальном мире мы бы, вероятно, говорили об операциях с файлами/сетью и обработке данных.

  1. Отменить future до того, как он начнет выполняться

Здесь мы создаем охранник удаления (drop guard), передаем его в асинхронную функцию, которая возвращает future, а затем отбрасываем future до того, как сделаем .await:

use std::time::Duration;

struct Guard;
impl Drop for Guard {
    fn drop(&mut self) {
        println!("2");
    }
}

async fn foo(guard: Guard) {
    println!("3");
    task::sleep(Duration::from_secs(1)).await;
    println!("4");
}

fn main() {
    println!("1");
    let guard = Guard {};
    let fut = foo(guard);
    drop(fut);
    println!("done");
}

Это выводит:

> 1
> 2
> done

Наш тип Guard здесь будет печатать, когда его деструктор (Drop) запустится. Мы никогда фактически не выполняем future, но деструктор все равно запускается, потому что мы передали значение в асинхронную функцию. Это означает, что первой точкой отмены любого future является момент сразу после создания, до того как тело асинхронной функции выполнится. То есть не все точки отмены обязательно обозначены .await.

  1. Отменить future в точке await

Здесь мы создаем future, опрашиваем его ровно один раз, а затем отбрасываем:

#![allow(unused)]
fn main() {
use std::{ptr, task};
use async_std::task;
use std::time::Duration;

async fn foo() {
    println!("2");
    task::sleep(Duration::from_secs(1)).await;
    println!("3");
}

let mut fut = Box::pin(foo());
let mut cx = empty_cx();

println!("1");
assert!(fut.as_mut().poll(&mut cx).is_pending());
drop(fut);
println!("done");

/// Создать пустой callback "Waker", обернутый в структуру "Context".
/// То, как это работает, не особенно важно для остальной части этого поста.
fn empty_cx() -> task::Context { ... }
}
> 1
> 2
> done

Фундаментально, вы можете думать о .await как об отметке точки, где может произойти отмена. Где ключевые слова, такие как return и ?, отмечают точки, где функция может вернуть значение, .await отмечает место, где вызывающая сторона функции может решить, что функция не должна выполняться дальше. Но что важно: во всех случаях деструкторы будут запущены, позволяя очистить ресурсы.

Future не могут быть отменены между вызовами .await или после последнего вызова .await. У нас также еще нет дизайна для «async Drop», поэтому мы еще не можем ничего meaningful сказать о том, как это будет взаимодействовать с отменой.

Отмена задач

Поскольку задачи еще не стандартизированы в Rust, отмена задач тоже нет. И неудивительно, что разные среды выполнения имеют разные представления о том, как отменять задачу. И async-std, и tokio разделяют похожую модель задач. Мне удобнее всего с async-std5, поэтому давайте использовать ее в качестве примера:

#![allow(unused)]
fn main() {
use async_std::task;

let handle = task::spawn(async {
    task::sleep(Duration::from_secs(1)).await;
    println!("2");
});

println!("1");
drop(handle);     // Отбрасываем handle задачи.
task::sleep(Duration::from_secs(2)).await;
println!("done");
}
> 1
> 2
> done

Здесь мы отбросили handle, но задача продолжила выполняться в фоне. Это потому, что среда выполнения async-std использует семантику «отсоединять при отбрасывании» (detach on drop) для задач. То же самое в среде выполнения tokio. Чтобы отменить задачу, нам нужно вручную вызвать метод у handle. Для async-std это JoinHandle::cancel, а для tokioJoinHandle::abort:

#![allow(unused)]
fn main() {
use async_std::task;

let handle = task::spawn(async {
    task::sleep(Duration::from_secs(1)).await;
    println!("2");
});

println!("1");
handle.cancel().await;    // Отменяем handle задачи
task::sleep(Duration::from_secs(2)).await;
println!("done");
}
> 1
> done

Мы видим, что если мы вызываем JoinHandle::cancel, задача отменяется в этот момент, и число 2 больше не печатается.

Распространение отмены для future

В асинхронном Rust отмена автоматически распространяется для future. Когда мы прекращаем опрашивать future, все future, содержащиеся внутри него, также перестанут делать прогресс. И все деструкторы внутри них будут запущены. В этом примере мы будем использовать функцию FutureExt::timeout из async-std, которая возвращает Result<T, TimeoutError>.

#![allow(unused)]
fn main() {
use async_std::prelude::*;
use async_std::task;
use std::time::Duration;

async fn foo() {
    println!("2");
    bar().timeout(Duration::from_secs(3)).await;
    println!("5");
}

async fn bar() {
    println!("3");
    task::sleep(Duration::from_secs(2)).await;
    println!("4");
}

println!("1");
foo().timeout(Duration::from_secs(1)).await;
println!("done");
}
> 1
> 2
> 3
> done    # `4` и `5` никогда не печатаются

Дерево future выше может быть выражено следующим графом:

main
  -> foo (таймаут через 1 сек)
    -> bar (таймаут через 3 сек)
      -> task::sleep (ожидание 2 сек)

Поскольку foo превышает таймаут и отбрасывается до того, как bar имеет шанс завершиться, не все числа успевают напечататься. bar отбрасывается до того, как у него появляется возможность завершиться, и все его ресурсы очищаются, когда запускаются его деструкторы.

Это означает, что для отмены цепочки future все, что нам нужно сделать, — это отбросить ее, и все ресурсы, в свою очередь, будут очищены. Отбрасываем ли мы future вручную, вызываем метод таймаута для future или соревнуем несколько future — отмена будет распространяться, и ресурсы будут очищены.

Распространение отмены для задач

Как мы показали ранее, простого отбрасывания handle задачи недостаточно для отмены задачи в большинстве сред выполнения. Нам нужно явно вызвать метод отмены, чтобы отменить задачу. Это означает, что отмена задач не распространяется автоматически.

Ретроспективно это, вероятно, была ошибка. Более конкретно, это, вероятно, была моя ошибка. JoinHandle от async-std был первым в экосистеме, и я настаивал на том, что мы должны моделировать его, используя поведение «отсоединять-при-отбрасывании» непосредственно после std::thread::JoinHandle. Но я не учел, что потоки не могут быть отменены извне: std::thread::JoinHandle не имеет и, возможно, никогда не будет иметь метода cancel6.

Неспособность распространять отмену означает, что если мы отменяем дерево работы, мы можем остаться с висящими задачами, которые продолжаются, когда мы действительно этого не хотим. Вместо того чтобы иметь метод cancel, который позволяет нам вручную подключаться к распространению отмены (подробнее об этом позже), распространение отмены должно быть отказоустойчивым по умолчанию.

К счастью, нам не нужно гадать, как бы выглядела среда выполнения с поведением «распространение отмены отключено по умолчанию». Планировщик async-task и, в свою очередь, среда выполнения smol делают именно это.

#![allow(unused)]
fn main() {
smol::block_on(async {
    println!("1");
    let task = smol::spawn(async {
        println!("2");
    });
    drop(task);
    println!("done")
});
}
> 1
> done

Исправление распространения отмены

К сожалению, только smol распространяет отмену между задачами, и было бы критическим изменением модифицировать поведение распространения отмены JoinHandle в других средах выполнения.

Пользователи сред выполнения все еще могут гарантировать, что отмена всегда будет правильно распространяться, создав пользовательскую функцию spawn, которая содержит охранник удаления, например так:

#![allow(unused)]
fn main() {
use tokio::task;
use core::future::Future;
use core::pin::Pin;
use core::task::{Context, Poll};

/// Породить новую задачу Tokio и отменить ее при отбрасывании.
pub fn spawn<T>(future: T) -> Wrapper<T::Output>
where
    T: Future + Send + 'static,
    T::Output: Send + 'static,
{
    Wrapper(task::spawn(future))
}

/// Отменяет обернутую задачу Tokio при Drop.
pub struct Wrapper<T>(task::JoinHandle<T>);

impl<T> Future for Wrapper<T>{
    type Output = Result<T, task::JoinError>;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        unsafe { Pin::new_unchecked(&mut self.0) }.poll(cx)
    }
}

impl<T> Drop for Wrapper<T> {
    fn drop(&mut self) {
        // для `async_std::task::Task` сделайте `let _ = self.0.cancel()`
        self.0.abort();
    }
}
}

Эта обертка может быть адаптирована для работы с async-std и гарантирует, что отмена распространяется через границы задач.

Структурированная конкурентность

Учитывая, что мы много говорим о распространении отмены и деревьях в этом посте, мы, вероятно, должны упомянуть концепцию «структурированной конкурентности».

Чтобы асинхронный Rust был структурно конкурентным, я думаю о задачах как имеющих следующие требования:

  • Убедиться7, что дочерние задачи не переживут своих родителей.
  • Если мы отменяем родительскую задачу, дочерняя задача также должна быть отменена.
  • Если дочерняя задача вызывает ошибку, родительская задача должна иметь возможность действовать на нее.

Эта структура обеспечивает то, что ошибки не игнорируются случайно, или мы не забываем отменять задачи дальше по дереву. Это основа для эффективной реализации других механизмов поверх, таких как повторные попытки, ограничения, супервизоры и транзакции.

Отмена группы задач

Отмена становится более сложной для реализации, когда мы имеем дело с потоком (stream) задач, каждая из которых должна быть порождена в среде выполнения. В настоящее время много асинхронного кода просто порождает задачи, отсоединяет их и логирует в случае ошибки:

#![allow(unused)]
fn main() {
// Пример "echo tcp server" из async-std.
use async_std::io;
use async_std::net::{TcpListener, TcpStream};
use async_std::prelude::*;
use async_std::task;

// Слушать новые TCP-подключения на порту 8080.
let listener = TcpListener::bind("127.0.0.1:8080").await?;

// Итерироваться по входящим подключениям и перемещать каждую задачу в многопоточную
// среду выполнения.
let mut incoming = listener.incoming();
while let Some(stream) = incoming.next().await {
    task::spawn(async {
        // Если произошла ошибка, записать ее в stderr.
        if let Err(err) = run(stream).await {
            eprintln!("error: {}", err);
        }
    });
}

// Основная логика нашего цикла прослушивания. Это простой эхо-сервер.
async fn run(stream: io::Result<TcpStream>) -> io::Result<()> {
    let stream = stream?;
    let (reader, writer) = &mut (&stream, &stream);
    io::copy(reader, writer).await?;
    Ok(())
}
}

Но если мы пытаемся правильно реализовать структурированную конкурентность, нам нужно гарантировать, что отмена распространяется и на эти порожденные задачи. Чтобы сделать это, нам нужно ввести новый асинхронный примитив в Rust: TaskGroup8.

Насколько мне известно, никакие среды выполнения в настоящее время не поддерживают это из коробки, но крейты для этого существуют на crates.io. Одним из примеров такого крейта является task-group, созданный группой Fastly WASM. Функционально он позволяет создавать группу задач, которые действуют как единое целое. Если задача завершается ошибкой или паникует, все другие задачи отменяются. И когда TaskManager отбрасывается, все текущие выполняющиеся задачи отменяются.

Группировка задач (включая области видимости задач) — это тема, которая заслуживает отдельного поста в блоге. Но если вы хотите применить распространение отмены к потоку элементов, по крайней мере, теперь вы знаете, что это примитив, который позволяет этому работать.

Устойчивость к останову (halt-safety)

До сих пор в этом посте мы довольно много говорили об отмене. Теперь, когда мы знаем, что существование .await означает, что наша функция может завершиться, естественно спросить: «Если наши машины состояний Future могут быть отменены в любом состоянии, как мы гарантируем, что они функционируют правильно?»

Когда мы используем future, созданные через async/.await, отмена в точках .await функционально будет действовать не иначе, чем досрочные возвраты через оператор try (?). В обоих случаях выполнение функции приостанавливается, запускаются деструкторы и очищаются ресурсы. Возьмем следующий пример:

#![allow(unused)]
fn main() {
// Независимо от того, где в функции мы останавливаем выполнение, деструкторы будут
// запущены и ресурсы будут очищены.
async fn do_something(path: PathBuf) -> io::Result<Output> {
                                        // 1. future не гарантирует прогресс после создания
    let file = fs::open(&path).await?;  // 2. `.await` и 3. `?` могут вызвать останов функции
    let res = parse(file).await;        // 4. `.await` может вызвать останов функции
    res                                 // 5. выполнение завершено, вернуть значение
}
}

У do_something есть 5 различных точек, где она может остановить выполнение и будут запущены деструкторы. Неважно, вызывает ли .await отмену, ? возвращает ошибку или наша функция завершается как ожидалось. Нашим деструкторам не нужно заботиться об этом, им нужно только обеспечить освобождение любых ресурсов, которые они удерживают9.

Вещи немного меняются, когда мы говорим о Future, созданных с использованием ручных машин состояний Poll. Внутри ручной машины состояний мы не вызываем .await; вместо этого мы делаем прогресс, вручную вызывая Future::poll или Stream::poll_next. Аналогично, при работе с async/.await мы хотим гарантировать, что деструкторы запустятся, если любая из poll_*, ? или return сработает. В отличие от future async/.await, устойчивость к останову нашей функции не предоставляется нам автоматически. Вместо этого вручную созданные future должны реализовать устойчивость, на которую полагаются future async/.await.

Мне нравится думать о графе вызовов future как о дереве. В дереве есть конечные узлы (leaf nodes), которые должны гарантировать правильную обработку отмены. И есть узлы-ветви (branch nodes) в дереве, которые все создаются через async/.await и полагаются на то, что конечные узлы правильно реализуют очистку ресурсов.

Вроде того, как в неасинхронном Rust нам не нужно really думать об освобождении файловых дескрипторов или памяти, поскольку мы полагаемся, что это просто работает из коробки. Единственный раз, когда нам действительно нужно думать о том, как освободить ресурсы, — это когда мы сами реализуем примитивы, такие как TcpStream или Vec.

Должны ли задачи быть отсоединяемыми?

Мы довольно много говорили о важности распространения отмены для задач, возможно, до такой степени, что вы теперь задаетесь вопросом, должны ли задачи вообще быть отсоединяемыми. К сожалению, деструкторы в Rust не гарантированно запускаются, поэтому мы не можем actually помешать людям передавать JoinHandle в mem::forget, чтобы отсоединить свои задачи.

Это не означает, что мы должны делать отсоединение задач легким. Например: MutexGuard тоже можно передать в mem::forget, но мы не предоставляем метод для этого непосредственно на MutexGuard, потому что это приводит к тому, что блокировка удерживается вечно. В зависимости от того, как мы относимся к висящим задачам, мы можем захотеть использовать дизайн API, чтобы обескураживать людей от перехода в нежелательные состояния.

Асинхронный трейт, который нельзя отменить?

В рабочей группе по основам асинхронности (Async Foundations WG) шла речь о потенциальном создании нового асинхронного трейта, который создавал бы future, гарантирующий, что он должен быть выполнен до завершения (т.е. «не может быть отменен»). Наш существующий core::future::Future трейт унаследовал бы от этого трейта и добавил бы дополнительную гарантию, что он на самом деле может быть отменен.

Я видел две основные мотивации для движения в этом направлении:

  1. Совместимость FFI с асинхронной системой C++, где невыполнение «C++ Future» до завершения является неопределенным поведением.
  2. Это упростило бы написание асинхронного Rust для не-экспертов, сделав «отмену» чем-то, о чем не нужно узнавать заранее.

Это не тот пост, чтобы углубляться в первый пункт, но второй пункт, безусловно, интересен для нас здесь. Хотя я согласен, что это действительно может создавать препятствие для людей, пишущих асинхронный код сегодня, я не верю, что так будет в будущем, из-за работы, которую мы делаем, чтобы устранить необходимость ручного создания машин состояний Poll. В настоящее время ведется работа, например, по изменению сигнатуры Stream с fn poll_next на async fn next. Аналогично рабочая группа работает над добавлением асинхронных трейтов и асинхронных замыканий, все с целью сократить необходимость создавать future вручную.

Как мы наблюдали, обработка отмены наиболее сложна при создании future вручную. Именно там нам нужно создать устойчивость к останову (через отмену и ошибки alike), от которой зависит остальная часть графа вызовов future. Если мы сделаем так, чтобы вручную созданные future требовались только для реализации примитивов, от которых полагается остальная экосистема, то проблема уже решена.

Чтобы завершить это: я думаю, что общий подход Rust к обеспечению {безопасности ввода-вывода, безопасности памяти, устойчивости к останову} должен быть многогранным. Мы должны сделать так, чтобы для подавляющего большинства операций операторам не нужно было обращаться к мощным инструментам Rust. Но когда мощные инструменты определенно являются правильным путем, мы должны добавить все проверки и подсказки, которые можем, чтобы обеспечить их безопасную работу10.

Промежуточное сопоставление при отмене?

Во время редактирования этого поста я услышал о возможной третьей мотивации для потенциального наличия альтернативного трейта future: желание иметь возможность сопоставлять с отменой. Идея в том, что мы можем заменить ? на match, чтобы обрабатывать ошибки с помощью альтернативной логики, но мы не можем сделать это в точке отмены для .await.

#![allow(unused)]
fn main() {
// Использование оператора Try для повторного выброса ошибки.
let string = fs::read_to_string("my-file.txt").await?;

/// Вручную повторно выбрасываем ошибку.
let string = match fs::read_to_string("my-file.txt").await {
    Err(err) => return Err(err),
    Ok(s) => s,
};

// Мы можем заменить `?` на `match`, но мы не можем заменить `.await`
// ни на что другое.
}

Идея в том, что в сочетании с трейтом future, который нельзя отменить, отмена вместо этого выполнялась бы путем отправки сигнала нижележащему ресурсу ввода-вывода, который мы ожидаем, который, в свою очередь, остановил бы то, что он делает, и вернул бы ошибку io::ErrorKind::Interrupted, которую промежуточные future должны вручную поднимать вверх до места, где вызывающая сторона может сопоставить ее.

Этот аргумент может звучать привлекательно: сейчас мы действительно не можем разобрать .await в оператор match так, как мы можем с ?. Так, может быть, этот механизм был бы полезен?

Чтобы немного углубиться; предположим, у нас есть следующее дерево future:

main
  -> foo (таймаут через 1 сек)
    -> bar (таймаут через 3 сек)
      -> task::sleep (ожидание 2 сек)

Если мы хотим обработать таймаут foo (таймаут через 1 сек) в нашей функции main, мы могли бы просто сопоставить с ним в любом случае:

#![allow(unused)]
fn main() {
// Текущий метод обработки асинхронной отмены.
let dur = Duration::from_secs(1);
let string = match fs::read_to_string("my-file.txt").timeout(dur).await {
    Err(err) => panic!("timed out!"),
    Ok(res) => res?,
};

// Отмена через сигнал среды выполнения.
let dur = Duration::from_secs(1);
let string = match fs::read_to_string("my-file.txt").timeout(dur).await {
    Err(ErrorKind::Interrupted) => panic!("timed out!"),
    Err(err) = return err,
    Ok(s) => s,
};
}

На практике оба подхода имеют incredibly похожую семантику на стороне вызова. Основное различие в том, что в подходе с сигналом среды выполнения мы можем никогда не попасть на путь ошибки. Фактически, больше нет гарантии, что внутренние future распространят отмену. Они вместо этого могут выбрать игнорирование отмены и (ошибочно) попытаться повторить попытку. Повторные попытки всегда должны планироваться вместе с таймаутами; таким образом, если мы один раз превысим таймаут, мы можем повторить попытку снова. Если эти два развязаны, повторные попытки после первой не будут иметь связанного таймаута и рискуют зависнуть навсегда. Это нежелательно, и мы должны отговаривать людей от этого. И наша существующая семантика уже достигает этого.

Могут быть причины, по которым мы хотим обнаружить отмену на промежуточных future, например, для предоставления внутреннего логирования. Но это уже возможно путем комбинирования отслеживания статуса завершения с охранниками удаления (Drop guards).

Это дополнительно осложняется тем, что мы теперь перегружаем io::ErrorKind::Interrupted для передачи отмен как от операционной системы, так и для запуска в пользовательском пространстве. Ошибки, вызванные операционной системой, должны быть повторены на месте. Но ошибки, вызванные пользователями, всегда должны всплывать. Мы больше не можем их различить.

Другая проблема заключается в том, что для единообразной поддержки отмены future теперь должны нести io::Result в своем типе возвращаемого значения. Нам нужно будет переписать примитивы ввода-вывода, такие как task::sleep, чтобы они были fallible только для этой цели. Это не худшее; но это напоминает дни Futures 0.1, когда future всегда должны были быть fallible.

В целом, я думаю, что оба подхода roughly сопоставимы. Но поскольку вариант отмены-через-сигналы позволяет игнорировать отмены (случайно или иначе), он открывает набор подводных камней для пользователей асинхронности, которых нет в нашей текущей системе11.

Блоки defer?

Механизмы защиты от устойчивости к останову похожи на те, что для устойчивости к раскрутке (unwind safety): создать охранник удаления (drop guard), чтобы гарантировать запуск наших деструкторов. Ручное написание этого может стать довольно многословным.

Языки, такие как Swift и Go, предоставляют решение на уровне языка для этого в форме ключевого слова defer. Это effectively позволяет пользователям языка написать встроенный охранник удаления для создания анонимного деструктора. «The defer keyword in Swift: try/finally done right» — это тщательный обзор того, как это работает в Swift. Но для иллюстративных целей вот пример:

func writeLog() {
    let file = openFile()
    defer { closeFile(file) }

    let hardwareStatus = fetchHardwareStatus()
    guard hardwareStatus != "disaster" else { return /* блок defer запускается здесь */ }
    file.write(hardwareStatus)

    // блок defer запускается здесь
}

Крейт scopeguard Rust предоставляет коллекцию макросов defer. Но их было бы недостаточно для примера, который мы показали ранее, поскольку мы хотим сохранять доступ к данным, продолжая их использовать, а scopeguard::defer нам этого не позволяет. Это потому, что мы хотим, чтобы охранники удаления не забирали владение значением до тех пор, пока деструктор не запустится. Я считаю, что это также называют поздним связыванием (late binding). И лучший способ, которым мы могли бы достичь этого, — это введение функции языка.

Чтобы быть ясным: я не обязательно выступаю за введение defer в Rust. Это добавило бы форму нелинейного управления потоком в язык, которая могла бы смутить многих людей12. Но если мы считаем {устойчивость к останову, устойчивость к раскрутке} достаточно важными, чтобы предоставить пользователям лучшие инструменты; тогда defer кажется кандидатом, которого мы, возможно, захотим изучить более пристально.

обновление (2021-11-14): как правильно указали люди, в то время как scopeguard::defer! не предоставляет доступ к захваченным значениям перед удалением, scopeguard::ScopeGuard делает это через трейты Deref / DerefMut. Гибкость возможности удалить реализацию Drop охранника путем преобразования охранника в его внутреннее значение представляет собой убедительный аргумент в пользу того, что решение встроенных реализаций Drop было бы лучше решено с помощью библиотечного дополнения, чем через элемент языка.

Отмена и io_uring

Этот раздел был добавлен 2021-11-17, после первоначальной публикации поста.

Патрик Уолтон задал следующий вопрос в /r/rust:

Одна мотивация для future завершения (completion futures), которая либо не упоминалась, либо является неожиданным побочным эффектом пункта №1 (совместимость с C++), заключается в том, что асинхронный код в C/C++ может принимать незабирающие владение ссылки на буферы. Например, в Linux, если вы выдаете асинхронный вызов read() с помощью io_uring, и вас later отменяют, вы должны каким-то образом сообщить ядру, что ему нужно не трогать буфер после того, как Rust освободит его. Есть способы сделать это, например, передав ядру владение буферами с помощью IORING_REGISTER_BUFFERS, но наличие у ядра владения буферами ввода-вывода может сделать вещи неудобными. (Люди из асинхронного C++ показывали мне шаблоны, которые потребовали бы копий в этом случае.) Вы давали thought о лучших практиках здесь? Это сложное решение, поскольку единственное реальное решение, которое я могу придумать, включает в себя сделать poll() небезопасным, что неприятно (NB: не обязательно ошибочно).

Saoirse написала excellent обзор того, как семейство kernel API Linux на основе завершения io_uring взаимодействует с отменой в Rust. Весь пост стоит прочитать, так как он охватывает большую часть нюансов и соображений безопасности, связанных с использованием io_uring с Rust. Но он также прямо отвечает на вопрос Патрика:

Так что я думаю, это решение, которое мы все должны принять и двигаться вперед: io_uring управляет буферами, самые быстрые интерфейсы на io_uring — это интерфейсы с буферизацией, интерфейсы без буферизации делают дополнительную копию. Мы можем перестать увязать в попытках заставить язык сделать что-то невозможное.

Заключение

В этом посте мы рассмотрели, как работает отмена для future и задач. Мы рассмотрели, как должно работать распространение отмены и как мы можем backport его в существующие среды выполнения. И, наконец, мы рассмотрели, как рассуждать о структурированной конкурентности, как распространение отмены может быть применено к группам задач и как асинхронная отмена может взаимодействовать с асинхронными дизайнами, которые в настоящее время разрабатываются.

Надеюсь, это было информативно! Вы могли заметить, что заголовок этого поста имеет «1» в конце. Я планирую опубликовать продолжение этого поста, охватывающее пространство дизайна запуска отмены на расстоянии и, возможно, еще один о группировке задач. working on different posts on async concurrency, so expect more on that soon.

Я открыл ветку обсуждения на Internals. И если вам понравился этот пост и вы хотите видеть, что бы я ни придумывал, в реальном времени, вы можете подписаться на меня @yoshuawuyts.

Обновление (2021-11-14): Firstyear написал продолжение этого поста о транзакционных операциях в Rust и о том, как они взаимодействуют с (асинхронной) отменой и устойчивостью к останову. Если вас интересуют транзакции и откаты, рекомендую прочитать!

Спасибо: Eric Holk, Ryan Levick, Irina Shestak и Francesco Cogno за помощь в рецензировании этого поста перед публикацией. И спасибо Niko Matsakis за то, что провел меня через некоторые альтернативные future (лол) трейта future.

Примечания


  1. Возможно, «Stream» / «AsyncIterator» является третьим асинхронным примитивом, но все, что мы говорим о типе Future, применимо и к Stream, поэтому мы считаем их одинаковыми в этом посте.

  2. «управляемый» используется на протяжении всего этого поста как: «планируется в среде выполнения». Это обычно сопровождается требованием, чтобы future были 'static (future не содержит никаких заимствований), и при планировании в многопоточной среде выполнения часто, но не всегда, требует, чтобы future были Send (future потокобезопасен).

  3. Вы можете думать о Задачах как о чем-то вроде облегченных потоков, управляемых вашей программой, а не вашей операционной системой.

  4. Аналогом задач Rust в JavaScript является Promise. Он начинает выполняться в момент его создания, а не в момент его ожидания. Насколько я понимаю, Task<T> в C# работает much таким же образом.

  5. Вероятно, потому что я был соавтором проекта.

  6. Способ, которым поток может быть отменен извне, — это передать канал в поток и отменить, когда сообщение получено. В отличие от асинхронного Rust, потоки не имеют точек .await, которые действуют как естественные точки остановки. Поэтому вместо этого потоки должны подключаться к отмене, вручную прослушивая сигналы.

  7. Подробнее о том, можем ли мы actually обеспечить это later в этом посте.

  8. Эта терминология заимствована из Swift, но похожа-ish на то, как работает crossbeam-scope (n потоков, управляемых центральной точкой). Однако, в отличие от crossbeam-scope, название меньше касается того, как протекают времена жизни, и больше того, как вы можете рассуждать о том, как это должно использоваться.

  9. «Очищать ресурсы независимо от того, что вызвало останов функции» — это то, что я называю «устойчивостью к останову» (halt-safety). Я не в восторге от термина, но мне нужно было найти способ дать имя этой группе механизмов. Я надеюсь, термин достаточно ясен.

  10. Например, если мне когда-либо понадобится создать что-то с использованием MaybeUninit, я хочу, чтобы компилятор напоминал мне создать охранник удаления при сохранении его живым после чего-то, что может panic. Возможно, someday (:

  11. Это, вероятно, заслуживает отдельного поста в блоге. Но у меня уже так много постов в работе, что я решил добавить это сюда.

  12. Люди говорили мне это из использования других языков. Я никогда actually не использовал defer, поэтому не могу комментировать, на что это похоже. Но я много занимался нелинейным управлением потоком, написанием JavaScript с большим количеством колбэков, и это действительно требует некоторого привыкания.