Более детальный взгляд на типажи для Async
На протяжении всей главы мы использовали типажи Future, Stream и StreamExt различными способами. Однако до сих пор мы избегали слишком глубокого погружения в детали их работы или того, как они сочетаются друг с другом, и в большинстве случаев этого достаточно для вашей повседневной работы с Rust. Но иногда вы столкнетесь с ситуациями, когда вам потребуется понять немного больше деталей об этих типажах, а также о типе Pin и типаже Unpin. В этом разделе мы углубимся ровно настолько, чтобы помочь в таких сценариях, оставив действительно глубокое погружение для другой документации.
Типаж 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 указывает, во что разрешается (resolves) фьючерс. Это аналогично ассоциированному типу Item для типажа Iterator. Во-вторых, у Future есть метод poll, который принимает специальную ссылку Pin для своего параметра self и изменяемую ссылку на тип Context, и возвращает Poll<Self::Output>. Мы поговорим больше о Pin и Context чуть позже. А сейчас давайте сосредоточимся на том, что возвращает метод, — на типе Poll:
#![allow(unused)] fn main() { pub enum Poll<T> { Ready(T), Pending, } }
Этот тип Poll похож на Option. У него есть один вариант со значением, Ready(T), и один без значения, Pending. Однако Poll означает нечто совершенно иное, чем Option! Вариант Pending указывает, что фьючерс все еще выполняет работу, поэтому вызывающей стороне нужно будет проверить снова позже. Вариант Ready указывает, что Future завершил свою работу и значение T доступно.
Примечание: Редко возникает необходимость вызывать
pollнапрямую, но если она есть, имейте в виду, что для большинства фьючерсов вызывающая сторона не должна вызыватьpollснова после того, как фьючерс вернулReady. Многие фьючерсы вызовут панику (panic), если их опрашивать снова после готовности. Фьючерсы, которые безопасно опрашивать повторно, явно скажут об этом в своей документации. Это похоже на поведениеIterator::next.
Когда вы видите код, использующий .await, Rust компилирует его под капотом в код, который вызывает poll. Если вы посмотрите на Листинг 17-4, где мы выводили заголовок страницы для одного URL-адреса после его разрешения, Rust компилирует это во что-то вроде (хотя и не совсем) такого:
#![allow(unused)] fn main() { 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 => { // Но что здесь должно быть? } } }
Что нам делать, когда фьючерс все еще Pending? Нам нужен какой-то способ пытаться снова, и снова, и снова, пока фьючерс наконец не будет готов. Другими словами, нам нужен цикл:
#![allow(unused)] fn main() { 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 => { // continue } } } }
Однако, если бы Rust компилировал это в точности в такой код, каждый .await был бы блокирующим — прямо противоположно тому, чего мы добивались! Вместо этого Rust обеспечивает, чтобы цикл мог передать управление чему-то, что может приостановить работу над этим фьючерсом, чтобы работать над другими фьючерсами, а затем проверить этот снова позже. Как мы видели, этим "чем-то" является асинхронный рантайм, и эта работа по планированию и координации — одна из его основных задач.
В разделе ["Передача данных между двумя задачами с помощью обмена сообщениями"] мы описывали ожидание rx.recv. Вызов recv возвращает фьючерс, и ожидание (await) этого фьючерса опрашивает (polls) его. Мы отметили, что рантайм приостановит выполнение фьючерса, пока он не будет готов с либо Some(message), либо None (когда канал закроется). С нашим более глубоким пониманием типажа Future и, в частности, Future::poll, мы можем видеть, как это работает. Рантайм знает, что фьючерс не готов, когда он возвращает Poll::Pending. И наоборот, рантайм знает, что фьючерс готов, и продвигает его, когда poll возвращает Poll::Ready(Some(message)) или Poll::Ready(None).
Точные детали того, как рантайм это делает, выходят за рамки этой книги, но ключевой момент — увидеть базовую механику фьючерсов: рантайм опрашивает каждый фьючерс, за который он отвечает, и снова "усыпляет" фьючерс, когда он еще не готов.
Тип Pin и типаж Unpin
Вернемся к Листингу 17-13, где мы использовали макрос trpl::join! для ожидания трех фьючерсов. Однако часто бывает, что есть коллекция, например вектор, содержащая некоторое количество фьючерсов, которое не будет известно до времени выполнения. Давайте изменим Листинг 17-13 на код в Листинге 17-23, который помещает три фьючерса в вектор и вместо этого вызывает функцию trpl::join_all, который пока не компилируется.
Файл: src/main.rs
#![allow(unused)] fn main() { // [Этот код не компилируется!] let tx_fut = async move { // --snip-- }; let futures: Vec<Box<dyn Future<Output = ()>>> = vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)]; trpl::join_all(futures).await; }
Листинг 17-23: Ожидание фьючерсов в коллекции
Мы помещаем каждый фьючерс в Box, чтобы превратить их в трейт-объекты, точно так же, как мы делали в разделе ["Возврат ошибок из run"] Главы 12. (Мы подробно рассмотрим трейт-объекты в Главе 18.) Использование трейт-объектов позволяет нам рассматривать каждый из анонимных фьючерсов, созданных этими типами, как один и тот же тип, потому что все они реализуют типаж Future.
Это может быть удивительно. В конце концов, ни один из асинхронных блоков ничего не возвращает, поэтому каждый производит Future<Output = ()>. Однако помните, что Future — это типаж, и компилятор создает уникальное перечисление (enum) для каждого асинхронного блока, даже если они имеют идентичные типы выходных данных. Так же как вы не можете поместить две разные структуры, написанные вручную, в Vec, вы не можете смешивать сгенерированные компилятором перечисления.
Затем мы передаем коллекцию фьючерсов в функцию trpl::join_all и ожидаем результат. Однако это не компилируется; вот соответствующая часть сообщений об ошибках.
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:48:33
|
48 | trpl::join_all(futures).await;
| ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
|
= 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<dyn Future<Output = ()>>` 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`
Примечание в этом сообщении об ошибке говорит нам, что мы должны использовать макрос pin!, чтобы закрепить (pin) значения, что означает поместить их внутрь типа Pin, который гарантирует, что значения не будут перемещены в памяти. Сообщение об ошибке говорит, что закрепление требуется, потому что dyn Future<Output = ()> должен реализовать типаж Unpin, а в настоящее время он этого не делает.
Функция trpl::join_all возвращает структуру с именем JoinAll. Эта структура обобщена (generic) по типу F, который ограничен (constrained) необходимостью реализовывать типаж Future. Прямое ожидание фьючерса с помощью .await неявно закрепляет (pins) фьючерс. Вот почему нам не нужно использовать pin! везде, где мы хотим ожидать фьючерсы.
Однако здесь мы не ожидаем фьючерс напрямую. Вместо этого мы создаем новый фьючерс, JoinAll, передавая коллекцию фьючерсов в функцию join_all. Сигнатура для join_all требует, чтобы типы элементов в коллекции все реализовывали типаж Future, а Box<T> реализует Future только в том случае, если обернутый им T — это фьючерс, который реализует типаж 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. Мы сосредоточимся вместо этого на типе для self, так как это первый раз, когда мы видим метод, где self имеет аннотацию типа. Аннотация типа для self работает так же, как аннотации типов для других параметров функции, но с двумя ключевыми различиями:
- Она сообщает Rust, какого типа должен быть
self, чтобы метод можно было вызвать. - Это не может быть просто любой тип. Он ограничен типом, для которого реализован метод, ссылкой или умным указателем на этот тип, или
Pin, оборачивающим ссылку на этот тип.
Мы увидим больше об этом синтаксисе в Главе 18. Пока достаточно знать, что если мы хотим опросить (poll) фьючерс, чтобы проверить, является ли он Pending или Ready(Output), нам нужна изменяемая ссылка на тип, обернутая в Pin.
Pin — это обертка для указателей (pointer-like types), таких как &, &mut, Box и Rc. (Технически, Pin работает с типами, которые реализуют типажи Deref или DerefMut, но это фактически эквивалентно работе только со ссылками и умными указателями.) Pin сам по себе не является указателем и не имеет собственного поведения, как Rc и Arc со счетчиком ссылок; это является инструментом, который компилятор может использовать для обеспечения ограничений на использование указателей.
Вспоминая, что .await реализован через вызовы poll, начинает объяснять сообщение об ошибке, которое мы видели ранее, но оно было сформулировано в терминах Unpin, а не Pin. Так как же именно Pin относится к Unpin и почему Future нуждается в том, чтобы self был в типе Pin для вызова poll?
Вспомните из начала этой главы, что серия точек ожидания (await points) в фьючерсе компилируется в state machine (машину состояний), и компилятор гарантирует, что эта state machine следует всем обычным правилам Rust в отношении безопасности, включая заимствование и владение. Чтобы это работало, Rust смотрит, какие данные нужны между одной точкой ожидания и либо следующей точкой ожидания, либо концом асинхронного блока. Затем он создает соответствующий вариант в скомпилированной state machine. Каждый вариант получает необходимый ему доступ к данным, которые будут использоваться в этом разделе исходного кода, будь то путем взятия владения этими данными или путем получения изменяемой или неизменяемой ссылки на них.
Пока все хорошо: если мы где-то ошибаемся с владением или ссылками в данном асинхронном блоке, проверщик заимствований (borrow checker) сообщит нам. Когда мы хотим переместить фьючерс, соответствующий этому блоку — например, переместить его в Vec для передачи в join_all — все становится сложнее.
Когда мы перемещаем фьючерс — будь то помещение его в структуру данных для использования в качестве итератора с join_all или возврат его из функции — это на самом деле означает перемещение state machine, которую Rust создает для нас. И в отличие от большинства других типов в Rust, фьючерсы, которые Rust создает для асинхронных блоков, могут в конечном итоге содержать ссылки на самих себя в полях любого данного варианта, как показано в упрощенной иллюстрации на Рисунке 17-4.
Рисунок 17-4: Самоссылающийся тип данных
Однако по умолчанию любое перемещение объекта, имеющего ссылку на себя, является небезопасным, потому что ссылки всегда указывают на фактический адрес памяти того, на что они ссылаются (см. Рисунок 17-5). Если вы переместите саму структуру данных, эти внутренние ссылки останутся указывать на старое местоположение. Однако это место в памяти теперь недействительно. С одной стороны, его значение не будет обновляться при внесении изменений в структуру данных. С другой — что более важно — компьютер теперь может свободно повторно использовать эту память для других целей! Впоследствии вы можете столкнуться с чтением совершенно несвязанных данных.

Рисунок 17-5: Небезопасный результат перемещения самоссылающегося типа данных
Теоретически компилятор Rust мог бы пытаться обновлять каждую ссылку на объект всякий раз, когда он перемещается, но это могло бы добавить много накладных расходов на производительность, особенно если нужно обновить целую сеть ссылок. Если бы мы вместо этого могли гарантировать, что рассматриваемая структура данных не перемещается в памяти, нам не пришлось бы обновлять никакие ссылки. Именно для этого и нужен проверщик заимствований (borrow checker) Rust: в безопасном коде он предотвращает перемещение любого элемента, на который есть активная ссылка.
Pin основывается на этом, чтобы дать нам именно ту гарантию, которая нам нужна. Когда мы закрепляем (pin) значение, оборачивая указатель на это значение в Pin, оно больше не может быть перемещено. Таким образом, если у вас есть Pin<Box<SomeType>>, вы фактически закрепляете значение SomeType, а не указатель Box. Рисунок 17-6 иллюстрирует этот процесс.
Рисунок 17-6: Закрепление Box, который указывает на самоссылающийся тип фьючерса
На самом деле, указатель Box все еще может свободно перемещаться. Помните: нам важно обеспечить, чтобы данные, на которые в конечном итоге ссылаются, оставались на месте. Если указатель перемещается, но данные, на которые он указывает, находятся в том же месте, как на Рисунке 17-7, то потенциальной проблемы нет. (В качестве самостоятельного упражнения посмотрите документацию по типам, а также модуль std::pin и попробуйте разобраться, как бы вы сделали это с Pin, оборачивающим Box.) Ключевой момент в том, что самоссылающийся тип сам не может перемещаться, потому что он все еще закреплен.
Рисунок 17-7: Перемещение Box, который указывает на самоссылающийся тип фьючерса
Однако большинство типов совершенно безопасно перемещать, даже если они находятся за указателем Pin. Нам нужно думать о закреплении только тогда, когда элементы имеют внутренние ссылки. Примитивные значения, такие как числа и логические значения, безопасны, потому что они, очевидно, не имеют никаких внутренних ссылок. Как и большинство типов, с которыми вы обычно работаете в Rust. Вы можете, например, перемещать Vec, не беспокоясь. Учитывая то, что мы видели до сих пор, если бы у вас был Pin<Vec<String>>, вам пришлось бы делать все через безопасные, но ограничительные API, предоставляемые Pin, даже though 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.

Рисунок 17-8: Закрепление String; пунктирная линия указывает на то, что String реализует типаж Unpin и, следовательно, не является закрепленным
В результате мы можем делать вещи, которые были бы незаконны, если бы String реализовывал !Unpin, например, заменять одну строку другой точно в том же месте памяти, как на Рисунке 17-9. Это не нарушает контракт Pin, потому что String не имеет внутренних ссылок, которые делали бы его перемещение небезопасным. Именно поэтому он реализует Unpin, а не !Unpin.
Рисунок 17-9: Замена String на совершенно другую String в памяти
Теперь мы знаем достаточно, чтобы понять ошибки, о которых сообщалось при вызове join_all обратно в Листинге 17-23. Изначально мы пытались переместить фьючерсы, созданные асинхронными блоками, в Vec<Box<dyn Future<Output = ()>>>, но, как мы видели, эти фьючерсы могут иметь внутренние ссылки, поэтому они не реализуют Unpin автоматически. Как только мы закрепим их, мы можем передать результирующий тип Pin в Vec, будучи уверенными, что базовые данные во фьючерсах не будут перемещены. Листинг 17-24 показывает, как исправить код, вызвав макрос pin! в месте определения каждого из трех фьючерсов и скорректировав тип трейт-объекта.
#![allow(unused)] fn main() { use std::pin::{Pin, pin}; // --snip-- let tx1_fut = pin!(async move { // --snip-- }); let rx_fut = pin!(async { // --snip-- }); let tx_fut = pin!(async move { // --snip-- }); let futures: Vec<Pin<&mut dyn Future<Output = ()>>> = vec![tx1_fut, rx_fut, tx_fut]; }
Листинг 17-24: Закрепление фьючерсов для возможности их перемещения в вектор
Этот пример теперь компилируется и запускается, и мы могли бы добавлять или удалять фьючерсы из вектора во время выполнения и объединять их все.
Pin и Unpin в основном важны для создания низкоуровневых библиотек или когда вы создаете сам рантайм, а не для повседневного кода на Rust. Однако, когда вы увидите эти типажи в сообщениях об ошибках, у вас теперь будет лучшее представление о том, как исправить ваш код!
Примечание: Это сочетание
PinиUnpinделает возможной безопасную реализацию целого класса сложных типов в Rust, которые в противном случае оказались бы сложными из-за их самоссылающейся природы. Типы, требующиеPin, сегодня чаще всего встречаются в асинхронном Rust, но иногда вы можете увидеть их и в других контекстах.Специфика того, как работают
PinиUnpin, и правила, которым они должны следовать, подробно описаны в API-документации дляstd::pin, так что если вы хотите узнать больше, это отличное место для начала.Если вы хотите понять, как все работает под капотом, еще более подробно, см. Главы 2 и 4 книги «Asynchronous Programming in 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, чтобы было ясно, что он опрашивает (polls) так же, как это делает Future::poll, и производит последовательность элементов так же, как это делает Iterator::next. Его возвращаемый тип объединяет Poll с Option. Внешний тип — это Poll, потому что его нужно проверять на готовность, так же как и фьючерс. Внутренний тип — это Option, потому что ему нужно сигнализировать, есть ли еще сообщения, так же как и итератору.
Нечто очень похожее на это определение, вероятно, в конечном итоге станет частью стандартной библиотеки Rust. А пока это часть инструментария большинства рантаймов, так что вы можете на него положиться, и всё, что мы рассмотрим дальше, должно в целом применяться!
В примерах, которые мы видели в разделе ["Потоки (Streams): Фьючерсы в последовательности"], однако, мы не использовали poll_next или Stream, а вместо этого использовали next и StreamExt. Мы могли бы работать напрямую с API poll_next, вручную написав свои собственные state machine для потоков, конечно, так же как мы могли бы работать с фьючерсами напрямую через их метод poll. Однако использование .await гораздо приятнее, и типаж StreamExt предоставляет метод next, чтобы мы могли сделать именно это:
#![allow(unused)] fn main() { trait StreamExt: Stream { async fn next(&mut self) -> Option<Self::Item> where Self: Unpin; // другие методы... } }
Примечание: Фактическое определение, которое мы использовали ранее в главе, выглядит немного иначе, потому что оно поддерживает версии Rust, которые еще не поддерживали использование асинхронных функций в типажах. В результате оно выглядит так:
#![allow(unused)] fn main() { fn next(&mut self) -> Next<'_, Self> where Self: Unpin; }Этот тип
Next— это структура, которая реализуетFutureи позволяет нам обозначить время жизни ссылки наselfс помощьюNext<'_, Self>, чтобы.awaitмог работать с этим методом.
Типаж StreamExt также является местом, где находятся все интересные методы, доступные для использования с потоками. StreamExt автоматически реализуется для каждого типа, который реализует Stream, но эти типажи определены отдельно, чтобы позволить сообществу итерировать по удобным API, не затрагивая базовый типаж.
В версии StreamExt, используемой в крейте trpl, типаж не только определяет метод next, но и предоставляет реализацию по умолчанию для next, которая корректно обрабатывает детали вызова Stream::poll_next. Это означает, что даже когда вам нужно написать свой собственный тип данных-потока, вам достаточно реализовать только Stream, и тогда любой, кто использует ваш тип данных, сможет автоматически использовать с ним StreamExt и его методы.
Это всё, что мы рассмотрим по низкоуровневым деталям этих типажей. В завершение давайте рассмотрим, как фьючерсы (включая потоки), задачи (tasks) и потоки (threads) сочетаются вместе!