Более детальный взгляд на типажи для асинхронного программирования
На протяжении всей главы мы использовали типажи Future, Pin, Unpin, Stream и StreamExt различными способами. Однако до сих пор мы избегали углубления в детали их работы или того, как они связаны между собой, что в большинстве случаев достаточно для повседневной работы с Rust. Иногда, однако, возникают ситуации, когда требуется понять некоторые из этих детальнее. В этом разделе мы рассмотрим их ровно настолько, насколько это необходимо для таких сценариев, оставляя действительно глубокое погружение для другой документации.
Типаж Future
Давайте начнём с более пристального взгляда на то, как работает типаж Future. Вот как Rust определяет его:
#![allow(unused)] fn main() { use std::pin::Pin; use std::task::{Context, Poll}; pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Это определение типажа включает множество новых типов и некоторый синтаксис, который мы раньше не видели, поэтому давайте разберём его по частям.
Во-первых, ассоциированный тип Output у Future указывает, к чему разрешается future. Это аналогично ассоциированному типу Item для типажа Iterator.
Во-вторых, Future также имеет метод poll, который принимает специальную ссылку Pin для своего параметра self и изменяемую ссылку на тип Context, а возвращает Poll<Self::Output>. Мы немного поговорим о Pin и Context позже. Пока сосредоточимся на том, что возвращает метод, на типе Poll:
#![allow(unused)] fn main() { enum Poll<T> { Ready(T), Pending, } }
Этот тип Poll похож на Option. У него есть один вариант со значением, Ready(T), и один без значения, Pending. Однако Poll означает нечто совершенно иное, чем Option! Вариант Pending указывает, что future всё ещё выполняет работу, поэтому вызывающей стороне потребуется проверить его снова позже. Вариант Ready указывает, что future завершил свою работу и значение T доступно.
Примечание: Для большинства futures вызывающая сторона не должна вызывать
pollснова после того, как future вернулReady. Многие futures вызовут панику, если их опросить снова после того, как они стали готовыми. Futures, которые безопасно опрашивать повторно, явно об этом сообщат в своей документации. Это похоже на поведениеIterator::next.
Когда вы видите код, использующий await, Rust компилирует его под капотом в код, который вызывает poll. Если вы посмотрите на Листинг 17-4, где мы выводили заголовок страницы для одного URL после его разрешения, Rust компилирует это во что-то вроде (хотя и не точно) этого:
match page_title(url).poll() {
Ready(page_title) => match page_title {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
Pending => {
// Но что здесь должно быть?
}
}
Что нам делать, когда future всё ещё Pending? Нам нужен способ попробовать снова, и снова, и снова, пока future наконец не станет готовым. Другими словами, нам нужен цикл:
let mut page_title_fut = page_title(url);
loop {
match page_title_fut.poll() {
Ready(value) => match page_title {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
Pending => {
// продолжить
}
}
}
Если бы Rust скомпилировал это в точно такой код, каждый await был бы блокирующим — прямо противоположным тому, к чему мы стремились! Вместо этого Rust гарантирует, что цикл может передать управление чему-то, что может приостановить работу над этим future, чтобы поработать над другими futures, а затем проверить этот снова позже. Как мы видели, этим “чем-то” является среда выполнения async, и эта работа по планированию и координации — одна из её главных задач.
Ранее в главе мы описали ожидание вызова rx.recv. Вызов recv возвращает future, и ожидание этого future опрашивает его. Мы отметили, что среда выполнения приостановит future до тех пор, пока он не будет готов с либо Some(message), либо None при закрытии канала. С нашим более глубоким пониманием типажа Future и, в частности, Future::poll, мы можем увидеть, как это работает. Среда выполнения знает, что future не готов, когда он возвращает Poll::Pending. И наоборот, среда выполнения знает, что future готов и продвигает его, когда poll возвращает Poll::Ready(Some(message)) или Poll::Ready(None).
Точные детали того, как среда выполнения это делает, выходят за рамки этой книги, но ключевое — понять базовую механику futures: среда выполнения опрашивает каждый future, за который она ответственна, возвращая future в спящий режим, когда он ещё не готов.
Типажи Pin и Unpin
Когда мы представили идею закрепления (pinning) в Листинге 17-16, мы столкнулись с очень запутанным сообщением об ошибке. Вот соответствующая часть его снова:
error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
--> src/main.rs:48:33
|
48 | trpl::join_all(futures).await;
| ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
= note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
--> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
|
27 | pub struct JoinAll<F>
| ------- required by a bound in this struct
28 | where
29 | F: Future,
| ^^^^^^ required by this bound in `JoinAll`
Это сообщение об ошибке говорит нам не только о том, что нужно закрепить значения, но и о том, почему закрепление требуется. Функция trpl::join_all возвращает структуру под названием JoinAll. Эта структура обобщена (generic) над типом F, который ограничен реализацией типажа Future. Прямое ожидание future с помощью await неявно закрепляет future. Именно поэтому нам не нужно использовать pin! везде, где мы хотим ожидать futures.
Однако здесь мы не ожидаем future напрямую. Вместо этого мы создаём новый future, JoinAll, передавая коллекцию futures функции join_all. Сигнатура для join_all требует, чтобы типы элементов в коллекции все реализовывали типаж Future, а Box<T> реализует Future только если обёрнутый им T является future, реализующим типаж Unpin.
Это многое стоит усвоить! Чтобы действительно это понять, давайте углубимся немного дальше в то, как на самом деле работает типаж Future, в частности вокруг закрепления (pinning).
Посмотрите снова на определение типажа Future:
#![allow(unused)] fn main() { use std::pin::Pin; use std::task::{Context, Poll}; pub trait Future { type Output; // Required method fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Параметр cx и его тип Context — это ключ к тому, как среда выполнения на самом деле знает, когда проверять любой данный future, оставаясь при этом ленивой. Снова, детали того, как это работает, выходят за рамки этой главы, и вам, как правило, нужно думать об этом только при написании собственной реализации Future. Мы сосредоточимся вместо этого на типе для self, так как это первый раз, когда мы видим метод, где self имеет аннотацию типа. Аннотация типа для self работает как аннотации типа для других параметров функции, но с двумя ключевыми отличиями:
- Она говорит Rust, каким типом должен быть
selfдля вызова метода. - Она не может быть просто любым типом. Она ограничена типом, для которого реализован метод, ссылкой или умным указателем на этот тип, или обёрткой
Pinвокруг ссылки на этот тип.
Мы увидим больше об этом синтаксисе в Главе 18. Пока достаточно знать, что если мы хотим опросить future, чтобы проверить, является ли он Pending или Ready(Output), нам нужна изменяемая ссылка, обёрнутая в Pin, на тип.
Pin — это обёртка для указательноподобных типов, таких как &, &mut, Box и Rc. (Технически, Pin работает с типами, которые реализуют типажи Deref или DerefMut, но это практически эквивалентно работе только с указателями.) Pin не является указателем сам по себе и не имеет собственного поведения, как Rc и Arc со счётчиком ссылок; это чисто инструмент, который компилятор может использовать для принуждения ограничений на использование указателей.
Вспоминая, что await реализован на основе вызовов poll, начинаешь объяснять сообщение об ошибке, которое мы видели ранее, но оно было в терминах Unpin, а не Pin. Так как же именно Pin связан с Unpin, и зачем Future нужен self в типе Pin для вызова poll?
Помните из предыдущей части главы, что серия точек ожидания (await points) в future компилируется в конечный автомат (state machine), и компилятор гарантирует, что этот конечный автомат следует всем обычным правилам Rust относительно безопасности, включая заимствование и владение. Чтобы это работало, Rust смотрит, какие данные нужны между одной точкой ожидания и либо следующей точкой ожидания, либо концом async-блока. Затем он создаёт соответствующий вариант в скомпилированном конечном автомате. Каждый вариант получает доступ, который ему нужен, к данным, которые будут использоваться в этом разделе исходного кода, либо путём взятия владения этими данными, либо путём получения изменяемой или неизменяемой ссылки на них.
Пока что всё хорошо: если мы что-то напутаем с владением или ссылками в данном async-блоке, проверка заимствований (borrow checker) сообщит нам об этом. Когда мы хотим переместить future, соответствующий этому блоку — например, переместить его в Vec для передачи в join_all — всё становится сложнее.
Когда мы перемещаем future — будь то помещая его в структуру данных для использования в качестве итератора с join_all или возвращая его из функции — это на самом деле означает перемещение конечного автомата, который Rust создаёт для нас. И в отличие от большинства других типов в Rust, futures, которые Rust создаёт для async-блоков, могут в итоге иметь ссылки на сами себя в полях любого данного варианта, как показано на упрощённой иллюстрации на Рисунке 17-4.
Однако по умолчанию любой объект, имеющий ссылку на себя, небезопасен для перемещения, потому что ссылки всегда указывают на фактический адрес памяти того, на что они ссылаются (см. Рисунок 17-5). Если вы переместите саму структуру данных, эти внутренние ссылки будут указывать на старое местоположение. Однако это местоположение памяти теперь недействительно. Во-первых, его значение не будет обновляться при внесении изменений в структуру данных. Во-вторых — и что более важно — компьютер теперь может свободно повторно использовать эту память для других целей! Вы можете в итоге читать совершенно несвязанные данные позже.
Теоретически компилятор Rust мог бы попытаться обновлять каждую ссылку на объект всякий раз, когда он перемещается, но это могло бы добавить большое быстродействие, особенно если требуется обновление целой сети ссылок. Если бы мы вместо этого могли гарантировать, что структура данных в вопросе не перемещается в памяти, нам не пришлось бы обновлять никакие ссылки. Это именно то, что требует проверка заимствований Rust: в безопасном коде она предотвращает перемещение любого элемента с активной ссылкой на него.
Pin основывается на этом, чтобы дать нам именно ту гарантию, которая нам нужна. Когда мы закрепляем (pin) значение, обернув указатель на это значение в Pin, оно больше не может перемещаться. Таким образом, если у вас есть Pin<Box<SomeType>>, вы на самом деле закрепляете значение SomeType, а не указатель Box. Рисунок 17-6 иллюстрирует этот процесс.
На самом деле, указатель Box по-прежнему может свободно перемещаться. Помните: нас заботит гарантия того, что данные, на которые в конечном итоге ссылаются, остаются на месте. Если указатель перемещается, но данные, на которые он указывает, находятся в том же месте, как на Рисунке 17-7, нет потенциальной проблемы. Как самостоятельное упражнение, посмотрите документацию на типы, а также на модуль std::pin и попробуйте понять, как это сделать с Pin, оборачивающим Box.) Ключ в том, что сам тип с самореференцией не может перемещаться, потому что он всё ещё закреплён.
Однако большинство типов абсолютно безопасны для перемещения, даже если они оказываются за обёрткой Pin. Нам нужно думать о закреплении только когда у элементов есть внутренние ссылки. Примитивные значения, такие как числа и булевы значения, безопасны, потому что они явно не имеют никаких внутренних ссылок. То же самое касается большинства типов, с которыми вы обычно работаете в Rust. Вы можете перемещать Vec, например, не беспокоясь об этом. Учитывая только то, что мы видели до сих пор, если у вас есть Pin<Vec<String>>, вам пришлось бы делать всё через безопасные, но ограничительные API, предоставляемые Pin, даже если Vec<String> всегда безопасен для перемещения, если на него нет других ссылок. Нам нужен способ сообщить компилятору, что безопасно перемещать элементы вокруг в случаях подобных этому — и вот где появляется Unpin.
Unpin — это маркерный типаж (marker trait), подобный типажам Send и Sync, которые мы видели в Главе 16, и, таким образом, не имеет собственной функциональности. Маркерные типажи существуют только для того, чтобы сообщить компилятору, что безопасно использовать тип, реализующий данный типаж, в определённом контексте. Unpin сообщает компилятору, что данный тип не должен соблюдать никаких гарантий относительно того, можно ли безопасно перемещать рассматриваемое значение.
Подобно Send и Sync, компилятор реализует Unpin автоматически для всех типов, где может доказать, что это безопасно. Особый случай, снова подобно Send и Sync, — это когда Unpin не реализован для типа. Нотация для этого — impl !Unpin for SomeType, где SomeType — это имя типа, который действительно должен соблюдать эти гарантии, чтобы быть безопасным всякий раз, когда указатель на этот тип используется в Pin.
Другими словами, есть две вещи, которые нужно помнить о взаимосвязи между Pin и Unpin. Во-первых, Unpin — это “нормальный” случай, а !Unpin — особый случай. Во-вторых, реализует ли тип Unpin или !Unpin имеет значение только когда вы используете закреплённый указатель на этот тип, такой как Pin<&mut SomeType>.
Чтобы это конкретизировать, подумайте о String: у него есть длина и символы Юникода, которые его составляют. Мы можем обернуть String в Pin, как видно на Рисунке 17-8. Однако String автоматически реализует Unpin, как и большинство других типов в Rust.
В результате мы можем делать то, что было бы незаконно, если бы String реализовывал !Unpin вместо этого, например, заменять одну строку на другую в том же самом месте в памяти, как на Рисунке 17-9. Это не нарушает контракт Pin, потому что String не имеет внутренних ссылок, которые делают его небезопасным для перемещения! Именно поэтому он реализует Unpin, а не !Unpin.
Теперь мы знаем достаточно, чтобы понять ошибки, сообщённые для того вызова join_all из Листинга 17-17. Мы изначально пытались переместить futures, производимые async-блоками, в Vec<Box<dyn Future<Output = ()>>>, но, как мы видели, эти futures могут иметь внутренние ссылки, поэтому они не реализуют Unpin. Им нужно быть закреплёнными, и тогда мы можем передать тип Pin в Vec, будучи уверенными, что базовые данные в futures не будут перемещены.
Pin и Unpin в основном важны для построения библиотек более низкого уровня или при создании самой среды выполнения, а не для повседневного кода на Rust. Однако когда вы видите эти типажи в сообщениях об ошибках, теперь у вас будет лучшее представление о том, как исправить ваш код!
Примечание: Эта комбинация
PinиUnpinделает возможной безопасную реализацию целого класса сложных типов в Rust, которые в противном случае оказались бы сложными из-за их самореференциальности. Типы, требующиеPin, чаще всего появляются в async Rust сегодня, но время от времени вы можете увидеть их и в других контекстах.Специфика того, как работают
PinиUnpin, и правила, которые они должны соблюдать, подробно рассматриваются в API-документации дляstd::pin, так что, если вы заинтересованы в изучении большего, это отличное место для начала.Если вы хотите понять, как всё работает под капотом, ещё более подробно, см. Главы 2 и 4 книги Асинхронное программирование на Rust.
Типаж Stream
Теперь, когда у вас более глубокое понимание типажей Future, Pin и Unpin, мы можем обратить наше внимание на типаж Stream. Как вы узнали ранее в главе, потоки (streams) похожи на асинхронные итераторы. В отличие от Iterator и Future, однако, Stream не имеет определения в стандартной библиотеке на момент написания, но существует очень распространённое определение из крейта futures, используемое во всей экосистеме.
Давайте пересмотрим определения типажей Iterator и Future перед тем, как посмотреть, как типаж Stream мог бы объединить их вместе. Из Iterator у нас есть идея последовательности: его метод next предоставляет Option<Self::Item>. Из Future у нас есть идея готовности во времени: его метод poll предоставляет Poll<Self::Output>. Чтобы представить последовательность элементов, которые становятся готовыми со временем, мы определяем типаж Stream, который объединяет эти особенности:
#![allow(unused)] fn main() { use std::pin::Pin; use std::task::{Context, Poll}; trait Stream { type Item; fn poll_next( self: Pin<&mut Self>, cx: &mut Context<'_> ) -> Poll<Option<Self::Item>>; } }
Типаж Stream определяет ассоциированный тип под названием Item для типа элементов, производимых потоком. Это похоже на Iterator, где может быть от нуля до многих элементов, и в отличие от Future, где всегда есть один Output, даже если это тип единицы ().
Stream также определяет метод для получения этих элементов. Мы называем его poll_next, чтобы было ясно, что он опрашивает так же, как Future::poll, и производит последовательность элементов так же, как Iterator::next. Его тип возвращаемого значения объединяет Poll с Option. Внешний тип — Poll, потому что его нужно проверять на готовность, как future. Внутренний тип — Option, потому что он должен сигнализировать, есть ли ещё сообщения, как итератор.
Нечто очень похожее на это определение, скорее всего, станет частью стандартной библиотеки Rust. Пока же это часть набора инструментов большинства сред выполнения, так что вы можете на это положиться, и всё, что мы рассмотрим дальше, должно в целом применяться!
В примере, который мы видели в разделе о потоках, однако, мы не использовали poll_next или Stream, а вместо этого использовали next и StreamExt. Мы могли работать непосредственно с API poll_next, вручную написав свои конечные автоматы Stream, конечно, точно так же, как мы могли работать с futures напрямую через их метод poll. Однако использование await гораздо приятнее, и типаж StreamExt предоставляет метод next, чтобы мы могли сделать именно это:
#![allow(unused)] fn main() { use std::pin::Pin; use std::task::{Context, Poll}; trait Stream { type Item; fn poll_next( self: Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll<Option<Self::Item>>; } trait StreamExt: Stream { async fn next(&mut self) -> Option<Self::Item> where Self: Unpin; // other methods... } }
Примечание: Фактическое определение, которое мы использовали ранее в главе, выглядит немного иначе, потому что оно поддерживает версии Rust, которые ещё не поддерживали использование async-функций в типажах. В результате оно выглядит так:
fn next(&mut self) -> Next<'_, Self> where Self: Unpin;Этот тип
Next— этоstruct, который реализуетFutureи позволяет нам назвать время жизни ссылки наselfс помощьюNext<'_, Self>, чтобыawaitмог работать с этим методом.
Типаж StreamExt — это также домен для всех интересных методов, доступных для использования с потоками. StreamExt автоматически реализуется для каждого типа, который реализует Stream, но эти типажи определены отдельно, чтобы позволить сообществу итерировать удобные API без влияния на фундаментальный типаж.
В версии StreamExt, используемой в крейте trpl, типаж не только определяет метод next, но и предоставляет реализацию по умолчанию для next, которая правильно обрабатывает детали вызова Stream::poll_next. Это означает, что даже когда вам нужно написать свой собственный тип данных для потока, вам только нужно реализовать Stream, и тогда любой, кто использует ваш тип данных, сможет автоматически использовать StreamExt и его методы с ним.
На этом мы закончим охват деталей низкого уровня по этим типажам. Чтобы завершить, давайте рассмотрим, как futures (включая потоки), задачи (tasks) и потоки (threads) все связаны вместе!