Comments

Спасибо за статью, интересно.


Другими словами, экзекьютор как минимум один раз вызывает poll на каждой асинхронной операции, а дальше уже всё зависит от возвращаемого значения poll и контекста. Такой дизайн позволяет не тратить время ЦПУ на бесполезный опрос асинхронных операций, ведь, например, настоящий асинхронный сетевой сокет (а не наша пародия) будет готов принять/отправить данные лишь после того, как очередь событий операционной системы (epoll, kqueue, ...) возвратит соответствующее событие.

Не смог разобраться, как именно такой дизайн позволяет эффективно работать с асинхронными операциями. Правильно ли я понимаю, что Poll должен вызываться до тех пор, пока Future не завершит работу, т.е. пока очередной выов не вернет Poll::Ready? Как в таком случае осуществляется работа с асинхронными IO-bound операциями (epoll итп)?

Эффективность в том, что экзекьютор не вызывает poll, когда не нужно. Например, после того, как нам epoll вернул EPOLLOUT, можно попытаться записать данные в сокет (цикл событий уведомит об этом экзекьютора), а иначе poll у сокета вызывать бессмысленно, т.к. всё равно получим что-то вроде EAGAIN.

Вот этот момент не очень понятен. Зачем нужен Waker, если футура уже вернула Pending? По-моему, это дублирующие функции.

Конечно, если Waker вызывается из отдельного потока — он может узнать, что произошло некое событие, которого ожидает футура и уведомить Executor-а о том, что необходимо вызвать poll.
Но тогда получается параллельная основой футуре еще одна, но только завязанная на Waker.

Не объясните? Официальная документация тоже ясности не внесла — там рассматривается пример с таймером, который как раз дёргает wake в отдельном потоке.

Вот в псевдокоде:


loop {
    let events = request_events_from_os();

    for event in events {
        get_future_cx(event.future_id).waker().wake_by_ref();

        // К этому моменту экзекьютор должен вызвать футуру с event.future_id.
    }
}

Контексты футурам назначает сама среда асинхронного исполнения, они хранятся где-то в коллекции под капотом. Совсем не обязательно, чтобы контекст дёргали в футурах, его может вообще любой код трогать, который имеет к нему доступ (в псевдокоде выше с контекстами обращается цикл событий).


Насчёт Pending. После того, как футура вернула Pending, она дропается, вместе с её контекстом. Контекст нужен во время жизненного цикла футуры, чтобы экзекьютор не делал


loop {
    if Poll::Ready(x) = fut.poll() {
        break;
    }
}

А опрашивал только когда придёт событие записи/считывания от ОС (см. первый блок кода с циклом событий), если мы про асинхронные сокеты говорим.

Да, но откуда этот код узнаёт, что нужно пнуть соответствующий waker, чтобы тот, в свою очередь, poll-нул (через exececutor, конечно) соответствующую футуру, если, теоретически это всё может исполняться в одном потоке ОС? И футура и exececutor и этот код, который дёргает waker. В псевдокоде, который Вы привели как раз предполагается, что этот цикл и executor исполняются в разных потоках.

И почему дропается при Pending? Судя по документации, ей можно назначать обработчики (then, map и т.д.) даже после того, как она завершится!

Ага, опечатался, она при Poll::Ready дропается.


Да, но откуда этот код узнаёт, что нужно пнуть соответствующий waker, чтобы тот, в свою очередь, poll-нул (через exececutor, конечно) соответствующую футуру, если, теоретически это всё может исполняться в одном потоке ОС?

Внимание на .wake_by_ref(). Откуда он берётся? Стандартная библиотека экспортирует метод RawWaker::new, туда вторым параметром мы передаём RawWakerVTable, содержащую функцию для wake_by_ref (и ещё 3 других). В своей реализации wake_by_ref мы можем просто поллить футуру, т.к. поток ОС всего один.

Т.е. метод wake/wake_by_ref, по сути, признак того, что нужно пнуть poll футуры?
Зачем его тогда настолько от неё отделили…
Спасибо, стало более-менее понятно. Хотя документацию ещё поизучать надо.

Спасибо большое за статью!
Знаете ли что-нибудь про про smol рантайм. Вроде как в async-std он теперь используется. Я читал что он простой и быстрый (быстрее токио). Пытался разобраться в сорцах, но то ли знаний не хватило, то ли воли :) Пока не разобрался. Может быть сможете пролить свет?
Чем смол так хорош? За счёт чего автору удалось уложить такую сложную штуку как экзекьютор асинхронных вычислений так компактно (мало кода)? Какой рантайм выбирать в каких случаях (при написании нового проекта)?




Я правильно понимаю что в ситуации


let x = send_http_req(url1).await;
let y = send_http_req(url2).await;
let z = send_http_req(url3).await;

запросы улетят "одновременно", а не поочерёдно после принятия ответа от предыдущего?

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

Да, всё так. Вообще одна футура — это последовательность .await, но вместе (имеются ввиду таски, зелёные потоки), когда исполняются на экзекьюторе, создают асинхронность. Можно ещё их через futures::join! (+ FuturesUnordered и т.д.) поллить, тогда кооперативность создаётся в одной футуре.

А насколько «дорогие» await`и в Расте?

В шарпе, например, затраты есть. Хотелось бы поменьше.
Если объект AsyncFuture переместится в памяти
А как в принципе такое возможно в Расте? Указатели контролирует ОВ, GC нет, realloc тоже нет. Кто может перемещать объекты в памяти, если на них есть владеющий указатель(ссылка) ???
Я не представляю возможности.

Сырой указатель компилятору ничего не говорит об указываемом объекте, поэтому компилятор может вполне переместить (move) AsyncFuture, в результате чего может скопироваться массив (если move вызвал memcpy), а указатель инвалидируется.

Но ведь &Self это не сырой указатель — я про пример под заголовком WriteFuture. И не должен соответственно, перемещаться?

Или, как я понимаю ответ Tyranron, в вызываемой (асинхронной) функции из валидной ссылки Self, используя unsafe, получается сырой указатель и контроль пропадает?

В принципе логично — ОВ распространить еще и на потоки м.б.нерешаемой задачей

Точнее, там не &Self, а Pin<&mut Self>. Именно Pin запрещает перемещение в памяти нашей WriteFuture.


Или, как я понимаю ответ Tyranron, в вызываемой (асинхронной) функции из валидной ссылки Self, используя unsafe, получается сырой указатель и контроль пропадает?

Можно из Pin достать и управлять как вздумается данными, но это будет unsafe.

Safe Rust действительно в данный момент не даёт средств для выражения self-referential структур c помощью ссылок, потому такое в нём невозможно. Но когда задача требует действительно использования self-referential структур, то приходится расчехлять unsafe и использовать сырые указатели, что и позволяет делать вещи вида:


struct AsyncFuture {
    x: [u8; 128],
    read_into_buf_fut: ReadIntoBuf<'self>,  // ссылается на поле `x`
}

А работая с подобной структурой, даже уже в safe Rust, очень легко получить UB, ведь при любом перемещении AsyncFuture (мы её только что создали, мы ею владеем, и мы решили её тут же переместить) мы получим dangling pointer в read_into_buf_fut, ибо само по себе перемещение не обновит указатель, указывающий на x, а borrow checker не отслеживает сырые указатели.


Соответственно, за'Pin'ив указатель read_into_buf_fut, мы получаем гарантию на уровне системы типов, что данные позади указателя не будут перемещены (либо что их перемещение не нарушит инвариантов типа, см. Unpin). И если у нас код где-то мувает запиненное значение, то он просто не скомпилируется.

И если у нас код где-то мувает запиненное значение, то он просто не скомпилируется.

К сожалению, это не так. Запиненый указатель на значение не реализующее Unpin обязывает программиста соблюдать контракт Pin. Но компилятор выполнение этого контракта не проверяет. Демонстрация на playground

Поэтому пиннинг обычно скрывается под интерфейсами, обеспечивающими эти контракты, такими как async/await или futures::pin_mut!().

Но из запинненой переменной перемещать уже нельзя, так? Поэтому скрывать нужно только создание запинненой копии ссылки, которая ее не съедает?

mem::replace(&mut *boxed_value, new_value), например, перемещает объект из Box'а на стек.

mem::replace прекрасен, но он unsafe…
Кажется, что Pin/Unpin нужны из-за того, что компилятор локально не может определить, что нечто не перемещается нигде и никогда. И поэтому надо различать нечты, которые сломаются при перемещении в памяти и нечты, которые от места размещения в памяти не зависят. Или я фантазирую?

mem::replace() — safe, как раз потому, что в safe коде невозможно сконструировать структуру содержащую ссылку на своё содержимое.


Кажется, что Pin/Unpin нужны из-за того, что компилятор локально не может определить, что нечто не перемещается нигде и никогда.

Правильно. Для этого нужен статический анализ (который алгоритмически неразрешим). Поэтому всё, что можно безопасно перемещать (типы не содержащие указателей на своё содержимое), пометили как Unpin. И в сейф коде можно пинить только указатели на Unpin типы.

Only those users with full accounts are able to leave comments. Log in, please.