ПРИМЕЧАНИЕ: данное руководство в настоящее время проходит процесс переписывания после долгого периода без значительных работ. Это работа в процессе, многое отсутствует, а существующее содержимое требует доработки.
Введение
Эта книга является руководством по асинхронному программированию в Rust. Она предназначена для того, чтобы помочь вам сделать первые шаги и узнать больше о продвинутых темах. Мы не предполагаем какого-либо опыта в асинхронном программировании (ни в Rust, ни в другом языке), но мы предполагаем, что вы уже знакомы с Rust. Если вы хотите изучить Rust, вы можете начать с "Языка программирования Rust".
Эта книга состоит из двух основных частей: часть первая — это руководство для начинающих, предназначенное для последовательного чтения, которое проведет вас от полного новичка до среднего уровня. Часть вторая — это набор независимых глав по более сложным темам. Она будет полезна после проработки первой части или если у вас уже есть некоторый опыт с асинхронным Rust.
Вы можете ориентироваться в этой книге несколькими способами:
- Вы можете читать её от начала до конца, по порядку. Это рекомендуемый путь для новичков в асинхронном Rust, по крайней мере для первой части книги.
- Слева на веб-странице находится оглавление.
- Если вам нужна информация по обширной теме, вы можете начать с предметного указателя.
- Если вы хотите найти все обсуждения по конкретной теме, вы можете начать с детального указателя.
- Вы можете проверить, есть ли ответ на ваш вопрос в ЧаВо (Часто задаваемых Вопросах).
Что такое Асинхронное Программирование и зачем оно нужно?
В параллельном программировании программа выполняет несколько действий одновременно (или, по крайней мере, создаёт такое впечатление). Программирование с использованием потоков — это одна из форм параллельного программирования. Код внутри потока написан в последовательном стиле, а операционная система выполняет потоки параллельно. При асинхронном программировании параллелизм полностью осуществляется внутри вашей программы (операционная система не участвует). Асинхронная среда выполнения (которая в Rust является просто ещё одним крейтом) управляет асинхронными задачами совместно с программистом, явно передающим управление с использованием ключевого слова await.
Поскольку операционная система не участвует, переключение контекста в асинхронном мире происходит очень быстро. Более того, асинхронные задачи имеют гораздо меньшие накладные расходы памяти, чем потоки операционной системы. Это делает асинхронное программирование хорошо подходящим для систем, которым необходимо обрабатывать очень много параллельных задач и где эти задачи проводят много времени в ожидании (например, ответов от клиентов или операций ввода-вывода). Это также делает асинхронное программирование хорошим выбором для микроконтроллеров с очень ограниченным объемом памяти и без операционной системы, предоставляющей потоки.
Асинхронное программирование также предоставляет программисту детальный контроль над тем, как задачи выполняются (уровни параллелизма и конкурентности, управление потоком выполнения, планирование и т.д.). Это означает, что асинхронное программирование может быть как выразительным, так и эргономичным для многих применений. В частности, асинхронное программирование в Rust обладает мощной концепцией отмены и поддерживает множество различных вариантов параллелизма (выражаемых с помощью конструкций, включающих spawn и его вариации, join, select, for_each_concurrent и т.д.). Это позволяет создавать компоновываемые и повторно используемые реализации таких концепций, как таймауты, приостановка и регулирование.
Hello, world!
Просто чтобы дать вам представление о том, как выглядит асинхронный Rust, вот пример "hello, world". В нём нет параллелизма, и он не совсем использует преимущества асинхронности. Но он определяет и использует асинхронную функцию и печатает "hello, world!":
// Define an async function. async fn say_hello() { println!("hello, world!"); } #[tokio::main] // Boilerplate which lets us write `async fn main`, we'll explain it later. async fn main() { // Call an async function and await its result. say_hello().await; }
Мы объясним всё подробно позже. Пока обратите внимание, как мы определяем асинхронную функцию с помощью async fn и вызываем её с помощью .await — асинхронная функция в Rust ничего не делает, если её не awaitить1.
Как и все примеры в этой книге, если вы хотите увидеть полный пример (включая Cargo.toml) или запустить его локально, вы можете найти их в репозитории GitHub книги: например, examples/hello-world.
Развитие Асинхронного Rust
Функции асинхронности в Rust разрабатываются уже некоторое время, но это не «завершённая» часть языка. Асинхронный Rust (по крайней мере, части, доступные в стабильном компиляторе и стандартных библиотеках) надежен и производителен. Он используется в production в некоторых из самых требовательных ситуаций в крупнейших технологических компаниях. Однако есть некоторые недостающие части и шероховатости (скорее в смысле эргономики, чем надежности). Вы, вероятно, наткнетесь на некоторые из них во время вашего путешествия по асинхронному Rust. Для большинства недостающих частей существуют обходные пути, и они освещены в этой книге.
В настоящее время большинство пользователей находят шероховатости при работе с асинхронными итераторами (также известными как потоки или streams). Некоторые случаи использования async в трейтах пока плохо поддерживаются. Не существует хорошего решения для асинхронного разрушения (destruction).
Над асинхронным Rust ведется активная работа. Если вы хотите следить за разработкой, вы можете посмотреть на домашнюю страницу Рабочей группы по асинхронности, которая включает их дорожную карту. Или вы можете прочитать цели проекта по асинхронности в рамках Проекта Rust.
Rust — это проект с открытым исходным кодом. Если вы хотите внести свой вклад в разработку асинхронного Rust, начните с документации по внесению вклада в основном репозитории Rust.
-
На самом деле, это плохой пример, потому что
println— это блокирующий ввод-вывод, и обычно это плохая идея — делать блокирующий ввод-вывод в асинхронных функциях. Мы объясним, что такое блокирующий ввод-вывод, в главе TODO и почему вам не следует делать блокирующий ввод-вывод в асинхронной функции, в главе TODO. ↩
Navigation
TODO Intro to navigation
Topic index
Concurrency and parallelism
- Introduction
- Running async tasks in parallel using
spawn - Running futures concurrently using
joinandselect - Mixing sync and async concurrency
Correctness and safety
- Cancellation
Performance
Testing
Index
-
Async/
async -
Futuretrait
-
Multitasking
-
Testing
-
Traits
- async
Future
Часть 1: Руководство по асинхронному программированию в Rust
Эта часть книги представляет собой руководство по асинхронному Rust в стиле учебника. Она предназначена для новичков в асинхронном программировании на Rust. Она будет полезна независимо от того, занимались ли вы асинхронным программированием на других языках или нет. Если да, вы можете пропустить первый раздел или пробежать его глазами для освежения памяти. Вам также, возможно, стоит поскорее прочитать сравнение с async в других языках.
Основные концепции
Мы начнём с обсуждения различных моделей параллельного программирования, использующих процессы, потоки или асинхронные задачи. Первая глава охватит основные части асинхронной модели Rust, прежде чем мы углубимся в детали асинхронного программирования в второй главе, где мы представим парадигму программирования async/await. Мы рассмотрим ещё несколько концепций асинхронного программирования в следующей главе.
Одной из основных причин использования асинхронного программирования является более производительный ввод-вывод (IO), который мы рассматриваем в следующей главе. Мы также подробно рассматриваем блокировки в той же главе. Блокировка — это серьёзная опасность в асинхронном программировании, когда поток лишается возможности прогрессировать из-за операции (часто ввода-вывода), которая синхронно ожидает.
Другой мотивацией для асинхронного программирования является то, что оно способствует новым моделям абстракции и композиции параллельного кода. После этого мы переходим к синхронизации между параллельными задачами.
Также есть глава, посвящённая инструментам для асинхронного программирования.
Последние несколько глав охватывают более специализированные темы, начиная с асинхронного разрушения и очистки (что является распространённым требованием, но, поскольку в настоящее время нет хорошего встроенного решения, это несколько специализированная тема).
Следующие две главы руководства подробно рассматривают фьючерсы (futures) и среды выполнения (runtimes) — два фундаментальных строительных блока асинхронного программирования.
Наконец, мы рассматриваем таймеры и обработку сигналов, а также асинхронные итераторы (async iterators), также известные как потоки (streams). Последние — это то, как мы программируем работы с последовательностями асинхронных событий (в отличие от отдельных асинхронных событий, которые представлены с помощью фьючерсов или асинхронных функций). Это область, в которой язык активно развивается, и поэтому здесь могут быть некоторые шероховатости.
Параллельное программирование
Цель этой главы — дать вам общее представление о том, как работает асинхронный параллелизм и чем он отличается от параллелизма с использованием потоков. Я считаю важным иметь хорошую ментальную модель происходящего, прежде чем переходить к практическим аспектам, но если вы из тех, кто предпочитает сначала увидеть реальный код, вам может понравиться прочитать следующую главу или две, а затем вернуться к этой.
Мы начнем с некоторой мотивации, затем рассмотрим последовательное выполнение, программирование с потоками или процессами, а затем асинхронное программирование. Глава завершается разделом о параллелизме и конкурентности.
Пользователи хотят, чтобы их компьютеры делали несколько вещей одновременно. Иногда пользователи хотят делать эти вещи в одно и то же время (например, слушать музыку в приложении и одновременно печатать в своем редакторе). Иногда выполнение нескольких задач одновременно более эффективно (например, выполнение некоторой работы в редакторе во время загрузки большого файла). Иногда несколько пользователей хотят использовать один компьютер одновременно (например, несколько клиентов, подключенных к серверу).
Чтобы привести пример более низкого уровня, музыкальной программе, возможно, необходимо продолжать воспроизводить музыку, пока пользователь взаимодействует с пользовательским интерфейсом (UI). Чтобы «продолжать воспроизводить музыку», ей может потребоваться потоковая передача музыкальных данных с сервера, обработка этих данных из одного формата в другой и отправка обработанных данных в аудиосистему компьютера через операционную систему (ОС). Для пользователя ей может потребоваться отправлять и получать данные или команды на сервер в ответ на инструкции пользователя, ей может потребоваться отправлять сигналы подсистеме, воспроизводящей музыку (например, если пользователь меняет трек или ставит на паузу), ей может потребоваться обновлять графический дисплей (например, выделяя кнопку или изменяя название трека), и она должна сохранять отзывчивость курсора мыши или текстовых вводов во время всего вышеперечисленного.
Выполнение нескольких вещей одновременно (или создание такого впечатления) называется конкурентностью (concurrency). Программы (вместе с ОС) должны управлять своей конкурентностью, и есть много способов сделать это. Мы опишем некоторые из этих способов в этой главе, но начнем с чисто последовательного кода, т.е. без какой-либо конкурентности.
Последовательное выполнение
Режимом выполнения по умолчанию в большинстве языков программирования (включая Rust) является последовательное выполнение.
do_a_thing();
println!("hello!");
do_another_thing();
Каждое оператор завершается до начала следующего1. Между этими операторами ничего не происходит2. Это может показаться тривиальным, но это действительно полезное свойство для рассуждений о нашем коде. Однако это также означает, что мы тратим много времени впустую. В приведенном выше примере, пока мы ждем выполнения println!("hello!"), мы могли бы выполнить do_another_thing(). Возможно, мы могли бы даже выполнить все три оператора одновременно.
Всякий раз, когда происходит IO3 (печать с помощью println! — это IO — это вывод текста на консоль через вызов ОС), программа будет ждать завершения IO4, прежде чем выполнить следующий оператор. Ожидание завершения IO перед продолжением выполнения блокирует программу от выполнения другой работы. Блокирующий IO — это самый простой вид IO для использования, реализации и рассуждений, но он также наименее эффективен — в последовательном мире программа не может делать ничего, пока ждет завершения IO.
Процессы и потоки
Процессы и потоки — это концепции, предоставляемые операционной системой для обеспечения конкурентности. На каждый исполняемый файл приходится один процесс, поэтому поддержка нескольких процессов означает, что компьютер может запускать несколько программ5 одновременно; может быть несколько потоков на процесс, что означает, что также может быть конкурентность внутри процесса.
Есть много небольших различий в том, как обрабатываются процессы и потоки. Самое важное различие заключается в том, что память разделяется между потоками, но не между процессами6. Это означает, что общение между процессами происходит посредством передачи сообщений, подобно общению между программами, работающими на разных компьютерах. С точки зрения программы, единственный процесс — это весь их мир; создание новых процессов означает запуск новых программ. Создание новых потоков, однако, является просто частью регулярного выполнения программы.
Из-за этих различий между процессами и потоками они ощущаются программистом по-разному. Но с точки зрения ОС они очень похожи, и мы будем обсуждать их свойства так, как если бы они были единой концепцией. Мы будем говорить о потоках, но если не оговорено иное, вы должны понимать, что это означает «потоки или процессы».
ОС отвечает за планирование потоков, что означает, что она решает, когда потоки запускаются и как долго. Большинство современных компьютеров имеют несколько ядер, поэтому они могут запускать несколько потоков буквально одновременно. Однако обычно потоков намного больше, чем ядер, поэтому ОС будет запускать каждый поток в течение небольшого промежутка времени, затем приостанавливать его и запускать другой поток на некоторое время7. Когда несколько потоков запускаются на одном ядре таким образом, это называется чередованием (interleaving) или разделением времени (time-slicing). Поскольку ОС выбирает, когда приостановить выполнение потока, это называется вытесняющей многозадачностью (pre-emptive multitasking) (многозадачность здесь просто означает одновременное выполнение нескольких потоков); ОС вытесняет (pre-empts) выполнение потока (или, более многословно, ОС вытесняюще приостанавливает выполнение. Она вытесняющая, потому что ОС приостанавливает поток, чтобы освободить время для другого потока, до того, как первый поток иначе приостановился бы, чтобы гарантировать, что второй поток сможет выполниться до того, как станет проблемой, что он не может).
Давайте снова посмотрим на IO. Что происходит, когда поток блокируется в ожидании IO? В системе с потоками ОС приостановит поток (в любом случае он просто будет ждать) и разбудит его снова, когда IO завершится8. В зависимости от алгоритма планирования, может пройти некоторое время после завершения IO, пока ОС разбудит поток, ожидающий IO, поскольку ОС может подождать, пока другие потоки выполнят некоторую работу. Так что теперь все стало намного эффективнее: пока один поток ждет IO, другой поток (или, скорее всего, многие потоки из-за многозадачности) может прогрессировать. Но с точки зрения потока, выполняющего IO, все остается последовательным — он ждет завершения IO, прежде чем начать следующую операцию.
Поток также может добровольно приостановить себя, вызвав функцию sleep, обычно с таймаутом. В этом случае ОС приостанавливает поток по запросу самого потока. Подобно приостановке из-за вытеснения или IO, ОС позже (после таймаута) разбудит поток для продолжения выполнения.
Когда ОС приостанавливает один поток и запускает другой (по любой причине), это называется переключением контекста (context switching). Переключаемый контекст включает регистры, записи операционной системы и содержимое многих кэшей. Это нетривиальный объем работы. Вместе с передачей управления ОС и обратно к потоку и затратами на работу с устаревшими кэшами, переключение контекста является дорогой операцией.
Наконец, обратите внимание, что некоторое оборудование или ОС не поддерживают процессы или потоки, это более вероятно во встроенном мире (embedded).
Асинхронное программирование
Асинхронное программирование — это вид конкурентности с теми же высокоуровневыми целями, что и конкурентность с потоками (делать много вещей одновременно), но с другой реализацией. Два больших различия между асинхронной конкурентностью и конкурентностью с потоками заключаются в том, что асинхронной конкурентностью управляют полностью внутри программы без помощи ОС9, и что многозадачность является кооперативной, а не вытесняющей10 (мы объясним это через минуту). Существует много различных моделей асинхронной конкурентности, мы сравним их позже в руководстве, но сейчас мы сосредоточимся только на модели Rust.
Чтобы отличать их от потоков, мы будем называть последовательность выполнений в асинхронной конкурентности задачей (task) (их также называют зелеными потоками (green threads), но это иногда имеет смысл вытесняющего планирования и деталей реализации, таких как один стек на задачу). Способ выполнения, планирования и представления задачи в памяти очень отличается от потока, но для высокоуровневой интуиции может быть полезно думать о задачах как о потоках, но управляемых полностью внутри программы, а не ОС.
В асинхронной системе все еще есть планировщик (scheduler), который решает, какую задачу запустить следующей (он является частью программы, а не частью ОС). Однако планировщик не может вытеснить задачу. Вместо этого задача должна добровольно отказаться от управления и позволить запланировать другую задачу. Поскольку задачи должны сотрудничать (cooperate) (отказываясь от управления), это называется кооперативной многозадачностью (cooperative multitasking).
Использование кооперативной, а не вытесняющей многозадачности имеет много последствий:
- между точками, где управление может быть уступлено, вы можете гарантировать, что код будет выполняться последовательно — вас никогда не приостановят неожиданно,
- если задача занимает много времени между точками уступки (например, выполняя блокирующий IO или длительные вычисления), другие задачи не смогут прогрессировать,
- реализация планировщика намного проще, и планирование (и переключение контекста) имеет меньше накладных расходов.
Асинхронная конкурентность намного эффективнее конкурентности с потоками. Накладные расходы на память намного ниже, а переключение контекста — гораздо более дешевая операция — оно не требует передачи управления ОС и обратно к программе, и данных для переключения намного меньше. Однако все еще могут быть некоторые эффекты кэширования — хотя кэши ОС, такие как TLB, не нужно менять, задачи, вероятно, работают с разными частями памяти, поэтому данные, требуемые вновь запланированной задачей, могут не находиться в кэше памяти.
Асинхронный IO — это альтернатива блокирующему IO (его иногда называют неблокирующим IO). Асинхронный IO не связан напрямую с асинхронной конкурентностью, но они часто используются вместе. При асинхронном IO программа инициирует IO одним системным вызовом, а затем может либо проверить, либо получить уведомление о завершении IO. Это означает, что программа может свободно выполнять другую работу, пока происходит IO. В Rust механику асинхронного IO обрабатывает асинхронная среда выполнения (runtime) (планировщик также является частью среды выполнения, мы обсудим среды выполнения подробнее позже в этой книге, но, по сути, среда выполнения — это просто библиотека, которая заботится о некоторых фундаментальных асинхронных вещах).
С точки зрения всей системы, блокирующий IO в конкурентной системе с потоками и неблокирующий IO в асинхронной конкурентной системе схожи. В обоих случаях IO занимает время, и другая работа выполняется, пока происходит IO:
- С потоками: поток, выполняющий IO, запрашивает IO у ОС, поток приостанавливается ОС, другие потоки выполняют работу, и когда IO завершен, ОС пробуждает поток, чтобы он мог продолжить выполнение с результатом IO.
- С async: задача, выполняющая IO, запрашивает IO у среды выполнения, среда выполнения запрашивает IO у ОС, но ОС возвращает управление среде выполнения. Среда выполнения приостанавливает задачу IO и планирует другие задачи для выполнения работы. Когда IO завершен, среда выполнения пробуждает задачу IO, чтобы она могла продолжить выполнение с результатом IO.
Преимущество использования асинхронного IO заключается в том, что накладные расходы намного ниже, поэтому система может поддерживать на порядки больше задач, чем потоков. Это делает асинхронную конкурентность особенно хорошо подходящей для задач с большим количеством пользователей, которые проводят много времени в ожидании IO (если они не проводят много времени в ожидании и вместо этого выполняют много работы, ограниченной CPU, то преимущество низких накладных расходов не так велико, потому что узким местом будут ресурсы CPU и памяти).
Потоки и async не исключают друг друга: многие программы используют и то, и другое. Некоторые программы имеют части, которые лучше реализованы с использованием потоков, и части, которые лучше реализованы с использованием async. Например, сервер базы данных может использовать асинхронные техники для управления сетевым общением с клиентами, но использовать потоки ОС для вычислений с данными. Альтернативно, программа может быть написана только с использованием асинхронной конкурентности, но среда выполнения будет выполнять задачи в нескольких потоках. Это необходимо для того, чтобы программа могла использовать несколько ядер CPU. Мы рассмотрим пересечение потоков и асинхронных задач в нескольких местах позже в книге.
Конкурентность и параллелизм
До сих пор мы говорили о конкурентности (concurrency) (делать или казаться делающим много вещей одновременно), и мы намекали на параллелизм (parallelism) (наличие нескольких ядер CPU, что способствует буквальному выполнению многих вещей одновременно). Эти термины иногда используются взаимозаменяемо, но они являются разными концепциями. В этом разделе мы попытаемся точно определить эти термины и разницу между ними. Я буду использовать простой псевдокод для иллюстрации.
Представьте себе одну задачу, разбитую на кучу подзадач:
task1 {
subTask1-1()
subTask1-2()
...
subTask1-100()
}
Давайте представим, что мы процессор, который выполняет такой псевдокод. Очевидный способ сделать это — сначала выполнить subTask1-1, затем выполнить subTask1-2 и так далее, пока мы не выполним все подзадачи. Это последовательное выполнение.
Теперь рассмотрим несколько задач. Как мы могли бы их выполнить? Мы могли бы начать одну задачу, выполнить все подзадачи, пока вся задача не будет завершена, затем начать следующую. Две задачи выполняются последовательно (и подзадачи внутри каждой задачи также выполняются последовательно). Глядя только на подзадачи, вы бы выполнили их так:
subTask1-1()
subTask1-2()
...
subTask1-100()
subTask2-1()
subTask2-2()
...
subTask2-100()
В качестве альтернативы, вы могли бы выполнить subTask1-1, затем отложить task1 (запомнив, как далеко вы продвинулись), взять следующую задачу и выполнить первую подзадачу из нее, затем вернуться к task1, чтобы выполнить следующую подзадачу. Две задачи были бы чередующимися (interleaved), мы называем это конкурентным выполнением двух задач. Это могло бы выглядеть так:
subTask1-1()
subTask2-1()
subTask1-2()
subTask2-2()
...
subTask1-100()
subTask2-100()
Если только одна задача не может наблюдать результаты или побочные эффекты другой задачи, то с точки зрения задачи подзадачи все еще выполняются последовательно.
Нет причин ограничиваться двумя задачами, мы можем чередовать любое количество и делать это в любом порядке.
Обратите внимание, что независимо от того, сколько конкурентности мы добавим, вся работа занимает одинаковое количество времени для завершения (на самом деле, с большей конкурентностью это может занять больше времени из-за накладных расходов на переключение контекста между ними). Однако для данной подзадачи мы можем завершить ее раньше, чем при чисто последовательном выполнении (для пользователя это может ощущаться более отзывчивым).
Теперь представьте, что обрабатываете задачи не только вы, у вас есть друзья-процессоры, чтобы помочь. Вы можете работать над задачами одновременно и выполнять работу быстрее! Это параллельное выполнение (которое также является конкурентным). Вы могли бы выполнять подзадачи так:
Процессор 1 Процессор 2
============== ==============
subTask1-1() subTask2-1()
subTask1-2() subTask2-2()
... ...
subTask1-100() subTask2-100()
Если процессоров больше двух, мы можем обрабатывать еще больше задач параллельно. Мы также могли бы делать некоторое чередование задач на каждом процессоре или разделение задач между процессорами.
В реальном коде все немного сложнее. Некоторые подзадачи (например, IO) не требуют активного участия процессора, им нужно только запуститься, и некоторое время спустя собрать результаты. А некоторые подзадачи могут требовать результаты (или побочные эффекты) подзадачи из другой задачи для продолжения работы (синхронизация). Оба этих сценария ограничивают эффективные способы конкурентного выполнения задач, и именно поэтому, вместе с обеспечением некоторой концепции справедливости, планирование важно.
Достаточно глупых примеров, давайте попробуем определить вещи правильно
Конкурентность (Concurrency) — это про порядок вычислений, а параллелизм (Parallelism) — про режим выполнения.
Для двух вычислений мы говорим, что они последовательны (т.е. не конкурентны), если мы можем наблюдать, что одно происходит до другого, или что они конкурентны, если мы не можем наблюдать (или, альтернативно, не имеет значения), что одно происходит до другого.
Два вычисления происходят параллельно, если они буквально происходят в одно и то же время. Мы можем думать о параллелизме как о ресурсе: чем больше параллелизма доступно, тем больше вычислений может произойти за фиксированный период времени (при условии, что вычисления происходят с той же скоростью). Увеличение конкурентности системы без увеличения параллелизма никогда не может сделать ее быстрее (хотя это может сделать систему более отзывчивой и может сделать возможной реализацию оптимизаций, которые в противном случае были бы непрактичны).
Перефразируя, два вычисления могут происходить одно за другим (ни конкурентно, ни параллельно), их выполнение может быть чередовано на одном ядре CPU (конкурентно, но не параллельно), или они могут выполняться одновременно на двух ядрах (конкурентно и параллельно)11.
Другая полезная формулировка12 заключается в том, что конкурентность — это способ организации кода, а параллелизм — это ресурс. Это мощное утверждение! То, что конкурентность — это про организацию кода, а не выполнение кода, важно, потому что с точки зрения процессора конкурентность без параллелизма просто не существует. Это особенно актуально для асинхронной конкурентности, потому что она реализована полностью в коде пользователя — не только это «всего лишь» про организацию кода, но вы можете легко доказать это себе, просто прочитав исходный код. То, что параллелизм — это ресурс, также полезно, потому что это напоминает нам, что для параллелизма и производительности важно только количество ядер процессора, а не то, как код организован с точки зрения конкурентности (например, сколько потоков).
Как системы с потоками, так и асинхронные системы могут предлагать и конкурентность, и параллелизм. В обоих случаях конкурентность контролируется кодом (порождение потоков или задач), а параллелизм контролируется планировщиком, который является частью ОС для потоков (настраивается через API ОС) и частью библиотеки среды выполнения для async (настраивается выбором среды выполнения, тем, как реализована среда выполнения, и опциями, которые среда выполнения предоставляет клиентскому коду). Однако есть практическое различие из-за соглашений и общих значений по умолчанию. В системах с потоками каждый конкурентный поток выполняется параллельно с использованием как можно большего параллелизма. В асинхронных системах нет строгого значения по умолчанию: система может запускать все задачи в одном потоке, может назначать несколько задач одному потоку и привязывать этот поток к ядру (так что группы задач выполняются параллельно, но внутри группы каждая задача выполняется конкурентно, но никогда параллельно с другими задачами внутри группы), или задачи могут запускаться параллельно с ограничениями или без. Для первой части этого руководства мы будем использовать среду выполнения Tokio, которая в основном поддерживает последнюю модель. Т.е. поведение относительно параллелизма похоже на конкурентность с потоками. Более того, мы увидим функции в асинхронном Rust, которые явно поддерживают конкурентность, но не параллелизм, независимо от среды выполнения.
Резюме
- Существует много моделей выполнения. Мы описали последовательное выполнение, потоки и процессы, и асинхронное программирование.
- Потоки — это абстракция, предоставляемая (и планируемая) ОС. Они обычно включают вытесняющую многозадачность, по умолчанию параллельны и имеют довольно высокие накладные расходы на управление и переключение контекста.
- Асинхронное программирование управляется пользовательской средой выполнения (user-space runtime). Многозадачность кооперативная. Оно имеет более низкие накладные расходы, чем потоки, но ощущается немного иначе, чем программирование с потоками, поскольку использует другие примитивы программирования (
asyncиawait, и фьючерсы (futures), а не потоки как объекты первого класса).
- Конкурентность и параллелизм — это разные, но тесно связанные концепции.
- Конкурентность — это про порядок вычислений (операции конкурентны, если порядок их выполнения нельзя наблюдать).
- Параллелизм — это про вычисления на нескольких процессорах (операции параллельны, если они буквально происходят в одно и то же время).
- И потоки ОС, и асинхронное программирование обеспечивают конкурентность и параллелизм; асинхронное программирование также может предлагать конструкции для гибкой или детализированной конкурентности, которые не являются частью API потоков большинства операционных систем.
-
На самом деле это не совсем так: современные компиляторы и процессоры реорганизуют ваш код и выполняют его в любом порядке, который им нравится. Последовательные операторы, вероятно, будут перекрываться многими различными способами. Однако это никогда не должно быть наблюдаемо для самой программы или ее пользователей. ↩
-
Это тоже не совсем так: даже когда одна программа является чисто последовательной, другие программы могут работать одновременно; подробнее об этом в следующем разделе. ↩
-
IO — это аббревиатура от input/output (ввод/вывод). Это означает любое общение программы с миром вне программы. Это может быть чтение или запись на диск или в сеть, вывод на терминал, получение пользовательского ввода с клавиатуры или мыши или общение с ОС или другой программой, работающей в системе. IO интересен в контексте конкурентности, потому что его выполнение занимает на несколько порядков больше времени, чем почти любая задача, которую программа может выполнять внутри. Это обычно означает много ожидания, и это время ожидания — возможность сделать другую работу. ↩
-
То, когда именно IO завершен, на самом деле довольно сложно. С точки зрения программы, один вызов IO завершен, когда управление возвращается от ОС. Обычно это указывает на то, что данные были отправлены в какое-то оборудование или другую программу, но это не обязательно означает, что данные были фактически записаны на диск или показаны пользователю и т.д. Для этого может потребоваться дополнительная работа в оборудовании или периодическая очистка кэшей, или чтобы другая программа прочитала данные. В основном нам не нужно об этом беспокоиться, но полезно знать. ↩
-
с точки зрения пользователя, одна программа может включать несколько процессов, но с точки зрения ОС каждый процесс — это отдельная программа. ↩
-
Некоторые ОС поддерживают разделение памяти между процессами, но для ее использования требуется специальный подход, и большая часть памяти не является разделяемой. ↩
-
То, как именно ОС выбирает, какой поток запускать и как долго (и на каком ядре), является ключевой частью планирования. Существует множество вариантов, как высокоуровневых стратегий, так и опций для настройки этих стратегий. Принятие правильных решений здесь имеет crucialное значение для хорошей производительности, но это сложно, и мы не будем здесь углубляться. ↩
-
Есть другой вариант: поток может активно ждать (busy wait), просто вращаясь в цикле, пока IO не завершится. Это не очень эффективно, поскольку другие потоки не смогут запуститься, и это необычно для большинства современных систем. Вы можете столкнуться с этим в реализациях блокировок или в очень простых встраиваемых системах. ↩
-
Мы начнем наше объяснение, предполагая, что программа имеет только один поток, но расширим его позже. Вероятно, в системе работают другие процессы, но они реально не влияют на то, как работает асинхронная конкурентность. ↩
-
Есть некоторые языки программирования (или даже библиотеки), которые имеют конкурентность, управляемую внутри программы (без ОС), но с вытесняющим планировщиком, а не полагаясь на сотрудничество между потоками. Go — известный пример. Эти системы не требуют обозначений
asyncиawait, но имеют другие недостатки, включая усложнение взаимодействия с другими языками или ОС и наличие тяжеловесной среды выполнения. Очень ранние версии Rust имели такую систему, но к версии 1.0 от нее не осталось и следа. ↩ -
Может ли вычисление быть параллельным, но не конкурентным? Вроде да, но не факт. Представьте две задачи (a и b), которые состоят из одной подзадачи каждая (1 и 2, принадлежащие a и b, соответственно). Используя синхронизацию, мы не можем начать подзадачу 2, пока подзадача 1 не завершена, и задача a должна ждать завершения подзадачи 2, пока она не завершится. Теперь a и b работают на разных процессорах. Если мы смотрим на задачи как на черные ящики, мы можем сказать, что они работают параллельно, но в некотором смысле они не конкурентны, потому что их порядок полностью определен. Однако, если мы посмотрим на подзадачи, мы можем увидеть, что они ни параллельны, ни конкурентны. ↩
-
Которую, я думаю, предложил Aaron Turon, и она отразилась в некотором дизайне стандартной библиотеки Rust, например, в функции available_parallelism. ↩
Async и Await
В этой главе мы начнем заниматься асинхронным программированием в Rust и познакомимся с ключевыми словами async и await.
async — это аннотация для функций (и других элементов, таких как трейты, к которым мы вернемся позже); await — это оператор, используемый в выражениях. Но прежде чем мы перейдем к этим ключевым словам, нам нужно охватить несколько основных концепций асинхронного программирования в Rust, что следует из обсуждения в предыдущей главе, здесь мы свяжем вещи напрямую с программированием на Rust.
Концепции async в Rust
Среда выполнения (Runtime)
Асинхронными задачами необходимо управлять и планировать их. Обычно задач больше, чем доступных ядер, поэтому их нельзя запустить все сразу. Когда одна задача останавливает выполнение, должна быть выбрана другая для выполнения. Если задача ожидает IO или какое-либо другое событие, ее не следует планировать, но когда это событие завершится, ее следует запланировать. Это требует взаимодействия с ОС и управления работой IO.
Многие языки программирования предоставляют среду выполнения (runtime). Обычно эта среда выполнения делает гораздо больше, чем просто управляет асинхронными задачами — она может управлять памятью (включая сборку мусора), участвовать в обработке исключений, предоставлять уровень абстракции над ОС или даже быть полноценной виртуальной машиной. Rust — это низкоуровневый язык, который стремится к минимальным накладным расходам времени выполнения. Поэтому асинхронная среда выполнения имеет гораздо более ограниченную область действия, чем среды выполнения многих других языков. Также существует множество способов проектирования и реализации асинхронной среды выполнения, поэтому Rust позволяет вам выбрать одну в зависимости от ваших требований, а не предоставляет одну. Это означает, что для начала работы с асинхронным программированием требуется дополнительный шаг.
Помимо запуска и планирования задач, среда выполнения должна взаимодействовать с ОС для управления асинхронным IO. Она также должна предоставлять функциональность таймеров для задач (что пересекается с управлением IO). Не существует строгих правил о том, как должна быть структурирована среда выполнения, но некоторые термины и разделение обязанностей являются общими:
- Реактор (reactor) или цикл событий (event loop) или драйвер (driver) (эквивалентные термины): распределяет события IO и таймеров, взаимодействует с ОС и выполняет низкоуровневое продвижение выполнения вперед.
- Планировщик (scheduler): определяет, когда задачи могут выполняться и на каких потоках ОС.
- Исполнитель (executor) или среда выполнения (runtime): объединяет реактор и планировщик и представляет собой пользовательский API для запуска асинхронных задач; среда выполнения (runtime) также используется для обозначения всей библиотеки функциональности (например, всего в крейте Tokio, а не только исполнителя Tokio, который представлен типом
Runtime).
Помимо исполнителя, как описано выше, крейт среды выполнения обычно включает множество вспомогательных трейтов и функций. Они могут включать трейты (например, AsyncRead) и реализации для IO, функциональность для распространенных задач IO, таких как сетевое взаимодействие или доступ к файловой системе, блокировки, каналы и другие примитивы синхронизации, утилиты для работы со временем, утилиты для работы с ОС (например, обработка сигналов), вспомогательные функции для работы с фьючерсами и потоками (асинхронные итераторы) или инструменты мониторинга и наблюдения. Мы рассмотрим многие из них в этом руководстве.
Существует множество асинхронных сред выполнения на выбор. Некоторые имеют очень разные политики планирования или оптимизированы для конкретной задачи или области. Для большей части этого руководства мы будем использовать среду выполнения Tokio. Это универсальная среда выполнения, и она является самой популярной в экосистеме. Это отличный выбор для начала работы и для production-использования. В некоторых обстоятельствах вы можете получить лучшую производительность или иметь возможность написать более простой код с другой средой выполнения. Позже в этом руководстве мы обсудим некоторые другие доступные среды выполнения и почему вы можете выбрать ту или иную, или даже написать свою собственную.
Чтобы начать работать как можно быстрее, вам нужно совсем немного шаблонного кода. Вам нужно включить крейт Tokio как зависимость в ваш Cargo.toml (как и любой другой крейт):
[dependencies]
tokio = { version = "1", features = ["full"] }
И вы будете использовать аннотацию tokio::main для вашей функции main, чтобы она могла быть асинхронной функцией (что в противном случае не разрешено в Rust):
#[tokio::main] async fn main() { ... }
Вот и все! Вы готовы писать асинхронный код!
Аннотация #[tokio::main] инициализирует среду выполнения Tokio и запускает асинхронную задачу для выполнения кода в main. Позже в этом руководстве мы подробнее объясним, что делает эта аннотация и как использовать асинхронный код без нее (что даст вам больше гибкости).
Futures-rs и экосистема
TODO контекст и история, для чего нужен futures-rs — раньше использовался часто, сейчас, вероятно, не нужен, пересечение с Tokio и другими средами выполнения (иногда с тонкими семантическими различиями), почему он может вам понадобиться (работа с фьючерсами напрямую, особенно написание своих, потоки (streams), некоторые утилиты)
Другие вещи экосистемы — крейты Yosh, альтернативные среды выполнения, экспериментальные вещи, другие?
Фьючерсы и задачи
Базовой единицей асинхронной конкурентности в Rust является фьючерс (future). Фьючерс — это просто обычный старый объект Rust (обычно структура или перечисление), который реализует трейт 'Future'. Фьючерс представляет отложенное вычисление. То есть вычисление, которое будет готово в какой-то момент в будущем.
Мы много будем говорить о фьючерсах в этом руководстве, но проще начать, не слишком беспокоясь о них. Мы будем упоминать их довольно часто в следующих нескольких разделах, но мы не будем на самом деле определять их или использовать напрямую до конца. Один важный аспект фьючерсов заключается в том, что их можно комбинировать, чтобы создавать новые, «большие» фьючерсы (мы поговорим подробнее о том, как их можно комбинировать позже).
Я довольно неформально использовал термин «асинхронная задача» в предыдущей главе и этой. Я использовал этот термин для обозначения логической последовательности выполнения; аналогично потоку, но управляемому внутри программы, а не извне ОС. Часто полезно думать в терминах задач, однако в самом Rust нет концепции задачи, и этот термин используется для обозначения разных вещей! Это сбивает с толку! Что еще хуже, среды выполнения имеют концепцию задачи, и разные среды выполнения имеют немного разные концепции задач.
Отныне я буду стараться быть точным в терминологии, касающейся задач. Когда я использую просто «задача» (task), я имею в виду абстрактную концепцию последовательности вычислений, которая может происходить конкурентно с другими задачами. Я буду использовать «асинхронная задача» (async task) для обозначения точно того же, но в противопоставление задаче, реализованной как поток ОС. Я буду использовать «задача среды выполнения» (runtime's task) для обозначения любого вида задачи, которую представляет себе среда выполнения, и «задача tokio» (tokio task) (или какой-либо другой конкретной среды выполнения) для обозначения концепции задачи в Tokio.
Асинхронная задача в Rust — это просто фьючерс (обычно «большой» фьючерс, составленный из многих других). Другими словами, задача — это фьючерс, который выполняется. Однако бывают случаи, когда фьючерс «выполняется», не будучи задачей среды выполнения. Такой фьючерс интуитивно является задачей, но не задачей среды выполнения. Я уточню это, когда мы дойдем до примера.
Асинхронные функции
Ключевое слово async является модификатором для объявлений функций. Например, мы можем написать pub async fn send_to_server(...). Асинхронная функция — это просто функция, объявленная с использованием ключевого слова async, и это означает, что это функция, которая может выполняться асинхронно, другими словами, вызывающая сторона может выбрать не ждать завершения функции перед выполнением чего-то другого.
Более механически, когда асинхронная функция вызывается, тело не выполняется, как это было бы для обычной функции. Вместо этого тело функции и ее аргументы упаковываются в фьючерс, который возвращается вместо реального результата. Вызывающая сторона может затем решить, что делать с этим фьючерсом (если вызывающей стороне нужен результат «немедленно», то она будет awaitить фьючерс, см. следующий раздел).
Внутри асинхронной функции код выполняется обычным, последовательным образом1, асинхронность не меняет этого. Вы можете вызывать синхронные функции из асинхронных функций, и выполнение продолжается как обычно. Одна дополнительная вещь, которую вы можете делать внутри асинхронной функции, — использовать await для ожидания других асинхронных функций (или фьючерсов), что может привести к уступке управления, чтобы другая задача могла выполниться.
await
Мы заявили выше, что фьючерс — это вычисление, которое будет готово в какой-то момент в будущем. Чтобы получить результат этого вычисления, мы используем ключевое слово await. Если результат готов немедленно или может быть вычислен без ожидания, то await просто выполняет это вычисление для получения результата. Однако, если результат не готов, то await передает управление планировщику, чтобы другая задача могла продолжить (это кооперативная многозадачность, упомянутая в предыдущей главе).
Синтаксис использования await — some_future.await, т.е. это постфиксное ключевое слово, используемое с оператором .. Это означает, что его можно эргономично использовать в цепочках вызовов методов и обращений к полям.
Рассмотрим следующие функции:
#![allow(unused)] fn main() { // Асинхронная функция, но ей не нужно ничего ждать. async fn add(a: u32, b: u32) -> u32 { a + b } async fn wait_to_add(a: u32, b: u32) -> u32 { sleep(1000).await; a + b } }
Если мы вызовем add(15, 3).await, то он немедленно вернет результат 18. Если мы вызовем wait_to_add(15, 3).await, мы в конечном итоге получим тот же ответ, но пока мы ждем, другая задача получит возможность запуститься.
В этом глупом примере вызов sleep является заменой выполнения какой-то длительной задачи, где нам приходится ждать результата. Обычно это операция IO, где результатом являются данные, прочитанные из внешнего источника, или подтверждение того, что запись во внешнее место назначения удалась. Чтение выглядит примерно так: let data = read(...).await?. В этом случае await заставит текущую задачу ждать, пока происходит чтение. Задача возобновит работу, когда чтение будет завершено (другие задачи могут выполнить некоторую работу, пока задача чтения ждет). Результатом чтения могут быть успешно прочитанные данные или ошибка (обрабатываемая оператором ?).
Обратите внимание, что если мы вызовем add или wait_to_add или read без использования .await, мы не получим никакого ответа!
Что?
Вызов асинхронной функции возвращает фьючерс, он не выполняет немедленно код внутри функции. Более того, фьючерс не выполняет никакой работы, пока его не await2. Это контрастирует с некоторыми другими языками, где асинхронная функция возвращает фьючерс, который начинает выполняться немедленно.
Это важный момент в асинхронном программировании на Rust. Через некоторое время это станет второй натурой, но это часто сбивает с толку начинающих, особенно тех, у кого есть опыт асинхронного программирования на других языках.
Важная интуиция о фьючерсах в Rust заключается в том, что они инертны. Чтобы выполнить какую-либо работу, их должно продвигать вперед внешняя сила (обычно асинхронная среда выполнения).
Мы описали await довольно операционно (он запускает фьючерс, производя результат), но мы говорили в предыдущей главе об асинхронных задачах и конкурентности, как await вписывается в эту ментальную модель? Во-первых, рассмотрим чисто последовательный код: логически, вызов функции просто выполняет код в функции (с некоторым присваиванием переменных). Другими словами, текущая задача продолжает выполнять следующий «кусок» кода, определенный функцией. Аналогично, в асинхронном контексте вызов неасинхронной функции просто продолжает выполнение с этой функции. Вызов асинхронной функции находит код для выполнения, но не выполняет его. await — это оператор, который продолжает выполнение текущей задачи, или, если текущая задача не может продолжить прямо сейчас, дает другой задаче возможность продолжить.
await может использоваться только внутри асинхронного контекста, пока это означает внутри асинхронной функции (позже мы увидим больше видов асинхронных контекстов). Чтобы понять почему, вспомните, что await может передавать управление среде выполнения, чтобы другая задача могла выполниться. Среда выполнения, которой можно передать управление, существует только в асинхронном контексте. Пока вы можете представить среду выполнения как глобальную переменную, доступную только в асинхронных функциях, позже мы объясним, как это работает на самом деле.
Наконец, для еще одной перспективы на await: мы упомянули ранее, что фьючерсы можно комбинировать, чтобы создавать «большие» фьючерсы. Асинхронные функции — это один из способов определить фьючерс, а await — один из способов комбинировать фьючерсы. Использование await на фьючерсе объединяет этот фьючерс в фьючерс, производимый асинхронной функцией, внутри которой он используется. Мы поговорим подробнее об этой перспективе и других способах комбинирования фьючерсов позже.
Некоторые примеры async/await
Давайте начнем с повторного посещения нашего примера «Hello, world!»:
// Define an async function. async fn say_hello() { println!("hello, world!"); } #[tokio::main] // Boilerplate which lets us write `async fn main`, we'll explain it later. async fn main() { // Call an async function and await its result. say_hello().await; }
Теперь вы должны узнавать шаблонный код вокруг main. Он нужен для инициализации среды выполнения Tokio и создания начальной задачи для запуска асинхронной функции main.
say_hello — это асинхронная функция, когда мы вызываем ее, мы должны следовать за вызовом .await, чтобы запустить ее как часть текущей задачи. Обратите внимание, что если вы удалите .await, то запуск программы ничего не делает! Вызов say_hello возвращает фьючерс, но он никогда не выполняется, поэтому println никогда не вызывается (компилятор, по крайней мере, предупредит вас).
Вот немного более реалистичный пример, взятый из учебника Tokio.
#[tokio::main] async fn main() -> Result<()> { // Открываем соединение с адресом mini-redis. let mut client = client::connect("127.0.0.1:6379").await?; // Устанавливаем ключ "hello" со значением "world" client.set("hello", "world".into()).await?; // Получаем ключ "hello" let result = client.get("hello").await?; println!("got value from the server; result={:?}", result); Ok(()) }
Код немного интереснее, но мы essentially делаем то же самое — вызываем асинхронные функции, а затем ожидаем (await) выполнения результата. На этот раз мы используем ? для обработки ошибок — это работает так же, как в синхронном Rust.
При всей нашей болтовне о конкурентности, параллелизме и асинхронности, оба этих примера на 100% последовательны. Простой вызов и ожидание асинхронных функций не вводят никакой конкурентности, если нет других задач для планирования, пока ожидающая задача ждет. Чтобы доказать это себе, давайте посмотрим на другой простой (но надуманный) пример:
use std::io::{stdout, Write}; use tokio::time::{sleep, Duration}; async fn say_hello() { print!("hello, "); // Flush stdout so we see the effect of the above `print` immediately. stdout().flush().unwrap(); } async fn say_world() { println!("world!"); } #[tokio::main] async fn main() { say_hello().await; // An async sleep function, puts the current task to sleep for 1s. sleep(Duration::from_millis(1000)).await; say_world().await; }
Между выводом "hello" и "world" мы усыпляем текущую задачу3 на одну секунду. Наблюдайте, что происходит при запуске программы: она печатает "hello", ничего не делает в течение одной секунды, затем печатает "world". Это потому, что выполнение одной задачи является чисто последовательным. Если бы у нас была какая-то конкурентность, то эта одна секунда сна была бы отличной возможностью сделать другую работу, например, напечатать "world". Мы увидим, как это сделать, в следующем разделе.
Порождение задач (Spawning tasks)
Мы говорили об async и await как о способе запуска кода в асинхронной задаче. И мы сказали, что await может усыпить текущую задачу, пока она ждет IO или какое-либо другое событие. Когда это происходит, другая задача может запуститься, но откуда берутся эти другие задачи? Так же, как мы используем std::thread::spawn для порождения новой задачи, мы можем использовать tokio::spawn для порождения новой асинхронной задачи. Обратите внимание, что spawn — это функция Tokio, среды выполнения, а не из стандартной библиотеки Rust, потому что задачи — это чисто концепция среды выполнения.
Вот крошечный пример запуска асинхронной функции в отдельной задаче с помощью spawn:
use tokio::{spawn, time::{sleep, Duration}}; async fn say_hello() { // Wait for a while before printing to make it a more interesting race. sleep(Duration::from_millis(100)).await; println!("hello"); } async fn say_world() { sleep(Duration::from_millis(100)).await; println!("world!"); } #[tokio::main] async fn main() { spawn(say_hello()); spawn(say_world()); // Wait for a while to give the tasks time to run. sleep(Duration::from_millis(1000)).await; }
Аналогично последнему примеру, у нас есть две функции, печатающие "hello" и "world!". Но на этот раз мы запускаем их конкурентно (и параллельно), а не последовательно. Если вы запустите программу несколько раз, вы должны увидеть, что строки печатаются в обоих порядках — иногда сначала "hello", иногда сначала "world!". Классическая гонка в конкурентном программировании!
Давайте углубимся в то, что здесь происходит. В игре три концепции: фьючерсы, задачи и потоки. Функция spawn принимает фьючерс (который, помните, может состоять из многих более мелких фьючерсов) и запускает его как новую задачу Tokio. Задачи — это концепция, которую среда выполнения Tokio планирует и управляет (не отдельными фьючерсами). Tokio (в своей конфигурации по умолчанию) — это многопоточная среда выполнения, что означает, что когда мы порождаем новую задачу, эта задача может быть запущена на другом потоке ОС, чем задача, из которой она была порождена (она может быть запущена на том же потоке, или она может начаться на одном потоке, а затем быть перемещена на другой позже).
Итак, когда фьючерс порождается как задача, он выполняется конкурентно с задачей, из которой он был порожден, и любыми другими задачами. Он также может выполняться параллельно этим задачам, если он запланирован на другом потоке.
Подводя итог, когда мы пишем два оператора, следующих друг за другом в Rust, они выполняются последовательно (независимо от того, в асинхронном коде или нет). Когда мы пишем await, это не меняет конкурентность последовательных операторов. Например, foo(); bar(); строго последовательно — foo вызывается, а затем bar. Это верно, независимо от того, являются ли foo и bar асинхронными функциями или нет. foo().await; bar().await; также строго последовательно, foo полностью вычисляется, а затем bar полностью вычисляется. В обоих случаях другой поток может чередоваться с последовательным выполнением, и во втором случае другая асинхронная задача может чередоваться в точках await, но два оператора выполняются последовательно по отношению друг к другу в обоих случаях.
Если мы используем либо thread::spawn, либо tokio::spawn, мы вводим конкурентность и потенциально параллелизм, в первом случае между потоками, а во втором — между задачами.
Позже в руководстве мы увидим случаи, когда мы выполняем фьючерсы конкурентно, но никогда параллельно.
Соединение задач (Joining tasks)
Если мы хотим получить результат выполнения порожденной задачи, то порождающая задача может ждать ее завершения и использовать результат, это называется соединением (joining) задач (аналогично соединению потоков, и API для соединения похожи).
Когда задача порождается, функция spawn возвращает JoinHandle. Если вы просто хотите, чтобы задача выполняла свою работу, JoinHandle можно отбросить (удаление JoinHandle не влияет на порожденную задачу). Но если вы хотите, чтобы порождающая задача ждала завершения порожденной задачи, а затем использовала результат, вы можете awaitить JoinHandle, чтобы сделать это.
Например, давайте еще раз пересмотрим наш пример «Hello, world!»:
use tokio::{spawn, time::{sleep, Duration}}; async fn say_hello() { // Wait for a while before printing to make it a more interesting race. sleep(Duration::from_millis(100)).await; println!("hello"); } async fn say_world() { sleep(Duration::from_millis(100)).await; println!("world"); } #[tokio::main] async fn main() { let handle1 = spawn(say_hello()); let handle2 = spawn(say_world()); let _ = handle1.await; let _ = handle2.await; println!("!"); }
Код похож на прошлый раз, но вместо простого вызова spawn мы сохраняем возвращенные JoinHandle и позже awaitим их. Поскольку мы ждем завершения этих задач перед выходом из функции main, нам больше не нужен sleep в main.
Две порожденные задачи все еще выполняются конкурентно. Если вы запустите программу несколько раз, вы должны увидеть оба порядка. Однако awaitенные join handles являются ограничением на конкурентность: восклицательный знак ('!') всегда будет напечатан последним (вы можете поэкспериментировать с перемещением println!("!"); относительно await. Вам, вероятно, также нужно будет изменить время сна, чтобы получить наблюдаемые эффекты).
Если бы мы сразу выполнили awaitили JoinHandle первого spawn вместо того, чтобы сохранить его и позже awaitить (т.е. написали spawn(say_hello()).await;), то мы бы породили другую задачу для запуска фьючерса 'hello', но порождающая задача ждала бы ее завершения, прежде чем делать что-либо еще. Другими словами, не было бы возможной конкурентности! Вы почти никогда не захотите делать это (потому что зачем тогда вообще spawn? Просто напишите последовательный код).
JoinHandle
Мы быстро рассмотрим JoinHandle немного глубже. Тот факт, что мы можем awaitить JoinHandle, является подсказкой, что JoinHandle сам по себе является фьючерсом. spawn — это не async функция, это обычная функция, которая возвращает фьючерс (JoinHandle). Она выполняет некоторую работу (для планирования задачи) перед возвратом фьючерса (в отличие от асинхронного фьючерса), поэтому нам не нужно awaitить spawn. Ожидание JoinHandle ждет завершения порожденной задачи, а затем возвращает результат. В приведенном выше примере не было результата, мы просто ждали завершения задачи. JoinHandle — это обобщенный тип, и его параметр типа — это тип, возвращаемый порожденной задачей. В приведенном выше примере тип был бы JoinHandle<()>, фьючерс, который дает String, производил бы JoinHandle с типом JoinHandle<String>.
awaitение JoinHandle возвращает Result (поэтому мы использовали let _ = ... в приведенном выше примере, это избегает предупреждения о неиспользуемом Result). Если порожденная задача завершилась успешно, то результат задачи будет в варианте Ok. Если задача запаниковала или была прервана (форма отмены), то результат будет Err, содержащий тип JoinError. Если вы не используете отмену через abort в своем проекте, то unwrapping результата JoinHandle.await является разумным подходом, поскольку это effectively распространяет панику из порожденной задачи в порождающую задачу.
-
как и любой другой поток, поток, на котором выполняется асинхронная функция, может быть вытеснен (pre-empted) операционной системой и приостановлен, чтобы другой поток мог выполнить некоторую работу. Однако с точки зрения функции это не наблюдается без проверки данных, которые могли быть изменены другими потоками (и которые могли быть изменены другим потоком, выполняющимся параллельно без приостановки текущего потока). ↩
-
Или опрошен (polled), что является операцией более низкого уровня, чем
await, и происходит за кулисами при использованииawait. Мы поговорим об опросе позже, когда будем подробно говорить о фьючерсах. ↩ -
Обратите внимание, что здесь мы используем асинхронную функцию сна, если бы мы использовали
sleepиз std, мы бы усыпили весь поток. Это не имело бы никакого значения в этом игрушечном примере, но в реальной программе это означало бы, что другие задачи не могут быть запланированы на этом потоке в течение этого времени. Это очень плохо. ↩
Дополнительные темы по async/await
Модульные тесты (Unit tests)
Как писать модульные тесты для асинхронного кода? Проблема в том, что await можно использовать только внутри асинхронного контекста, а модульные тесты в Rust по умолчанию не являются асинхронными. К счастью, большинство сред выполнения предоставляют удобные атрибуты для тестов, аналогичные атрибуту для async main. При использовании Tokio это выглядит так:
#![allow(unused)] fn main() { #[tokio::test] async fn test_something() { // Пишите тест здесь, включая все нужные вам `await`. } }
Существует множество способов настройки теста, подробности см. в документации.
Атрибут #[tokio::test] автоматически создает среду выполнения Tokio для выполнения асинхронного теста. Это позволяет использовать .await внутри тестовой функции так же, как и в обычном асинхронном коде.
Для тестирования асинхронных функций, которые могут возвращать ошибки, вы можете использовать assert!(result.is_ok()) или assert!(result.is_err()) для проверки результата Result.
#![allow(unused)] fn main() { #[tokio::test] async fn test_fetch_data_error() { let invalid_url = "https://invalid-url"; let result = fetch_data(invalid_url).await; assert!(result.is_err()); } }
Более сложные темы тестирования асинхронного кода (например, тестирование на состояние гонки, взаимоблокировки и т.д.) будут рассмотрены позже в этом руководстве.
Блокировки и отмена (Cancellation)
Блокировки и отмена — это важные концепции, которые необходимо учитывать при программировании на асинхронном Rust. Это не особенности конкретных функций, а повсеместные свойства системы, которые вы должны понимать, чтобы писать корректный код.
Блокирующий IO (Blocking IO)
Мы говорим, что поток (здесь речь идет о потоках ОС, а не об асинхронных задачах) заблокирован, когда он не может делать прогресс. Обычно это происходит потому, что он ожидает завершения задачи от ОС (чаще всего операции ввода-вывода). Важно, что пока поток заблокирован, ОС знает, что его не нужно планировать, чтобы другие потоки могли работать. Это нормально в многопоточной программе, так как позволяет другим потокам работать, пока заблокированный поток ожидает. Однако в асинхронной программе есть другие задачи, которые должны быть запланированы на том же потоке ОС, но ОС ничего о них не знает и заставляет весь поток ожидать. Это означает, что вместо одной задачи, ожидающей завершения своего IO (что нормально), ждать вынуждены многие задачи (что уже плохо).
Скоро мы поговорим о неблокирующем/асинхронном IO. Пока просто знайте, что неблокирующий IO — это IO, о котором знает асинхронная среда выполнения, и поэтому ждать будет только текущая задача, а поток не будет заблокирован. Крайне важно использовать в асинхронной задаче только неблокирующий IO, и никогда — блокирующий IO (который является единственным видом, предоставляемым стандартной библиотекой Rust).
Блокирующие вычисления (Blocking computation)
Вы также можете заблокировать поток, выполняя вычисления (это не совсем то же самое, что блокирующий IO, поскольку ОС не участвует, но эффект схож). Если у вас есть длительное вычисление (с блокирующим IO или без него) без уступки управления среде выполнения, то эта задача никогда не даст планировщику среды выполнения шанс запланировать другие задачи. Помните, что асинхронное программирование использует кооперативную многозадачность. Здесь задача не сотрудничает, поэтому другие задачи не получат возможности выполнить свою работу. Способы решения этой проблемы мы обсудим позже.
Существует множество других способов заблокировать целый поток, и мы еще не раз вернемся к теме блокировок в этом руководстве.
Отмена (Cancellation)
Отмена означает остановку выполнения фьючерса (или задачи). Поскольку в Rust (в отличие от многих других систем async/await) фьючерсы должны продвигаться вперед внешней силой (например, асинхронной средой выполнения), если фьючерс больше не продвигается, то он не будет выполняться. Если фьючерс удаляется (помните, фьючерс — это просто обычный старый объект Rust), то он больше не может делать прогресс и считается отмененным.
Инициировать отмену можно несколькими способами:
- Простым удалением фьючерса (если он вам принадлежит).
- Вызовом
abortуJoinHandleзадачи (или использованиемAbortHandle). - Через
CancellationToken(что требует, чтобы отменяемый фьючерс отслеживал токен и кооперативно отменял сам себя). - Неявно, с помощью функции или макроса, такого как
select.
Второй и третий способы специфичны для Tokio, хотя большинство сред выполнения предоставляют аналогичные возможности. Использование CancellationToken требует сотрудничества со стороны отменяемого фьючерса, а другие способы — нет. В этих других случаях отмененный фьючерс не получит уведомления об отмене и не получит возможности очистить ресурсы (кроме своего деструктора). Обратите внимание, что даже если у фьючерса есть токен отмены, он все равно может быть отменен другими методами, которые не активируют этот токен.
С точки зрения написания асинхронного кода (в асинхронных функциях, блоках, фьючерсах и т.д.), код может остановить выполнение в любой точке await (включая скрытые внутри макросов) и никогда больше не возобновить его. Чтобы ваш код был корректным (в частности, безопасным к отмене - cancellation safe), он должен работать правильно как при нормальном завершении, так и при завершении в любой точке await1.
#![allow(unused)] fn main() { async fn some_function(input: Option<Input>) { let Some(input) = input else { return; // Может завершиться здесь (`return`). }; let x = foo(input)?; // Может завершиться здесь (`?`). let y = bar(x).await; // Может завершиться здесь (`await`). // ... // Может завершиться здесь (неявный return). } }
Пример того, как это может пойти не так: если асинхронная функция читает данные во внутренний буфер, а затем ожидает (await) следующий фрагмент данных. Если чтение данных является деструктивным (т.е. их нельзя перечитать из исходного источника) и асинхронная функция отменена, то внутренний буфер будет удален, и данные в нем будут потеряны. Важно учитывать, как на фьючерс и любые данные, к которым он обращается, повлияет отмена фьючерса, его перезапуск или запуск нового фьючерса, который обращается к тем же данным.
Мы еще несколько раз вернемся к отмене и безопасности отмены в этом руководстве, а в справочном разделе есть целая глава, посвященная этой теме.
Асинхронные блоки (Async blocks)
Обычный блок ({ ... }) группирует код в исходном тексте и создает область видимости для имен. Во время выполнения блок выполняется по порядку и вычисляется в значение своего последнего выражения (или в тип-единицу (), если завершающего выражения нет).
Подобно асинхронным функциям, асинхронный блок — это отложенная версия обычного блока. Асинхронный блок группирует код и имена, но во время выполнения он не выполняется немедленно и вычисляется в фьючерс. Чтобы выполнить блок и получить результат, его необходимо awaitить. Например:
#![allow(unused)] fn main() { let s1 = { let a = 42; format!("The answer is {a}") }; let s2 = async { let q = question().await; format!("The question is {q}") }; }
Если бы мы выполнили этот фрагмент, s1 была бы строкой, которую можно напечатать, а s2 была бы фьючерсом; question() даже не была бы вызвана. Чтобы напечатать s2, нам сначала нужно сделать s2.await.
Асинхронный блок — это простейший способ начать асинхронный контекст и создать фьючерс. Он обычно используется для создания небольших фьючерсов, которые используются только в одном месте.
К сожалению, управление потоком выполнения с асинхронными блоками немного своеобразно. Поскольку асинхронный блок создает фьючерс, а не выполняется напрямую, он ведет себя больше как функция, чем обычный блок, с точки зрения управления потоком. break и continue не могут «пройти сквозь» асинхронный блок, как это бывает с обычными блоками; вместо этого вам придется использовать return:
#![allow(unused)] fn main() { loop { { if ... { // ok continue; } } async { if ... { // не ok // continue; // ok - продолжает со следующей итерации `loop`, хотя учтите, что если бы в цикле после // асинхронного блока был код, он бы выполнился. return; } }.await } }
Чтобы реализовать break, вам нужно будет проверить значение блока (распространенной идиомой является использование ControlFlow для значения блока, что также позволяет использовать ?).
Аналогично, ? внутри асинхронного блока завершит выполнение фьючерса при ошибке, в результате чего awaitенный блок примет значение ошибки, но не выйдет из окружающей функции (как это сделал бы ? в обычном блоке). Для этого вам понадобится еще один ? после await:
#![allow(unused)] fn main() { async { let x = foo()?; // Этот `?` выходит только из асинхронного блока, а не из окружающей функции. consume(x); Ok(()) }.await? }
Раздражает, что это часто сбивает с толку компилятор, поскольку (в отличие от функций) «возвращаемый» тип асинхронного блока не указан явно. Вам, вероятно, потребуется добавить некоторые аннотации типов к переменным или использовать турбо-рыбу (turbofish) для указания типов, например, Ok::<_, MyError>(()) вместо Ok(()) в приведенном выше примере.
Функция, возвращающая асинхронный блок, довольно похожа на асинхронную функцию. Написание async fn foo() -> ... { ... } примерно эквивалентно fn foo() -> ... { async { ... } }. Фактически, с точки зрения вызывающей стороны они эквивалентны, и переход от одной формы к другой не является критическим изменением. Более того, вы можете переопределить одну другой при реализации асинхронного трейта (см. ниже). Однако вам придется скорректировать тип, сделав Future явным в версии с асинхронным блоком: async fn foo() -> Foo становится fn foo() -> impl Future<Output = Foo> (вам также, возможно, потребуется сделать явными другие ограничения, например, Send и 'static).
Обычно следует предпочитать версию с асинхронной функцией, поскольку она проще и понятнее. Однако версия с асинхронным блоком более гибкая, поскольку вы можете выполнить некоторый код при вызове функции (написав его вне асинхронного блока) и некоторый код, когда результат ожидается (код внутри асинхронного блока).
Асинхронные замыкания (Async closures)
- Замыкания
- Скоро будет (https://github.com/rust-lang/rust/pull/132706, https://blog.rust-lang.org/inside-rust/2024/08/09/async-closures-call-for-testing.html)
- Асинхронные блоки в замыканиях против асинхронных замыканий
Время жизни и заимствование (Lifetimes and borrowing)
- Упоминалось о времени жизни
'staticвыше - Ограничения времени жизни для фьючерсов (
Future + '_и т.д.) - Заимствование across await points
- Я не знаю, я уверен, что с асинхронными функциями есть еще проблемы с временем жизни...
Ограничения Send + 'static на фьючерсах
- Почему они там есть, многопоточные среды выполнения
spawn_local, чтобы избежать их- Что делает асинхронную функцию
Send + 'staticи как исправлять ошибки с этим связанные
Асинхронные трейты (Async traits)
- Синтаксис
- Проблема с
Send + 'staticи способы ее обходаtrait_variant- Явный future
- Нотация возвращаемого типа (Return Type Notation, RTN) (https://blog.rust-lang.org/inside-rust/2024/09/26/rtn-call-for-testing.html)
- Проблема с
- Переопределение (overriding)
- Нотация future против async для методов
- Безопасность объектов (object safety)
- Правила захвата (capture rules) (https://blog.rust-lang.org/2024/09/05/impl-trait-capture-rules.html)
- История и крейт
async-trait
Рекурсия (Recursion)
- Разрешена (относительно недавно), но требует явного помещения в
Box(boxing).- Ссылка вперед на фьючерсы, закрепление (pinning)
- https://rust-lang.github.io/async-book/07_workarounds/04_recursion.html
- https://blog.rust-lang.org/2024/03/21/Rust-1.77.0.html#support-for-recursion-in-async-fn
- Макрос
async-recursion(https://docs.rs/async-recursion/latest/async_recursion/)
Рекурсивные асинхронные функции создают тип конечного автомата, который содержит сам себя, что приводит к типу бесконечного размера. Компилятор выдаст ошибку: "recursion in an async fn requires boxing".
Для исправления этой проблемы можно использовать крейт async_recursion, который предоставляет атрибут #[async_recursion] для автоматического преобразования функции. Этот макрос изменяет функцию так, чтобы она возвращала запакованный (boxed) фьючерс.
#![allow(unused)] fn main() { use async_recursion::async_recursion; #[async_recursion] async fn fib(n: u32) -> u32 { match n { 0 | 1 => 1, _ => fib(n-1).await + fib(n-2).await } } }
Макрос также позволяет контролировать ограничения трейтов для возвращаемого фьючерса с помощью опций (?Send) и (Sync).
-
Интересно сравнить отмену в асинхронном программировании с отменой потоков. Отменить поток возможно (например, с помощью
pthread_cancelв C; в Rust нет прямого способа сделать это), но это почти всегда очень и очень плохая идея, поскольку отменяемый поток может завершиться где угодно. В отличие от этого, отмена асинхронной задачи может произойти только в точке await. Как следствие, отмена потока ОС без завершения всего процесса происходит очень редко, и вам, как программисту, обычно не нужно об этом беспокоиться. Однако в асинхронном Rust отмена — это то, что может произойти. Мы обсудим, как с этим бороться, по мере нашего продвижения. ↩
Ввод-вывод и проблемы с блокировками
Эффективная обработка ввода-вывода (IO) является одной из основных причин использования асинхронного программирования, и большинство асинхронных программ выполняют много операций IO. Корень проблемы с IO заключается в том, что он занимает на порядки больше времени, чем вычисления, поэтому простое ожидание завершения IO вместо выполнения другой работы невероятно неэффективно. В идеале, асинхронное программирование позволяет программе заниматься другой работой во время ожидания IO.
Эта глава представляет собой введение в IO в асинхронном контексте. Мы рассмотрим важное различие между блокирующим и неблокирующим IO, и почему блокирующий IO и асинхронное программирование несовместимы (по крайней мере, без некоторой доли размышлений и усилий). Мы рассмотрим, как использовать неблокирующий IO, затем посмотрим на некоторые проблемы, которые могут возникнуть с IO и асинхронным программированием. Мы также рассмотрим, как операционная система обрабатывает IO, и мельком взглянем на некоторые альтернативные методы IO, такие как io_uring.
Мы закончим рассмотрением других способов блокировки асинхронной задачи (что плохо) и того, как правильно смешивать асинхронное программирование с блокирующим IO или длительными, ресурсоёмкими вычислениями.
Блокирующий и неблокирующий IO
IO реализуется операционной системой; работа по IO происходит в отдельных процессах и/или на специализированном оборудовании, в любом случае вне процесса программы. IO может быть синхронным или асинхронным (также известным как блокирующий и неблокирующий, соответственно). Синхронный IO означает, что программа (или, по крайней мере, поток) ожидает (то есть блокируется) во время выполнения IO и не начинает обработку, пока IO не завершится и результат не будет получен от ОС. Асинхронный IO означает, что программа может продолжать выполнять работу во время выполнения IO и может забрать результат позже. Существует множество различных API ОС для обоих видов IO, хотя больше разнообразия в асинхронной сфере.
Асинхронный IO и асинхронное программирование не связаны по своей сути. Однако асинхронное программирование способствует эргономичному и производительному асинхронному IO, и это является основной мотивацией для асинхронного программирования. Блокировки из-за синхронного IO являются основным источником проблем с производительностью в асинхронном программировании, и мы должны быть осторожны, чтобы избежать их (подробнее об этом ниже).
Стандартная библиотека Rust включает функции и трейты для блокирующего IO. Для неблокирующего IO вы должны использовать специализированные библиотеки, которые часто являются частью асинхронной среды выполнения, например, модуль io в Tokio.
Давайте быстро взглянем на пример (адаптированный из документации Tokio):
#![allow(unused)] fn main() { use tokio::{io::AsyncWriteExt, net::TcpStream}; async fn write_hello() -> Result<(), Box<dyn std::error::Error>> { let mut stream = TcpStream::connect("127.0.0.1:8080").await?; stream.write_all(b"hello world!").await?; Ok(()) } }
write_all — это асинхронный метод IO, который записывает данные в stream. Это может завершиться сразу, но более вероятно, что это займёт некоторое время, поэтому stream.write_all(...).await приведёт к приостановке текущей задачи во время ожидания обработки записи ОС. Планировщик будет запускать другие задачи, и когда запись завершится, он разбудит задачу и запланирует её продолжение.
Однако, если бы мы использовали функцию записи из стандартной библиотеки, асинхронный планировщик не был бы задействован, и ОС приостановила бы весь поток на время завершения IO, что означает, что не только текущая задача приостанавливается, но и никакая другая задача не может быть выполнена с использованием этого потока. Если это произойдёт со всеми потоками в пуле потоков среды выполнения (который в некоторых обстоятельствах может состоять всего из одного потока), то вся программа останавливается и не может прогрессировать. Это называется блокировкой потока (или программы) и очень плохо сказывается на производительности. Важно никогда не блокировать потоки в асинхронной программе, и поэтому вам следует избегать использования блокирующего IO в асинхронной задаче.
Блокировка потока может быть вызвана длительными задачами или задачами, ожидающими блокировок, а также блокирующим IO. Мы обсудим это подробнее в конце этой главы.
Распространённым шаблоном является многократное чтение или запись, и потоки (streams) и приёмники (sinks) (также известные как асинхронные итераторы) являются удобным механизмом для этого. Они рассматриваются в отдельной главе.
Чтение и запись
TODO
- Асинхронные трейты Read и Write
- часть среды выполнения
- Как использовать
- Конкретные реализации
- Сеть против диска
- TCP, UDP
- Файловая система не совсем асинхронна, но io_uring (ссылка на эту главу)
- Практические примеры
- stdout и т.д.
- Каналы (pipe), файловые дескрипторы (fd) и т.д.
- Сеть против диска
Управление памятью
Когда мы читаем данные, нам нужно куда-то их поместить, а когда мы записываем данные, их нужно где-то хранить до завершения записи. В любом случае, то, как управляется эта память, важно.
TODO
- Проблемы с управлением буферами и асинхронным IO
- Различные решения и их плюсы и минусы
- Подход с нулевым копированием (zero-copy)
- Подход с общим буфером
- Вспомогательные крейты, такие как Bytes и т.д.
Продвинутые темы по IO
TODO
- Блочное чтение/запись (buf read/write)
- Read + Write, разделение (split), объединение (join)
- Копирование (copy)
- Симплексные и дуплексные соединения (simplex and duplex)
- Отмена (cancelation)
- Что делать, если нам нужно выполнить синхронный IO? Создать поток или использовать spawn_blocking (см. ниже)
Взгляд ОС на IO
TODO
- Различные виды IO и механизмы, IO с завершением (completion IO), ссылка на главу о completion IO в разделе для продвинутых
- разные среды выполнения могут обеспечивать это
- mio для низкоуровневого интерфейса
Другие блокирующие операции
Как упоминалось в начале главы, неблокирование потоков имеет crucialное значение для производительности асинхронных программ. Блокирующий IO различных видов — это распространённый способ блокировки, но также возможно заблокироваться, выполняя много вычислений или ожидая способом, с которым асинхронный планировщик не координируется.
Ожидание чаще всего вызывается использованием механизмов синхронизации, не осведомлённых об асинхронности, например, использованием std::sync::Mutex вместо асинхронного мьютекса или ожиданием неасинхронного канала. Мы обсудим эту проблему в главе о Каналах, блокировках и синхронизации. Есть и другие способы блокирующего ожидания, и в целом вам нужно найти неблокирующий или иным образом асинхронно-дружественный механизм, например, использовать асинхронную функцию sleep вместо функции из std. Ожидание также может быть активным (busy wait) (по сути, просто цикл без выполнения какой-либо работы, также known как спин-блокировка), вам, вероятно, следует просто избегать этого.
Ресурсоёмкая работа (CPU-intensive)
Выполнение длительной (т.е. ресурсоёмкой или ограниченной производительностью CPU, cpu-bound) работы предотвратит запуск других задач планировщиком. Это является своего рода блокировкой, но не такой плохой, как блокировка на IO или ожидании, потому что, по крайней мере, ваша программа прогрессирует. Однако (без должного внимания и рассмотрения) это, вероятно, будет неоптимальным для производительности по некоторым показателям (например, задержки в хвосте распределения) и, возможно, проблемой корректности, если задачи, которые не могут быть запущены, должны были выполняться в определённое время. Существует мем, что вам просто не следует использовать асинхронный Rust (или универсальные асинхронные среды выполнения, такие как Tokio) для ресурсоёмких задач, но это упрощение. Правильно то, что вы не можете смешивать задачи, ограниченные IO и CPU (или, точнее, длительные и чувствительные к задержкам), без специальной обработки и ожидать хороших результатов.
Для оставшейся части этого раздела мы будем предполагать, что у вас есть смесь чувствительных к задержкам задач и длительных, ресурсоёмких задач. Если у вас нет ничего, что было бы чувствительно к задержкам, то ситуация несколько иная (в основном, проще).
По сути, существует три решения для запуска длительных или блокирующих задач: использовать встроенные возможности среды выполнения, использовать отдельный поток или использовать отдельную среду выполнения.
В Tokio вы можете использовать spawn_blocking для порождения задачи, которая может блокировать. Это работает как spawn для порождения задачи, но запускает задачу в отдельном пуле потоков, оптимизированном для задач, которые могут блокировать (задача, вероятно, будет запущена в своём собственном потоке). Обратите внимание, что это запускает обычный синхронный код, а не асинхронную задачу. Это означает, что задачу нельзя отменить (даже несмотря на то, что её JoinHandle имеет метод abort). Другие среды выполнения предоставляют аналогичную функциональность.
Вы можете создать поток для выполнения блокирующей работы с помощью std::thread::spawn (или аналогичных функций). Это довольно просто. Если вам нужно запустить много задач, вам, вероятно, понадобится какой-то пул потоков или планировщик заданий. Если вы продолжаете создавать потоки и их становится намного больше, чем доступных ядер, вы в конечном итоге пожертвуете пропускной способностью. Rayon — популярный выбор, который позволяет легко запускать и управлять параллельными задачами. Вы можете добиться лучшей производительности с чем-то более специфичным для вашей рабочей нагрузки и/или имеющим некоторое представление о выполняемых задачах.
Вы можете использовать отдельные экземпляры асинхронной среды выполнения для чувствительных к задержкам задач и для длительных задач. Это подходит для задач, ограниченных CPU, но вам всё равно не следует использовать блокирующий IO, даже в среде выполнения для длительных задач. Для задач, ограниченных CPU, это хорошее решение, поскольку оно единственное поддерживает возможность того, что длительные задачи являются асинхронными. Оно также гибкое (поскольку среды выполнения можно настроить для оптимальной работы с типом задач, которые они выполняют; действительно, необходимо приложить некоторые усилия для настройки среды выполнения, чтобы добиться оптимальной производительности) и позволяет вам извлекать выгоду из использования зрелых, хорошо спроектированных подсистем, таких как Tokio. Вы даже можете использовать две разные асинхронные среды выполнения. В любом случае, среды выполнения должны работать в разных потоках.
С другой стороны, вам действительно нужно немного подумать: вы должны убедиться, что запускаете задачи в правильной среде выполнения (что может быть сложнее, чем кажется), и общение между задачами может быть осложнено. Мы обсудим синхронизацию между синхронными и асинхронными контекстами далее, но это может быть ещё сложнее между несколькими асинхронными средами выполнения. Каждая среда выполнения — это своя собственная маленькая вселенная задач, и планировщики полностью независимы. Каналы и блокировки Tokio могут использоваться из разных сред выполнения (даже не-Tokio), но примитивы других сред выполнения могут не работать таким образом.
Поскольку планировщик в каждой среде выполнения не знает о других средах выполнения (а ОС не знает ни о каких асинхронных планировщиках), нет координации или разделения приоритетов планирования, и работа не может быть "украдена" между средами выполнения. Следовательно, планирование задач может быть неоптимальным (особенно если среды выполнения не хорошо настроены для своих рабочих нагрузок). Более того, поскольку всё планирование является кооперативным, длительные задачи всё равно могут испытывать нехватку ресурсов, и задержки могут пострадать. См. следующий раздел о том, как длительные задачи можно сделать более кооперативными.
Как чистый планировщик, использование Tokio для CPU-работы, вероятно, будет иметь несколько более высокие накладные расходы, чем выделенный синхронный пул рабочих. Это неудивительно, если учесть дополнительную работу, необходимую для поддержки асинхронного программирования. На практике для большинства пользователей это вряд ли станет проблемой, но может быть стоит учитывать, если ваш код чрезвычайно чувствителен к производительности.
Для любого из вышеперечисленных решений у вас будут задачи, выполняющиеся в разных контекстах (синхронных и асинхронных, или разных асинхронных средах выполнения). Если вам нужно общаться между задачами, то вам нужно позаботиться о том, чтобы вы использовали правильные комбинации синхронных и асинхронных примитивов (каналы, мьютексы и т.д.) и правильные (блокирующие или неблокирующие) методы для этих примитивов. Для мьютексов и подобных блокировок вам, вероятно, следует использовать асинхронные версии, если вам нужно удерживать блокировку через точку await или защищать ресурс IO (её должно быть можно использовать из синхронных контекстов с помощью блокирующего метода lock), или синхронную версию для защиты данных или там, где блокировку не нужно удерживать через точку await. Асинхронные каналы Tokio можно использовать из синхронного контекста с блокирующими методами, но см. эту документацию для получения некоторых подробностей о том, когда использовать синхронные или асинхронные каналы.
Итак, какое из вышеперечисленных решений вам следует использовать?
- Если вы выполняете блокирующий IO, вам, вероятно, следует использовать
spawn_blocking. Вы не можете использовать вторую среду выполнения или другой пул потоков (по крайней мере, если вам нужна оптимальная производительность). - Если у вас есть поток, который будет работать вечно, вам следует использовать
std::thread::spawn, а не любой вид пула потоков (поскольку он будет использовать один из потоков пула). - Если вы выполняете много CPU-работы, то вам следует использовать пул потоков, либо специализированный, либо вторую асинхронную среду выполнения.
- Если вам нужно запускать длительный асинхронный код, то вам следует использовать вторую среду выполнения.
- Вы можете выбрать использование выделенного потока или
spawn_blocking, потому что это легко и имеет удовлетворительную производительность, даже если более сложное решение является более оптимальным.
Уступка
Длительный код является проблемой, потому что он не даёт планировщику возможности планировать другие задачи. Асинхронная конкурентность кооперативна: планировщик не может вытеснить (pre-empt) задачу, чтобы запустить другую. Если длительная задача не уступает планировщику, то планировщик не может её остановить. Однако, если длительный код уступает планировщику, то другие задачи могут быть запланированы, и тот факт, что задача является длительной, не является проблемой. Это можно использовать как альтернативу использованию другого потока для ресурсоёмкой работы или для ресурсоёмкой работы в её собственной среде выполнения, чтобы (возможно) улучшить производительность.
Уступить легко, просто вызовите функцию yield среды выполнения. В Tokio это yield_now. Обратите внимание, что это отличается от yield_now стандартной библиотеки и ключевого слова yield для уступки из корутины. Вызов yield_now не уступит планировщику, если текущий фьючерс выполняется внутри select или join (см. главу о композиции фьючерсов параллельно); это может быть или не быть тем, что вы хотите.
Знать, когда вам нужно уступить, немного сложнее. Во-первых, вам нужно знать, уступает ли ваша программа неявно. Это может произойти только при .await, поэтому, если вы не awaitите, то вы не уступаете. Но await не уступает планировщику автоматически. Это происходит только если конечный (leaf) фьючерс, который awaitится, находится в состоянии ожидания (pending) или где-то в стеке вызовов есть явный yield. Tokio и большинство асинхронных сред выполнения будут делать это в своих функциях IO и синхронизации, но в общем случае вы не можете знать, уступит ли await, без отладки или изучения исходного кода.
Хорошее эмпирическое правило заключается в том, что код не должен выполняться более 10-100 микросекунд без достижения потенциальной точки уступки.
Ссылки
- Документация Tokio по задачам, ограниченным CPU, и блокирующему коду
- Статья в блоге: Что такое блокировка?
- Статья в блоге: Использование асинхронной среды выполнения Tokio в Rust для задач, ограниченных CPU
Композиция фьючерсов конкурентно
В этой главе мы рассмотрим дополнительные способы компоновки фьючерсов. В частности, новые способы, позволяющие выполнять фьючерсы конкурентно (но не параллельно). Поверхностно, новые функции/макросы, которые мы представим в этой главе, довольно просты. Однако лежащие в их основе концепции могут быть довольно тонкими. Мы начнем с повторения фьючерсов, конкурентности и параллелизма, но вам также может быть полезно перечитать более ранний раздел, сравнивающий конкурентность с параллелизмом.
Фьючерс — это отложенное вычисление. Фьючерс можно продвинуть с помощью await, который передает управление среде выполнения, заставляя текущую задачу ждать результата вычисления. Если a и b — фьючерсы, то их можно скомпоновать последовательно (то есть объединить в фьючерс, который выполняет a до завершения, а затем b до завершения), awaitя сначала один, потом другой: async { a.await; b.await}.
Мы также видели параллельную композицию фьючерсов с помощью spawn: async { let a = spawn(a); let b = spawn(b); (a.await, b.await)} запускает два фьючерса параллельно. Обратите внимание, что await в кортеже ожидают не сами фьючерсы, а JoinHandle, чтобы получить результаты фьючерсов, когда они завершатся.
В этой главе мы представляем два способа компоновки фьючерсов конкурентно без параллелизма: join и select/race. В обоих случаях фьючерсы выполняются конкурентно за счет разделения времени; каждый из составленных фьючерсов по очереди выполняется, затем наступает очередь следующего. Это делается без привлечения асинхронной среды выполнения (и, следовательно, без нескольких потоков ОС и без какого-либо потенциала для параллелизма). Конструкция композиции локально чередует выполнение фьючерсов. Вы можете думать об этих конструкциях как о мини-исполнителях, которые выполняют свои составные фьючерсы в рамках одной асинхронной задачи.
Фундаментальное различие между join и select/race заключается в том, как они обрабатывают завершение работы фьючерсов: join завершается, когда все фьючерсы завершаются, а select/race завершается, когда один фьючерс завершается (все остальные отменяются). Также существуют вариации обоих для обработки ошибок.
Эти конструкции (или аналогичные концепции) часто используются с потоками (streams), мы кратко коснемся этого ниже, но подробнее поговорим об этом в главе о потоках.
Если вы хотите параллелизма (или вы не explicitly не хотите его), порождение задач часто является более простой альтернативой этим конструкциям композиции. Порождение задач обычно менее подвержено ошибкам, более универсально, и производительность более предсказуема. С другой стороны, порождение по своей природе менее структурировано, что может затруднить рассуждения о жизненном цикле и управлении ресурсами.
Стоит рассмотреть проблему производительности немного глубже. Потенциальная проблема производительности при конкурентной композиции — это справедливость распределения времени. Если у вас есть 100 задач в вашей программе, то обычно оптимальный способ распределения ресурсов — предоставить каждой задаче 1% процессорного времени (или, если все задачи ожидают, то дать каждой одинаковый шанс быть разбуженной). Если вы порождаете 100 задач, то обычно это примерно так и происходит. Однако если вы породите две задачи и соедините (join) 99 фьючерсов на одной из этих задач, то планировщик будет знать только о двух задачах, и одна задача получит 50% времени, а 99 фьючерсов получат по 0.5% каждый.
Обычно распределение задач не настолько смещено, и очень часто мы используем join/select/и т.д. для таких вещей, как таймауты, где такое поведение фактически желательно. Но стоит учитывать это, чтобы убедиться, что ваша программа имеет желаемые характеристики производительности.
Join (Соединение)
Макрос join в Tokio принимает список фьючерсов и запускает их все до завершения конкурентно (возвращая все результаты в виде кортежа). Он возвращается, когда все фьючерсы завершены. Фьючерсы всегда выполняются в одном и том же потоке (конкурентно, а не параллельно).
Вот простой пример:
async fn main() { let (result_1, result_2) = join!(do_a_thing(), do_a_thing()); // Используйте `result_1` и `result_2`. }
Здесь два выполнения do_a_thing происходят конкурентно, и результаты готовы, когда оба завершены. Обратите внимание, что мы не используем await для получения результатов. join! неявно ожидает свои фьючерсы и производит значение. Он не создает фьючерс. Вам все еще нужно использовать его внутри асинхронного контекста (например, внутри асинхронной функции).
Хотя вы не видите этого в примере выше, join! принимает выражения, которые вычисляются в фьючерсы1. join не создает асинхронный контекст в своем теле, и вам не следует awaitить фьючерсы, передаваемые в join (иначе они будут вычислены до того, как присоединенные фьючерсы начнут выполняться).
Поскольку все фьючерсы выполняются в одном потоке, если какой-либо фьючерс блокирует поток, то ни один из них не может прогрессировать. При использовании мьютекса или другой блокировки это может легко привести к взаимоблокировке (deadlock), если один фьючерс ждет блокировку, удерживаемую другим фьючерсом.
join не заботится о результате фьючерсов. В частности, если фьючерс отменен или возвращает ошибку, это не влияет на другие — они продолжают выполняться. Если вы хотите поведения «быстроого отказа» (fail fast), используйте try_join. try_join работает аналогично join, однако, если любой фьючерс возвращает Err, то все остальные фьючерсы отменяются, и try_join немедленно возвращает ошибку.
Еще в предыдущей главе об async/await мы использовали слово «join» для описания соединения порожденных задач. Как следует из названия, соединение фьючерсов и задач связано: соединение означает, что мы выполняем несколько фьючерсов конкурентно и ждем результат, прежде чем продолжить. Синтаксис отличается: использование JoinHandle против макроса join, но идея схожа. Ключевое различие заключается в том, что при соединении задач задачи выполняются конкурентно и параллельно, тогда как при использовании join! фьючерсы выполняются конкурентно, но не параллельно. Более того, порожденные задачи планируются планировщиком среды выполнения, тогда как с join! фьючерсы «планируются» локально (в той же задаче и в пределах временной области выполнения макроса). Другое отличие состоит в том, что если порожденная задача паникует, паника перехватывается средой выполнения, но если фьючерс в join паникует, то паникует вся задача.
Альтернативы
Конкурентный запуск фьючерсов и сбор их результатов — это распространенное требование. Вам, вероятно, следует использовать spawn и JoinHandle, если у вас нет веской причины не делать этого (т.е. вы явно не хотите параллелизма, и даже тогда вы можете предпочесть spawn_local). Абстракция JoinSet управляет такими порожденными задачами способом, аналогичным join!.
Большинство сред выполнения (и futures.rs) имеют эквивалент макроса join от Tokio, и они в основном ведут себя одинаково. Также существуют функции join, которые похожи на макрос, но немного менее гибки. Например, futures.rs имеет join для соединения двух фьючерсов, join3, join4 и join5 для соединения очевидного количества фьючерсов, и join_all для соединения коллекции фьючерсов (а также try_ вариации каждого из них).
Futures-concurrency также предоставляет функциональность для join (и try_join). В стиле futures-concurrency эти операции являются методами трейта для групп фьючерсов, таких как кортежи, Vec или массивы. Например, чтобы соединить два фьючерса, вы бы написали (fut1, fut2).join().await (обратите внимание, что await здесь явный).
Если набор фьючерсов, которые вы хотите соединить, изменяется динамически (например, новые фьючерсы создаются по мере поступления входных данных по сети), или вы хотите получать результаты по мере их завершения, а не когда все фьючерсы завершатся, то вам нужно будет использовать потоки и функциональность FuturesUnordered или FuturesOrdered. Мы рассмотрим их в главе о потоках.
Race/Select (Гонка/Выбор)
Аналогом соединения фьючерсов является их гонка (также known как выбор). При гонке/выборе фьючерсы выполняются конкурентно, но вместо того, чтобы ждать завершения всех фьючерсов, мы ждем только завершения первого, а затем отменяем остальные. Хотя это звучит похоже на соединение, это значительно интереснее (и иногда чревато ошибками), потому что теперь нам приходится рассуждать об отмене.
Вот пример использования макроса select от Tokio:
async fn main() { select! { result = do_a_thing() => { println!("computation completed and returned {result}"); } _ = timeout() => { println!("computation timed-out"); } } }
Вы заметите, что все уже интереснее, чем с макросом join, потому что мы обрабатываем результаты фьючерсов внутри макроса select. Это немного похоже на выражение match, но с select все ветви выполняются конкурентно, и тело ветви, которая завершается первой, выполняется с ее результатом (другие ветви не выполняются, и фьючерсы отменяются путем dropа). В примере do_a_thing и timeout выполняются конкурентно, и первый завершившийся будет иметь свой блок выполненным (т.е. только один println запустится), другой фьючерс будет отменен. Как и в макросе join, ожидание фьючерсов неявное.
Макрос select от Tokio поддерживает кучу функций:
- Сопоставление с образцом (pattern matching): синтаксис слева от
=в каждой ветви может быть образцом, и блок выполняется только если результат фьючерса соответствует образцу. Если образец не совпадает, то фьючерс больше не опрашивается (но другие фьючерсы — да). Это может быть полезно для фьючерсов, которые опционально возвращают значение, например,Some(x) = do_a_thing() => { ... }. - Охранники
if: каждая ветвь может иметь охранникif. Когда макросselectзапускается, после вычисления каждого выражения для получения фьючерса, вычисляется охранникif, и фьючерс опрашивается только если охранитель истинен. Например,x = do_a_thing() if false => { ... }никогда не будет опрошен. Обратите внимание, что охранникifне перевычисляется во время опроса, только при инициализации макроса. - Ветвь
else:selectможет иметь ветвьelseelse => { ... }, она выполняется, если все фьючерсы остановились и ни один из блоков не был выполнен. Если это происходит без ветвиelse, тоselectзапаникует.
Значение макроса select! — это значение выполненной ветви (как и в match), поэтому все ветви должны иметь одинаковый тип. Например, если бы мы захотели использовать результат приведенного выше примера вне select, мы бы написали это так:
async fn main() { let result = select! { result = do_a_thing() => { Some(result) } _ = timeout() => { None } }; // Используйте `result` }
Как и в случае с join!, select! не обрабатывает Result каким-либо особым образом (кроме упомянутого ранее сопоставления с образцом), и если ветвь завершается с ошибкой, то все другие ветви отменяются, и ошибка используется как результат select (так же, как если бы ветвь завершилась успешно).
Макрос select по своей сути использует отмену, поэтому если вы пытаетесь избежать отмены в вашей программе, вы должны избегать select!. Фактически, select часто является основным источником отмены в асинхронной программе. Как обсуждалось в другом месте, отмена имеет множество тонких проблем, которые могут привести к ошибкам. В частности, обратите внимание, что select отменяет фьючерсы, просто удаляя их. Это не уведомит удаляемый фьючерс и не активирует какие-либо токены отмены и т.д.
select! часто используется в цикле для обработки потоков или других последовательностей фьючерсов. Это добавляет дополнительный уровень сложности и возможностей для ошибок. В простом случае, когда мы создаем новый, независимый фьючерс на каждой итерации цикла, все не намного сложнее. Однако это редко то, что нужно. Обычно мы хотим сохранить некоторое состояние между итерациями. Распространено использование select в цикле с потоками, где каждая итерация цикла обрабатывает один результат из потока. Например:
async fn main() { let mut stream = ...; loop { select! { result = stream.next() => { match result { Some(x) => println!("received: {x}"), None => break, } } _ = timeout() => { println!("time out!"); break; } } } }
В этом примере мы читаем значения из stream и печатаем их, пока они не закончатся или ожидание результата не превысит таймаут. Что произойдет с любыми оставшимися данными в потоке в случае таймаута, зависит от реализации потока (они могут быть потеряны! Или продублированы!). Это пример того, почему поведение при отмене может быть важным (и сложным).
Мы можем захотеть повторно использовать фьючерс, а не только поток, across итераций. Например, мы можем захотеть соревноваться с фьючерсом таймаута, где таймаут применяется ко всем итерациям, а не устанавливает новый таймаут для каждой итерации. Это возможно путем создания фьючерса вне цикла и ссылки на него:
async fn main() { let mut stream = ...; let mut timeout = timeout(); loop { select! { result = stream.next() => { match result { Some(x) => println!("received: {x}"), None => break, } } // Создаем ссылку на `timeout`, а не перемещаем его. _ = &mut timeout => { println!("time out!"); break; } } } }
Есть несколько важных деталей при использовании select! в цикле с фьючерсами или потоками, созданными вне select!. Они являются фундаментальным следствием того, как работает select, поэтому я представлю их, подробно разобрав select, используя timeout в последнем примере в качестве примера.
timeoutсоздается вне цикла и инициализируется некоторым временем для обратного отсчета.- На каждой итерации цикла
selectсоздает ссылку наtimeout, но не меняет его состояние. - По мере выполнения
selectопрашиваетtimeout, который будет возвращатьPending, пока время не истекло, иReady, когда время истекает, после чего его блок выполняется.
В приведенном выше примере, когда timeout готов, мы выходим из цикла с помощью break. Но что, если бы мы этого не сделали? В этом случае select просто снова опросил бы timeout, что, согласно документации Future, не должно происходить! select не может этого предотвратить, у него нет никакого состояния (между итерациями), чтобы решить, следует ли опрашивать timeout. В зависимости от того, как написан timeout, это может вызвать панику, логическую ошибку или какой-то сбой.
Вы можете предотвратить такого рода ошибки несколькими способами:
- Используйте fused фьючерс или поток, чтобы повторный опрос был безопасным.
- Убедитесь, что ваш код структурирован так, что фьючерсы никогда не переопрашиваются, например, выходя из цикла (как в предыдущем примере) или используя охранник
if.
Теперь рассмотрим тип &mut timeout. Предположим, что timeout() возвращает тип, который реализует Future, который может быть анонимным типом из асинхронной функции или именованным типом, таким как Timeout. Предположим последнее, потому что это делает примеры проще (но логика применима в любом случае). Учитывая, что Timeout реализует Future, будет ли &mut Timeout реализовывать Future? Не обязательно! Существует blanket impl, который делает это истинным, но только если Timeout реализует Unpin. Это верно не для всех фьючерсов, поэтому часто вы получите ошибку типа, написав код, подобный последнему примеру. Такая ошибка легко исправляется с помощью макроса pin, например, let mut timeout = pin!(timeout());
Отмена с помощью select в цикле — это богатый источник тонких ошибок. Обычно они происходят, когда фьючерс содержит некоторое состояние, включающее некоторые данные, но не сами данные. Когда фьючерс удаляется из-за отмены, это состояние теряется, но лежащие в основе данные не обновляются. Это может привести к потере данных или их многократной обработке.
Альтернативы
Futures.rs имеет свой собственный макрос select, а futures-concurrency имеет трейт Race, которые являются альтернативами макросу select от Tokio. Они оба имеют ту же основную семантику конкурентной гонки нескольких фьючерсов, обработки результата первого и отмены остальных, но они имеют разный синтаксис и различаются в деталях.
select от futures.rs поверхностно похож на Tokio; чтобы обобщить различия, в версии futures.rs:
- Фьючерсы всегда должны быть fused (обеспечивается проверкой типов).
selectимеет ветвиdefaultиcomplete, а не ветвьelse.selectне поддерживает охранникиif.
Race от futures-concurrency имеет очень отличающийся синтаксис, похожий на его версию join, например, (future_a, future_b).race().await (он работает с Vec и массивами, а также с кортежами). Синтаксис менее гибкий, чем у макросов, но хорошо вписывается в большинство асинхронного кода. Обратите внимание, что если вы используете race внутри цикла, у вас все равно могут быть те же проблемы, что и с select.
Как и в случае с join, порождение задач и позволение им выполняться параллельно часто является хорошей альтернативой использованию select. Однако отмена оставшихся задач после завершения первой требует некоторой дополнительной работы. Это можно сделать с помощью каналов или токена отмены. В любом случае, отмена требует некоторого действия со стороны отменяемой задачи, что означает, что задача может выполнить некоторую очистку или другое graceful завершение.
Распространенное использование select (особенно внутри цикла) — работа с потоками. Существуют методы-комбинаторы потоков, которые могут заменить некоторые использования select. Например, merge в futures-concurrency является хорошей альтернативой для объединения нескольких потоков вместе.
Заключительные слова
В этом разделе мы говорили о двух способах запуска групп фьючерсов конкурентно. Соединение фьючерсов означает ожидание завершения их всех; выбор (также known как гонка) фьючерсов означает ожидание завершения первого. В отличие от порождения задач, эти композиции не используют параллелизм.
И join, и select работают с наборами фьючерсов, которые известны заранее (часто при написании программы, а не во время выполнения). Иногда фьючерсы, которые нужно скомпоновать, не известны заранее — фьючерсы должны добавляться в набор компонуемых фьючерсов по мере их выполнения. Для этого нам нужны потоки, которые имеют свои собственные операции композиции.
Стоит повторить, что хотя эти операторы композиции мощны и выразительны, часто проще и уместнее использовать задачи и порождение: параллелизм часто желателен, у вас меньше вероятность ошибок, связанных с отменой или блокировкой, и распределение ресурсов обычно более справедливо (или, по крайней мере, проще) и более предсказуемо.
-
Выражения должны иметь тип, который реализует
IntoFuture. Выражение вычисляется и преобразуется в фьючерс макросом. Т.е. им не обязательно вычисляться в фьючерс, а во что-то, что может быть преобразовано в фьючерс, но это довольно незначительное различие. Сами выражения вычисляются последовательно до выполнения любых результирующих фьючерсов. ↩
Каналы, блокировки и синхронизация
Примечание о специфичности примитивов синхронизации для среды выполнения.
Почему нам нужны асинхронные примитивы вместо использования синхронных.
Каналы (Channels)
- В основном те же, что и в std, но с
await- Общение между задачами (в одном потоке или разных)
- Одноразовый канал (one shot)
- Многопроизводительный, многопотребительский (mpsc)
- Другие каналы
- Ограниченные (bounded) и неограниченные (unbounded) каналы
Каналы — это механизм для передачи сообщений между задачами. Они позволяют задачам общаться и координировать свою работу, даже если они выполняются в разных потоках. Асинхронные каналы похожи на свои синхронные аналоги из стандартной библиотеки, но используют await для операций, которые могут блокировать, таких как отправка или получение сообщений, когда канал полон или пуст.
Одноразовый канал (one shot) используется для отправки одного сообщения от отправителя к получателю. Это легковесный канал для однократной коммуникации.
Многопроизводительный, многопотребительский канал (mpsc) позволяет нескольким отправителям отправлять сообщения одному получателю. Это один из самых распространенных типов каналов.
Ограниченные (bounded) и неограниченные (unbounded) каналы отличаются по емкости. Ограниченные каналы имеют фиксированный размер буфера, и операция отправки будет ожидать (await), пока в буфере не появится место. Неограниченные каналы теоретически могут принимать бесконечное количество сообщений, но могут привести к чрезмерному потреблению памяти, если производители обгоняют потребителей.
Блокировки (Locks)
- Асинхронный Mutex
- В сравнении с
std::Mutex— может удерживаться через точки await (заимствуя мьютекс в guard, guard является Send, осведомлен о планировщике? или просто потому что lock асинхронный?), lock является асинхронным (не будет блокировать поток в ожидании доступности блокировки)- даже есть clippy lint для удержания guard через await (https://rust-lang.github.io/rust-clippy/master/index.html#await_holding_lock)
- Более дорогой, потому что может удерживаться через await
- используйте
std::Mutex, если можете- можно использовать
try_lockили если мьютекс, как ожидается, не будет в состоянии contention (соревнования)
- можно использовать
- используйте
- Блокировка не волшебным образом отпускается при yield (в этом и смысл блокировки!)
- Взаимоблокировка (deadlock) из-за удержания мьютекса через await
- задачи взаимоблокируются, но другие задачи могут прогрессировать, поэтому это может не выглядеть как взаимоблокировка в статистике процесса/инструментах/ОС
- обычный совет — ограничивайте область действия, минимизируйте блокировки, упорядочивайте блокировки, предпочитайте альтернативы
- Нет отравления (poisoning) мьютекса
lock_ownedblocking_lock- нельзя использовать в асинхронном контексте
- Применяется к другим блокировкам (следует ли перенести вышесказанное до обсуждения мьютекса конкретно? Вероятно, да)
- В сравнении с
- RWLock (Read-Write Lock)
- Семафор (Semaphore)
- Уступка (yielding)
Асинхронные блокировки, такие как Mutex и RwLock, предназначены для использования в асинхронном коде. Ключевое отличие от их синхронных аналогов в том, что они не блокируют весь поток ОС при ожидании блокировки. Вместо этого, если блокировка недоступна, задача добровольно уступает управление планировщику, позволяя выполняться другим задачам на том же потоке. Это делает их пригодными для удержания через точки await.
Однако асинхронные блокировки обычно дороже своих синхронных аналогов из-за дополнительной сложности, связанной с интеграцией в асинхронную среду выполнения. Поэтому, если вы защищаете данные, которые не требуют удержания блокировки через await (например, короткоживущие критические sections), часто лучше использовать std::sync::Mutex.
Важно: Блокировка не отпускается автоматически при достижении точки await. Она будет удерживаться до тех пор, пока MutexGuard не выйдет из области видимости или не будет явно удален. Удержание блокировки через await может легко привести к взаимоблокировкам, если другая задача попытается получить ту же блокировку.
RWLock (Read-Write Lock) позволяет нескольким читателям одновременно получать доступ к данным, но только одному писателю. Это полезно, когда данные часто читаются, но редко записываются.
Семафор (Semaphore) ограничивает количество задач, которые могут одновременно получить доступ к ресурсу. Он поддерживает счетчик, и задачи должны приобрести "разрешение" (permit) перед доступом к ресурсу.
Уступка (Yielding) в контексте блокировок относится к добровольному освобождению процессорного времени задачей, ожидающей блокировки, чтобы другие задачи могли прогрессировать.
Другие примитивы синхронизации
- Уведомление (notify), барьер (barrier)
- OnceCell
- Атомарные операции (atomics)
Notify — это простой механизм для уведомления одной или нескольких задач о том, что произошло некоторое событие. Это легковесная альтернатива каналам, когда нужно только сигнализировать, без передачи данных.
Barrier заставляет группу задач ждать друг друга в определенной точке. Все задачи, достигающие барьера, блокируются до тех пор, пока не будет достигнуто заданное количество задач, после чего все они продолжают выполнение.
OnceCell (или его асинхронный аналог) используется для однократной инициализации значения, которое затем можно многократно читать. Это полезно для ленивой инициализации глобального состояния.
Атомарные операции предоставляют безопасный для многопоточности доступ к примитивным типам данных без использования блокировок. Они реализуются с помощью аппаратной поддержки процессора и очень эффективны. В асинхронном коде атомарные операции могут использоваться так же, как и в синхронном, поскольку они не блокируют поток.
Инструменты для асинхронного программирования
- Почему нам нужны специализированные инструменты для async
- Есть ли другие инструменты для рассмотрения
- loom
Мониторинг (Monitoring)
Трассировка и логирование (Tracing and logging)
- Проблемы с асинхронной трассировкой
- Крейт tracing (https://github.com/tokio-rs/tracing)
Отладка (Debugging)
- Понимание асинхронных backtraces (RUST_BACKTRACE и в отладчике)
- Методы отладки асинхронного кода
- Использование Tokio console для отладки
- Поддержка отладчиков (WinDbg?)
Профилирование (Profiling)
- Как async портит flamegraphs
- Как профилировать асинхронный IO
- Получение информации о среде выполнения (runtime)
- Метрики Tokio
Асинхронное программирование в Rust представляет уникальные проблемы для мониторинга, отладки и профилирования. Традиционные инструменты, разработанные для синхронного, поточного кода, часто не справляются с кооперативной многозадачностью и сложными состояниями выполнения, присущими асинхронным программам.
Мониторинг (Monitoring)
Tokio Console
Tokio Console — это мощный инструмент мониторинга и отладки для асинхронных приложений, использующих среду выполнения Tokio. Он предоставляет веб-интерфейс для наблюдения за задачами, ресурсами и асинхронными операциями в реальном времени.
Основные возможности:
- Просмотр всех активных задач и их состояния
- Мониторинг ресурсов (например, мьютексов, семафоров)
- Отслеживание асинхронных операций ввода-вывода
- Анализ производительности и выявление узких мест
Использование:
Для подключения Tokio Console к вашему приложению необходимо добавить зависимость tokio-console и настроить среду выполнения Tokio с соответствующими функциями инструментирования.
Трассировка и логирование (Tracing and logging)
Проблемы с асинхронной трассировкой
В асинхронном коде традиционное логирование может быть проблематичным из-за:
- Переключения контекста между задачами
- Одновременного выполнения множества операций
- Трудности отслеживания потока выполнения через точки
await
Крейт tracing
Крейт tracing предоставляет framework для инструментирования Rust-программ для сбора структурированных, событийно-ориентированных диагностических данных.
Преимущества для асинхронного кода:
- Распределенные трейсы (Spans): Позволяют отслеживать выполнение через асинхронные границы
- Структурированное логирование: Данные логируются как структурированные события, а не простые строки
- Интеграция с async: Специально разработан для работы с асинхронным кодом
- Производительность: Минимальные накладные расходы в production-сборках
Пример использования:
#![allow(unused)] fn main() { use tracing::{info, instrument}; #[instrument] async fn process_request(user_id: u64, data: &str) -> Result<(), Error> { info!("Processing request for user {}", user_id); // Асинхронная работа... Ok(()) } }
Отладка (Debugging)
Понимание асинхронных backtraces
Асинхронные backtraces могут быть сложными для интерпретации из-за:
- Фреймов планировщика в стеке вызовов
- Разделения логического потока выполнения между несколькими состояниями Future
- Того факта, что одна задача может выполняться в нескольких разных контекстах
Переменная окружения RUST_BACKTRACE=1 покажет backtrace, но он может содержать много внутренних фреймов среды выполнения.
Методы отладки асинхронного кода
- Логирование состояния задач: Используйте
tracingилиlogдля записи состояния до и после точекawait - Упрощенные тестовые случаи: Создавайте минимальные воспроизводимые примеры для изоляции проблем
- Принудительное выполнение в одном потоке: Используйте однопоточную среду выполнения для детерминированного поведения
- Инструменты времени выполнения: Tokio Console и аналогичные инструменты для наблюдения за состоянием выполнения
Использование Tokio console для отладки
Tokio Console может помочь в отладке:
- Взаимоблокировки: Выявление задач, ожидающих друг друга
- Голодание ресурсов: Определение задач, которые не получают времени CPU
- Утечки ресурсов: Отслеживание создания и уничтожения ресурсов
Поддержка отладчиков
Отладчики, такие как gdb, lldb и WinDbg, могут использоваться для отладки асинхронного Rust-кода, но имеют ограничения:
- Трудно соотнести состояние выполнения с исходным кодом из-за преобразований async/await
- Переменные могут быть перемещены между фреймами стека
- Состояние Future распределено по структурам данных автомата
Профилирование (Profiling)
Как async портит flamegraphs
Традиционные flamegraphs, генерируемые инструментами вроде perf или flamegraph, могут быть испорчены для асинхронного кода:
- Время, проведенное в точках
await, может выглядеть как активная работа - Фреймы планировщика доминируют в профиле
- Трудно различить, какая логическая операция выполняется
Как профилировать асинхронный IO
- Инструменты специализированные для async: Используйте
tracingс временными метками для измерения продолжительности операций - Метрики времени выполнения: Tokio предоставляет метрики для операций ввода-вывода
- Кастомное инструментирование: Добавляйте измерения вокруг критических асинхронных операций
Получение информации о среде выполнения (runtime)
Метрики Tokio
Tokio предоставляет различные метрики для мониторинга производительности:
Включение метрик:
#![allow(unused)] fn main() { use tokio::runtime::Runtime; let rt = Runtime::builder() .enable_metrics() .build()?; }
Доступные метрики:
- Количество активных задач
- Количество обработанных операций ввода-вывода
- Время, проведенное в планировщике
- Использование рабочих потоков
- Очереди задач и их размеры
Эти метрики могут быть экспортированы в системы мониторинга или использоваться для внутренней оптимизации производительности.
Другие инструменты
Loom
Loom — это инструмент для тестирования concurrent-кода в Rust, который особенно полезен для:
- Тестирования на состояние гонки (race conditions)
- Проверки корректности синхронизации
- Моделирования различных порядков выполнения
Loom выполняет exhaustive тестирование всех возможных чередований выполнения, что делает его бесценным для тестирования примитивов синхронизации и сложного асинхронного кода.
Уничтожение и очистка
- Уничтожение объектов и повторение Drop
- Общие требования к очистке в программном обеспечении
- Проблемы с async
- Может потребоваться делать что-то асинхронно во время очистки, например, отправить финальное сообщение
- Может потребоваться очистить что-то, что все еще используется асинхронно
- Может потребоваться очистка при завершении или отмене асинхронной задачи, и нет способа перехватить это
- Состояние среды выполнения во время фазы очистки (особенно если мы паникуем или что-то подобное)
- Нет асинхронного Drop
- В работе (WIP)
- Ссылка вперед на тему completion io
Отмена (Cancellation)
- Как это происходит (повторение из more-async-await.md)
- удаление (drop) фьючерса
- токен отмены (cancellation token)
- функции прерывания (abort)
- Что мы можем сделать для «перехвата» отмены
- логирование или мониторинг отмены
- Как отмена влияет на другие фьючерсы и задачи (ссылка вперед на главу о безопасности отмены, здесь должно быть просто предупреждение)
Паника (Panicking) и async
- Распространение паники между задачами (результат spawn)
- Паника, оставляющая данные в несогласованном состоянии (мьютексы Tokio)
- Вызов асинхронного кода при панике (убедитесь, что вы этого не делаете)
Паттерны для очистки
- Избегание необходимости очистки (abort/restart)
- Не использовать async для очистки и не беспокоиться слишком сильно
- Асинхронный метод очистки + "dtor bomb" (т.е., отделение очистки от уничтожения)
- Централизация/аутсорсинг очистки в отдельной задаче, потоке или объекте-супервизоре/процессе
- https://tokio.rs/tokio/topics/shutdown
Почему (пока) нет асинхронного Drop
- Примечание: это продвинутый раздел, и его чтение не обязательно
- Почему асинхронный Drop сложен
- Возможные решения и их проблемы
- Текущий статус
Асинхронное программирование вводит уникальные проблемы, когда дело доходит до уничтожения объектов и очистки ресурсов. В этом разделе мы рассмотрим эти проблемы и существующие подходы к их решению.
Уничтожение объектов и повторение Drop
В Rust деструкторы реализуются с помощью трейта Drop. Когда объект выходит из области видимости, автоматически вызывается его метод drop:
#![allow(unused)] fn main() { impl Drop for MyStruct { fn drop(&mut self) { // Синхронная очистка ресурсов println!("Object is being destroyed"); } } }
Однако метод drop является синхронным и не может быть асинхронным. Это создает фундаментальное ограничение для асинхронной очистки.
Общие требования к очистке в программном обеспечении
Типичные сценарии очистки включают:
- Закрытие сетевых соединений
- Сохранение состояния на диск
- Освобождение блокировок и ресурсов
- Уведомление других компонентов системы
Проблемы с async
Асинхронные операции при очистке
Часто требуется выполнять асинхронные операции во время очистки:
#![allow(unused)] fn main() { // НЕ РАБОТАЕТ - Drop не может быть async impl Drop for DatabaseConnection { async fn drop(&mut self) { // ❌ Не компилируется self.send_final_metrics().await; self.close_gracefully().await; } } }
Очистка используемых асинхронно ресурсов
Ресурсы могут все использоваться асинхронными задачами в момент, когда требуется их очистка, что требует координации.
Очистка при завершении задач
Асинхронные задачи могут завершаться или отменяться без явного вызова методов очистки.
Состояние среды выполнения
Во время очистки (особенно при панике) среда выполнения может находиться в нестабильном состоянии, что затрудняет выполнение асинхронных операций.
Отмена (Cancellation)
Как происходит отмена
- Удаление фьючерса: Когда фьючерс удаляется (выходит из области видимости), он отменяется
- Токены отмены: Явная сигнализация через
CancellationToken - Функции прерывания: Вызов
abort()наJoinHandle
Перехват отмены
Хотя нельзя "перехватить" отмену напрямую, можно использовать паттерны для логирования и мониторинга:
#![allow(unused)] fn main() { async fn critical_operation(cancel: CancellationToken) -> Result<()> { tokio::select! { result = async { // Основная работа do_work().await } => result, _ = cancel.cancelled() => { log::warn!("Operation was cancelled"); Err(Error::Cancelled) } } } }
Паника (Panicking) и async
Распространение паники между задачами
Когда задача, порожденная с помощью tokio::spawn, паникует, паника не распространяется автоматически на родительскую задачу. Вместо этого JoinHandle возвращает Err при await:
#![allow(unused)] fn main() { let handle = tokio::spawn(async { panic!("This panic is caught by the runtime"); }); match handle.await { Ok(result) => println!("Task succeeded: {:?}", result), Err(err) => println!("Task panicked: {:?}", err), // Сработает здесь } }
Несогласованность данных
Паника может оставить данные в мьютексах в несогласованном состоянии. В отличие от std::sync::Mutex, мьютексы Tokio не отравляются при панике.
Вызов async кода при панике
Выполнение асинхронного кода во время паники (например, в деструкторе) крайне опасно, так как среда выполнения может находиться в нестабильном состоянии.
Паттерны для очистки
Избегание необходимости очистки
В некоторых системах проще перезапустить процесс/сервис, чем пытаться очистить сложное состояние.
Синхронная очистка
Для многих случаев достаточно синхронной очистки. Если асинхронные операции не критичны, можно просто игнорировать их при очистке.
Асинхронный метод очистки + "dtor bomb"
Отделение асинхронной очистки от деструктора:
#![allow(unused)] fn main() { struct Resource { // поля ресурса cleaned_up: bool, } impl Resource { async fn cleanup(&mut self) -> Result<()> { // Асинхронная очистка self.send_final_message().await?; self.cleaned_up = true; Ok(()) } } impl Drop for Resource { fn drop(&mut self) { if !self.cleaned_up { // "Бомба" - логируем ошибку или паникуем eprintln!("WARNING: Resource was not properly cleaned up"); // Или: panic!("Resource must be cleaned up before drop"); } } } }
Централизованная очистка
Создание отдельной задачи или сервиса для управления очисткой:
#![allow(unused)] fn main() { struct CleanupManager { shutdown_tx: tokio::sync::mpsc::Sender<CleanupCommand>, } impl CleanupManager { async fn shutdown(&self) { // Отправляем команду на очистку всем компонентам let _ = self.shutdown_tx.send(CleanupCommand::Shutdown).await; } } }
Почему (пока) нет асинхронного Drop
Почему асинхронный Drop сложен
- Семантика времени жизни: Асинхронные операции требуют активной среды выполнения, которая может быть недоступна во время уничтожения
- Порядок уничтожения: Традиционный порядок LIFO (стек) разрушается при асинхронных операциях
- Ошибки при очистке: Обработка ошибок в деструкторах уже сложна, а асинхронность усугубляет проблему
- Интеграция с существующей экосистемой: Изменение поведения
Dropзатронуло бы всю экосистему Rust
Возможные решения и их проблемы
- Новый трейт
AsyncDrop: Параллельный трейт для асинхронной очистки, но это создает дублирование API - Изменение семантики
Drop: СделатьDropасинхронным, но это ломающее изменение - Макросы или атрибуты: Генерация кода для обработки асинхронной очистки, но это может быть неэргономично
Текущий статус
На момент написания, асинхронный Drop все еще находится на стадии исследований и предложений. Ведутся активные обсуждения в рабочих группах Rust, но стабильного решения еще не представлено.
Рекомендация: Используйте явные методы асинхронной очистки (как показано в паттернах выше) и убедитесь, что критические ресурсы должным образом очищаются до уничтожения объектов.
Фьючерсы (Futures)
Мы много говорили о фьючерсах в предыдущих главах; они являются ключевой частью асинхронного программирования в Rust! В этой главе мы углубимся в некоторые детали того, что такое фьючерсы, как они работают, и рассмотрим библиотеки для работы с фьючерсами напрямую.
Трейты Future и IntoFuture
- Future
- Ассоциированный тип
Output - Без реальных деталей здесь, опрос (polling) в следующем разделе, ссылка на продвинутые разделы о Pin, исполнителях (executors)/wakeraх
- Ассоциированный тип
- IntoFuture
- Использование - общее, в await, асинхронный строительный паттерн (builder pattern) (плюсы и минусы использования)
- Боксирование (Boxing) фьючерсов,
Box<dyn Future>и как это раньше было распространено и необходимо, но в основном не сейчас, за исключением рекурсии и т.д.
Опрос (Polling)
- что это такое и кто его выполняет, тип
PollReady- конечное состояние
- как это связано с
await drop= отмена (cancel)- для фьючерсов и, следовательно, задач
- последствия для асинхронного программирования в целом
- ссылка на главу о безопасности отмены (cancellation safety)
"Плавление" (Fusing)
Крейт futures-rs
- История и цель
- см. главу о потоках (streams)
- помощники для написания исполнителей (executors) или другого низкоуровневого кода с фьючерсами
- закрепление (pinning) и боксирование (boxing)
- исполнитель как частичная среда выполнения (см. альтернативные среды выполнения в справочнике)
- TryFuture
- удобные фьючерсы:
pending,ready,ok/errи т.д. - функции-комбинаторы в
FutureExt - альтернатива вещам из Tokio
- функции
- трейты IO
Крейт futures-concurrency
https://docs.rs/futures-concurrency/latest/futures_concurrency/
Фьючерсы — это основа асинхронного программирования в Rust. Понимание их внутреннего устройства необходимо для написания эффективного и корректного асинхронного кода.
Трейты Future и IntoFuture
Трейт Future
Трейт Future представляет собой асинхронную операцию, которая может производить значение в какой-то момент в будущем.
#![allow(unused)] fn main() { pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Ассоциированный тип Output определяет тип значения, которое производит фьючерс при успешном завершении.
Метод poll используется для продвижения фьючерса вперед. Он возвращает Poll::Ready(Output), когда фьючерс завершен, или Poll::Pending, если он еще не готов.
Трейт IntoFuture
Трейт IntoFuture позволяет типам, которые не являются фьючерсами, быть преобразованными в фьючерсы. Это используется оператором .await для автоматического преобразования значений.
#![allow(unused)] fn main() { pub trait IntoFuture { type Output; type IntoFuture: Future<Output = Self::Output>; fn into_future(self) -> Self::IntoFuture; } }
Использование в await: Когда вы пишете expression.await, Rust автоматически вызывает into_future() для expression, если это необходимо.
Асинхронный строительный паттерн: IntoFuture позволяет создавать API, где методы возвращают не фьючерсы, а строительные объекты, которые затем преобразуются в фьючерсы:
#![allow(unused)] fn main() { struct QueryBuilder { ... } impl QueryBuilder { fn filter(self, condition: Filter) -> Self { ... } fn limit(self, n: usize) -> Self { ... } } impl IntoFuture for QueryBuilder { type Output = Result<Vec<Record>>; type IntoFuture = QueryFuture; fn into_future(self) -> Self::IntoFuture { QueryFuture::new(self) } } // Использование: let results = query_builder .filter(some_filter) .limit(10) .await?; }
Боксирование фьючерсов
Box<dyn Future<Output = T>> используется, когда вам нужна трейт-объект для фьючерса. Раньше это было необходимо для многих сценариев, но с появлением impl Trait и улучшенной работой компилятора с временами жизни, необходимость в этом значительно уменьшилась.
Текущие случаи использования:
- Рекурсивные фьючерсы
- Гетерогенные коллекции фьючерсов
- Динамическая диспетчеризация
#![allow(unused)] fn main() { // Рекурсивный фьючерс требует боксинга fn recursive_future(n: u32) -> BoxFuture<'static, u32> { async move { if n == 0 { 1 } else { recursive_future(n - 1).await + 1 } }.boxed() } }
Опрос (Polling)
Что такое опрос и кто его выполняет
Опрос — это механизм, с помощью которого фьючерс продвигается вперед. Исполнитель (executor) многократно вызывает метод poll фьючерса, пока он не вернет Poll::Ready.
Тип Poll:
#![allow(unused)] fn main() { pub enum Poll<T> { Ready(T), Pending, } }
Ready(T): Фьючерс завершен и произвел значениеTPending: Фьючерс еще не готов и должен быть опрошен позже
Связь с await
Оператор await скрывает сложность опроса от программиста. Когда вы пишете future.await, компилятор генерирует код, который:
- Опросит фьючерс
- Если
Pending, уступит управление планировщику - Когда фьючерс будет готов, продолжит выполнение
Удаление (Drop) = Отмена (Cancel)
Когда фьючерс удаляется (выходит из области видимости), он отменяется. Это имеет важные последствия:
Для фьючерсов: Любые ресурсы, удерживаемые фьючерсом, освобождаются, но фьючерс не получает уведомления об отмене.
Для задач: Когда задача отменяется, все ее фьючерсы удаляются, что может привести к потере данных, если фьючерс находился в середине операции.
Последствия для программирования: Необходимо проектировать фьючерсы так, чтобы они были безопасны к отмене (cancellation safe).
"Плавление" (Fusing)
Фьючерс, который после возврата Poll::Ready всегда должен возвращать Poll::Pending, называется "плавким" (fused). Это предотвращает неправильное повторное использование завершенного фьючерса.
#![allow(unused)] fn main() { use futures::future::Fuse; use futures::FutureExt; let mut future = async { 42 }.fuse(); // Первый опрос возвращает Ready(42) assert_eq!(future.poll(...), Poll::Ready(42)); // Все последующие опросы возвращают Pending assert_eq!(future.poll(...), Poll::Pending); }
Крейт futures-rs
История и цель
futures-rs — это foundational библиотека для асинхронного программирования в Rust. Она предоставляет:
- Базовые трейты и типы (
Future,Stream,Sink,AsyncRead,AsyncWrite) - Комбинаторы и утилиты для работы с фьючерсами
- Инфраструктуру для создания исполнителей и реакторов
Помощники для низкоуровневых операций
Помощники для закрепления (pinning):
#![allow(unused)] fn main() { use futures::pin_mut; let future = async { 42 }; pin_mut!(future); // Закрепляет future в памяти }
Боксирование:
#![allow(unused)] fn main() { use futures::future::BoxFuture; use futures::FutureExt; fn returns_boxed_future() -> BoxFuture<'static, i32> { async { 42 }.boxed() } }
Исполнитель как частичная среда выполнения
futures::executor предоставляет простой однопоточный исполнитель, полезный для тестирования и простых случаев:
#![allow(unused)] fn main() { use futures::executor::block_on; let result = block_on(async { some_async_function().await }); }
TryFuture
TryFuture — это фьючерс, который производит Result:
#![allow(unused)] fn main() { pub trait TryFuture: Future<Output = Result<Self::Ok, Self::Err>> { type Ok; type Err; } }
Комбинаторы TryFutureExt предоставляют методы для работы с фьючерсами, возвращающими Result.
Удобные фьючерсы
pending(): Фьючерс, который никогда не завершаетсяready(value): Немедленно завершающийся фьючерсok(value),err(error): Фьючерсы, возвращающиеResult
Функции-комбинаторы в FutureExt
FutureExt добавляет методы к любым типам, реализующим Future:
#![allow(unused)] fn main() { use futures::FutureExt; let future = async { 42 } .map(|x| x * 2) // Преобразование результата .then(|x| async { x }) // Цепочка с другим фьючерсом .boxed(); // Боксирование }
Альтернатива Tokio
futures-rs предоставляет альтернативные реализации для:
- Асинхронных трейтов ввода-вывода (
AsyncRead,AsyncWrite) - Каналов и примитивов синхронизации
- Таймеров и утилит времени
Крейт futures-concurrency
futures-concurrency предоставляет эргономичные API для конкурентного выполнения фьючерсов.
Основные возможности
Join для кортежей и массивов:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; let (a, b) = (future_a, future_b).join().await; let results = [future1, future2, future3].join().await; }
Race (гонка) фьючерсов:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; let result = (future_a, future_b).race().await; // Первый завершившийся }
Try-вариации:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; // try_join отменяет все фьючерсы при первой ошибке let result: Result<(A, B), E> = (future_a, future_b).try_join().await; }
Преимущества перед макросами
По сравнению с макросами join! и select!, futures-concurrency предлагает:
- Более чистый синтаксис
- Лучшую интеграцию с системами типов
- Поддержку динамических коллекций фьючерсов
#![allow(unused)] fn main() { // С динамической коллекцией let futures: Vec<impl Future<Output = i32>> = get_futures(); let results: Vec<i32> = futures.join().await; }
Этот крейт особенно полезен в случаях, когда набор фьючерсов определяется во время выполнения или когда нужна более сильная типизация.
Среда выполнения и связанные с ней вопросы
Запуск асинхронного кода
- Явный запуск vs async main
- Концепция контекста tokio
- block_on
- Среда выполнения, отраженная в коде (Runtime, Handle)
- Завершение работы среды выполнения
Потоки и задачи
- Работа по умолчанию с кражеством задач (work stealing), многопоточность
- повторное рассмотрение ограничений Send + 'static
- yield
- spawn-local
- spawn-blocking (повторение), block-in-place
- Специфичные для tokio особенности по уступке другим потокам, локальные vs глобальные очереди и т.д.
Параметры конфигурации
- Размер пула потоков
- Однопоточность, один поток на ядро и т.д.
Альтернативные среды выполнения
- Почему вы можете захотеть использовать другую среду выполнения или реализовать свою собственную
- Какие вариации существуют на высоком уровне проектирования
- Ссылка вперед на продвинутые главы
Среда выполнения (runtime) является сердцем любой асинхронной программы на Rust. Она отвечает за планирование и выполнение асинхронных задач. В этом разделе мы подробно рассмотрим, как работают среды выполнения и с какими проблемами можно столкнуться.
Запуск асинхронного кода
Явный запуск vs async main
Существует два основных подхода к запуску асинхронного кода:
Async main (использование макроса):
#[tokio::main] async fn main() { // Асинхронный код some_async_function().await; }
Макрос #[tokio::main] разворачивается в код, который создает среду выполнения и запускает асинхронную функцию main.
Явный запуск:
fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { // Асинхронный код some_async_function().await; }); }
Явное создание среды выполнения дает больше контроля над конфигурацией.
Концепция контекста tokio
Tokio использует концепцию thread-local контекста для отслеживания текущей среды выполнения. Это позволяет функциям вроде tokio::spawn работать без явной передачи ссылки на среду выполнения.
#[tokio::main] async fn main() { // Внутри этого блока доступен контекст Tokio tokio::spawn(async { // Эта задача будет запущена в контексте текущей среды выполнения }); }
block_on
Функция block_on блокирует текущий поток до завершения фьючерса:
#![allow(unused)] fn main() { use tokio::runtime::Runtime; let rt = Runtime::new().unwrap(); let result = rt.block_on(async { some_async_function().await }); }
Важно: block_on не должна использоваться внутри асинхронного контекста, так как это может привести к взаимоблокировкам.
Среда выполнения, отраженная в коде (Runtime, Handle)
Runtime - это основная структура, представляющая среду выполнения Tokio. Она владеет рабочими потоками и ресурсами.
Handle - это легковесная ссылка на среду выполнения, которая может быть клонирована и передана между задачами:
#[tokio::main] async fn main() { let handle = tokio::runtime::Handle::current(); // Handle можно использовать для запуска задач извне async контекста std::thread::spawn(move || { handle.block_on(async { tokio::spawn(async { /* ... */ }); }); }); }
Завершение работы среды выполнения
При завершении работы среды выполнения важно правильно закрыть все ресурсы:
use tokio::runtime::Runtime; fn main() -> Result<(), Box<dyn std::error::Error>> { let rt = Runtime::new()?; // Запускаем основную логику rt.block_on(async { // Асинхронный код }); // Runtime автоматически завершает работу при выходе из области видимости // Можно также явно вызвать shutdown_timeout // rt.shutdown_timeout(Duration::from_secs(30)); Ok(()) }
Потоки и задачи
Работа по умолчанию с кражеством задач (work stealing), многопоточность
По умолчанию Tokio использует многопоточный планировщик с кражеством задач (work-stealing). Это означает:
- Каждый рабочий поток имеет свою локальную очередь задач
- Если у потока нет задач, он может "украсть" задачи из очередей других потоков
- Это обеспечивает хорошую балансировку нагрузки
Повторное рассмотрение ограничений Send + 'static
Из-за многопоточной природы среды выполнения Tokio, задачи должны быть Send + 'static:
#![allow(unused)] fn main() { // Эта задача может быть запущена в любом потоке tokio::spawn(async { // Код здесь должен быть Send }); // Эта НЕ может быть запущена через spawn let local_data = Rc::new(42); tokio::spawn(async { // ОШИБКА: Rc не является Send // println!("{}", local_data); }); }
yield
Функция yield_now позволяет задаче добровольно уступить управление планировщику:
#![allow(unused)] fn main() { async fn cooperative_task() { for i in 0..100 { if i % 10 == 0 { tokio::task::yield_now().await; } // Тяжелые вычисления } } }
spawn-local
Для задач, которые не являются Send, используйте spawn_local:
use tokio::task; #[tokio::main] async fn main() { let local_data = Rc::new(42); task::spawn_local(async move { // Это работает, так как задача выполняется в текущем потоке println!("{}", local_data); }).await.unwrap(); }
Ограничение: spawn_local требует, чтобы текущий поток был настроен для локального запуска задач.
spawn-blocking (повторение), block-in-place
spawn_blocking запускает блокирующий код в отдельном пуле потоков:
#![allow(unused)] fn main() { let result = tokio::task::spawn_blocking(|| { // Блокирующие вычисления или IO std::thread::sleep(Duration::from_secs(1)); 42 }).await?; }
block_in_place временно освобождает текущий рабочий поток для выполнения блокирующего кода:
#![allow(unused)] fn main() { tokio::task::block_in_place(|| { // Блокирующий код std::thread::sleep(Duration::from_secs(1)); }); }
Специфичные для tokio особенности
Локальные vs глобальные очереди:
- Каждый рабочий поток имеет локальную очередь LIFO
- Есть общая глобальная очередь
- Задачи из локальной очереди имеют приоритет
Уступка другим потокам: При уступке управления (yield_now), задача может быть перемещена в глобальную очередь, что дает другим потокам возможность ее выполнить.
Параметры конфигурации
Размер пула потоков
#![allow(unused)] fn main() { use tokio::runtime; let rt = runtime::Builder::new_multi_thread() .worker_threads(4) // Количество рабочих потоков .max_blocking_threads(100) // Максимум потоков для блокирующих задач .enable_time() // Включить поддержку времени .enable_io() // Включить поддержку IO .build()?; }
Однопоточность, один поток на ядро и т.д.
Однопоточная среда выполнения:
#[tokio::main(flavor = "current_thread")] async fn main() { // Все задачи выполняются в одном потоке }
Многопоточная с фиксированным количеством потоков:
#[tokio::main(flavor = "multi_thread", worker_threads = 2)] async fn main() { // Два рабочих потока }
Альтернативные среды выполнения
Почему вы можете захотеть использовать другую среду выполнения
- Специфичные требования к производительности
- Специализированные use-cases (встраиваемые системы, WASM)
- Экспериментальные функции
- Упрощение зависимостей
Какие вариации существуют на высоком уровне проектирования
Типы планировщиков:
- Work-stealing (Tokio)
- Fixed thread pool
- Single-threaded
- Actor-based
Модели ввода-вывода:
- epoll (Linux)
- kqueue (macOS, BSD)
- IOCP (Windows)
- io_uring (современный Linux)
Примеры альтернативных сред выполнения:
- async-std: Альтернатива с другим API дизайном
- smol: Минималистичная среда выполнения
- glommio: Среда выполнения с ориентацией на производительность, использующая io_uring
- bastion: Actor-based среда выполнения
Ссылка вперед на продвинутые главы
Более подробное обсуждение реализации сред выполнения, создания собственных планировщиков и продвинутых паттернов будет в продвинутых главах этого руководства.
Таймеры и обработка сигналов
Время и таймеры
- Интеграция со средой выполнения, не используйте thread::sleep и т.д.
- std Instant и Duration
- sleep
- interval
- timeout
- Специальный фьючерс vs select/race
Обработка сигналов
- Что такое обработка сигналов и почему это проблема для async?
- Сильно зависит от ОС
- См. документацию Tokio
Работа с временем и обработка сигналов — это важные аспекты асинхронного программирования, которые требуют специального подхода при использовании async/await.
Время и таймеры
Интеграция со средой выполнения, не используйте thread::sleep
В асинхронном программировании критически важно использовать асинхронные версии функций работы со временем, а не их синхронные аналоги:
#![allow(unused)] fn main() { // ПЛОХО - блокирует весь поток std::thread::sleep(Duration::from_secs(1)); // ХОРОШО - уступает управление планировщику tokio::time::sleep(Duration::from_secs(1)).await; }
Использование thread::sleep в асинхронной задаче блокирует весь поток ОС, предотвращая выполнение других задач на этом потоке.
std Instant и Duration
Типы из стандартной библиотеки std::time::Instant и std::time::Duration полностью совместимы с асинхронным кодом:
#![allow(unused)] fn main() { use std::time::{Instant, Duration}; async fn measure_performance() { let start = Instant::now(); // Асинхронная работа some_async_operation().await; let elapsed = start.elapsed(); println!("Операция заняла: {:?}", elapsed); } }
sleep
tokio::time::sleep — это асинхронная функция, которая приостанавливает выполнение текущей задачи на указанное время:
#![allow(unused)] fn main() { use tokio::time; async fn delayed_task() { println!("Начало задачи"); time::sleep(Duration::from_secs(2)).await; println!("Задача продолжена после 2 секунд"); } }
Важно: sleep не гарантирует точное время пробуждения, только минимальную задержку.
interval
interval создает периодический таймер, который "тикнул" через регулярные промежутки времени:
#![allow(unused)] fn main() { use tokio::time; async fn periodic_task() { let mut interval = time::interval(Duration::from_secs(1)); loop { interval.tick().await; // Ждет следующий "тик" println!("Прошла еще одна секунда"); // Выход по какому-то условию if should_stop() { break; } } } }
Особенности:
- Первый
tickзавершается немедленно - Последующие
tickждут до следующего интервала - Если обработка занимает больше времени, чем интервал, следующие "тики" будут пропущены
timeout
timeout оборачивает фьючерс и возвращает ошибку, если он не завершился за указанное время:
#![allow(unused)] fn main() { use tokio::time; async fn fetch_with_timeout() -> Result<String, Box<dyn std::error::Error>> { let result = time::timeout( Duration::from_secs(5), fetch_data_from_network() ).await?; Ok(result) } }
Специальный фьючерс vs select/race
timeout реализован как специальный фьючерс, а не просто комбинация sleep и select. Это дает несколько преимуществ:
С использованием timeout:
#![allow(unused)] fn main() { // Просто и понятно match time::timeout(Duration::from_secs(5), async_operation()).await { Ok(result) => println!("Успех: {:?}", result), Err(_) => println!("Таймаут"), } }
Эквивалент с select:
#![allow(unused)] fn main() { tokio::select! { result = async_operation() => { println!("Успех: {:?}", result); } _ = time::sleep(Duration::from_secs(5)) => { println!("Таймаут"); } } }
Преимущества timeout:
- Более чистый и выразительный код
- Лучшая обработка ошибок
- Проще в использовании и понимании
Обработка сигналов
Что такое обработка сигналов и почему это проблема для async?
Сигналы — это механизм в Unix-подобных системах для уведомления процессов о различных событиях (например, SIGINT для прерывания, SIGTERM для завершения).
Проблемы с обработкой сигналов в async:
- Блокирующий характер: Традиционные обработчики сигналов блокируют поток
- Интеграция с event loop: Сигналы должны быть интегрированы в цикл событий среды выполнения
- Портабельность: Разные ОС имеют разные API для работы с сигналами
Сильно зависит от ОС
Обработка сигналов значительно различается между операционными системами:
Unix-системы (Linux, macOS, BSD):
#![allow(unused)] fn main() { #[cfg(unix)] mod unix_example { use tokio::signal::unix::{signal, SignalKind}; pub async fn handle_unix_signals() -> Result<(), Box<dyn std::error::Error>> { let mut sigterm = signal(SignalKind::terminate())?; let mut sigint = signal(SignalKind::interrupt())?; tokio::select! { _ = sigterm.recv() => { println!("Получен SIGTERM"); } _ = sigint.recv() => { println!("Получен SIGINT"); } } Ok(()) } } }
Windows:
#![allow(unused)] fn main() { #[cfg(windows)] mod windows_example { use tokio::signal::windows; pub async fn handle_windows_signals() -> Result<(), Box<dyn std::error::Error>> { let mut ctrl_c = windows::ctrl_c()?; ctrl_c.recv().await; println!("Получен CTRL+C"); Ok(()) } } }
Кросс-платформенная обработка сигналов
Tokio предоставляет кросс-платформенные API для распространенных сценариев:
#![allow(unused)] fn main() { use tokio::signal; async fn graceful_shutdown() { // Ожидание CTRL+C (Unix) или CTRL+C/CTRL+BREAK (Windows) signal::ctrl_c() .await .expect("Не удалось настроить обработчик сигнала"); println!("Получен сигнал завершения, начинаем graceful shutdown"); // Здесь можно освободить ресурсы, сохранить состояние и т.д. perform_cleanup().await; println("Завершение работы"); } }
Практический пример: Graceful Shutdown
use tokio::{signal, sync::mpsc, time}; use std::time::Duration; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1); // Запускаем фоновую задачу let worker = tokio::spawn(async move { while let Some(_) = shutdown_rx.recv().await { println!("Получена команда завершения"); break; } println!("Рабочая задача завершена"); }); // Ожидаем сигнал завершения tokio::select! { _ = signal::ctrl_c() => { println!("\nПолучен CTRL+C, инициируем завершение..."); } _ = time::sleep(Duration::from_secs(30)) => { println!("Таймаут, инициируем завершение..."); } } // Отправляем команду завершения let _ = shutdown_tx.send(()).await; // Ждем завершения рабочей задачи let _ = worker.await; println!("Приложение завершено корректно"); Ok(()) }
См. документацию Tokio
Для более сложных сценариев обработки сигналов и специфичных для ОС функций обратитесь к официальной документации Tokio:
Асинхронные итераторы (ранее известные как потоки - streams)
- Stream как асинхронный итератор или как множество фьючерсов
- Работа в процессе (WIP)
- текущий статус
- трейты Stream в futures и Tokio
- ночной (nightly) трейт
- Ленивость, как у синхронных итераторов
- Закрепление (pinning) и потоки (ссылка вперед на главу о закреплении)
- "Плавленые" (fused) потоки
Потребление асинхронного итератора
while letс асинхроннымnextfor_each,for_each_concurrentcollectinto_future,buffered
Комбинаторы потоков
- Принятие фьючерса вместо замыкания
- Некоторые примеры комбинаторов
- Неупорядоченные вариации
StreamGroup
join/select/race с потоками
- Опасности с
selectв цикле - "Плавление" (fusing)
- Отличие от просто фьючерсов
- Альтернативы этим конструкциям
Stream::mergeи т.д.
Реализация асинхронного итератора
- Реализация трейта
- Практические аспекты и вспомогательные функции
- Макрос
async_iterдля потоков
Приемники (Sinks)
- https://docs.rs/futures/latest/futures/sink/index.html
Будущая работа
- Текущий статус
- https://rust-lang.github.io/rfcs/2996-async-iterator.html
async nextvspoll- Синтаксис асинхронной итерации
- (Асинхронные) генераторы
- Заимствующие итераторы (lending iterators)
Асинхронные итераторы (ранее известные как "потоки" или "streams") представляют собой последовательности значений, которые производятся асинхронно. Они являются асинхронным аналогом трейта Iterator из стандартной библиотеки.
Stream как асинхронный итератор или как множество фьючерсов
Асинхронный итератор можно рассматривать двумя способами:
-
Как асинхронный аналог
Iterator:#![allow(unused)] fn main() { trait AsyncIterator { type Item; async fn next(&mut self) -> Option<Self::Item>; } } -
Как последовательность фьючерсов, которые производят значения:
#![allow(unused)] fn main() { // Концептуально - поток это Future<Output = Option<T>> }
Работа в процессе (WIP)
Текущий статус
На момент написания, асинхронные итераторы все еще находятся в стадии активной разработки. Существует несколько конкурирующих подходов и трейтов.
Трейты Stream в futures и Tokio
Трейт futures::Stream (де-факто стандарт):
#![allow(unused)] fn main() { pub trait Stream { type Item; fn poll_next( self: Pin<&mut Self>, cx: &mut Context<'_> ) -> Poll<Option<Self::Item>>; } }
Трейт tokio_stream::Stream (совместим с futures):
#![allow(unused)] fn main() { // В основном такой же, как futures::Stream }
Ночной (nightly) трейт
В ночной версии Rust идет работа над стандартизированным трейтом:
#![allow(unused)] #![feature(async_iterator)] fn main() { use std::async_iter::AsyncIterator; // Пока что экспериментальный API }
Ленивость, как у синхронных итераторов
Как и синхронные итераторы, асинхронные итераторы ленивы - они не производят значения, пока их не запросят:
#![allow(unused)] fn main() { use futures::stream::StreamExt; let mut stream = some_async_generator(); while let Some(item) = stream.next().await { // Обрабатываем каждый элемент по мере поступления process(item).await; } }
Закрепление (pinning) и потоки
Поскольку потоки часто содержат ссылки на самих себя, они требуют закрепления (pinning) для безопасности. Метод poll_next принимает Pin<&mut Self>.
"Плавленые" (fused) потоки
"Плавленый" поток гарантирует, что после возврата None все последующие вызовы next() также вернут None:
#![allow(unused)] fn main() { use futures::stream::StreamExt; let mut stream = some_stream().fuse(); while let Some(item) = stream.next().await { // ... } // Дальнейшие вызовы stream.next().await всегда вернут None }
Потребление асинхронного итератора
while let с асинхронным next
Базовый способ итерации:
#![allow(unused)] fn main() { use futures::stream::StreamExt; let mut stream = tokio_stream::iter(vec![1, 2, 3]); while let Some(value) = stream.next().await { println!("Получено: {}", value); } }
for_each, for_each_concurrent
Последовательная обработка:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; iter(vec![1, 2, 3]) .for_each(|item| async move { println!("Обработка: {}", item); process_item(item).await; }) .await; }
Параллельная обработка:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; iter(vec![1, 2, 3, 4, 5]) .for_each_concurrent(Some(3), |item| async move { // До 3 элементов обрабатываются одновременно println!("Обработка: {}", item); tokio::time::sleep(Duration::from_secs(1)).await; }) .await; }
collect
Сбор всех элементов в коллекцию:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; let numbers: Vec<i32> = iter(vec![1, 2, 3]).collect().await; println!("Собраны: {:?}", numbers); }
into_future, buffered
into_future преобразует поток в фьючерс, который завершается первым элементом:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; let (first, rest) = iter(vec![1, 2, 3]).into_future().await; println!("Первый элемент: {:?}", first); }
buffered выполняет фьючерсы из потока с ограничением параллелизма:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; let stream = iter(vec![ async { 1 }, async { 2 }, async { 3 }, ]); let results: Vec<i32> = stream.buffered(2).collect().await; // Выполняет до 2 фьючерсов одновременно }
Комбинаторы потоков
Принятие фьючерса вместо замыкания
Многие комбинаторы потоков принимают фьючерсы, а не замыкания:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; let stream = iter(vec![1, 2, 3]) .then(|x| async move { x * 2 }) // Принимает фьючерс .map(|x| x + 1) // Принимает синхронное замыкание .collect::<Vec<_>>() .await; }
Некоторые примеры комбинаторов
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; let results = iter(1..=10) .filter(|&x| async move { x % 2 == 0 }) // Фильтрация .map(|x| x * 3) // Преобразование .take(5) // Ограничение количества .collect::<Vec<_>>() .await; }
Неупорядоченные вариации
buffer_unordered выполняет фьючерсы параллельно без сохранения порядка:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; use tokio::time::{sleep, Duration}; let stream = iter(vec![ async { sleep(Duration::from_millis(300)).await; 1 }, async { sleep(Duration::from_millis(100)).await; 2 }, async { sleep(Duration::from_millis(200)).await; 3 }, ]); let results = stream.buffer_unordered(10).collect::<Vec<_>>().await; // Порядок: [2, 3, 1] (по времени завершения) }
StreamGroup
В крейте futures-concurrency:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; use futures::stream::{StreamExt, iter}; let streams = vec![ iter(vec![1, 2, 3]), iter(vec![4, 5, 6]), ]; let mut group = streams.merge(); // Объединение потоков while let Some(item) = group.next().await { println!("Получено из любого потока: {}", item); } }
join/select/race с потоками
Опасности с select в цикле
Использование select! с потоками в цикле может быть опасным:
#![allow(unused)] fn main() { use tokio_stream::StreamExt; let mut stream_a = some_stream(); let mut stream_b = another_stream(); loop { tokio::select! { Some(item) = stream_a.next() => { process_a(item).await; } Some(item) = stream_b.next() => { process_b(item).await; } else => break, } } }
Проблема: Если поток завершился (вернул None), он все равно будет опрашиваться в следующих итерациях.
"Плавление" (fusing)
Решение - использование "плавленых" потоков:
#![allow(unused)] fn main() { use futures::stream::StreamExt; let mut stream_a = some_stream().fuse(); let mut stream_b = another_stream().fuse(); loop { tokio::select! { item = stream_a.next() => { if let Some(value) = item { process_a(value).await; } else { // stream_a завершен } } item = stream_b.next() => { if let Some(value) = item { process_b(value).await; } else { // stream_b завершен } } // Выход, когда оба потока завершены () = async { if stream_a.is_terminated() && stream_b.is_terminated() } => { break; } } } }
Отличие от просто фьючерсов
Потоки производят множественные значения с течением времени, в то время как фьючерсы производят одно значение.
Альтернативы
Stream::merge объединяет несколько потоков в один:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, select}; let merged = select(stream_a, stream_b); while let Some(item) = merged.next().await { // Элементы из любого потока } }
Реализация асинхронного итератора
Реализация трейта
#![allow(unused)] fn main() { use futures::stream::Stream; use std::pin::Pin; use std::task::{Context, Poll}; struct Counter { current: u32, max: u32, } impl Stream for Counter { type Item = u32; fn poll_next( mut self: Pin<&mut Self>, _cx: &mut Context<'_>, ) -> Poll<Option<Self::Item>> { if self.current < self.max { let current = self.current; self.current += 1; Poll::Ready(Some(current)) } else { Poll::Ready(None) } } } }
Практические аспекты и вспомогательные функции
Использование готовых комбинаторов часто проще, чем реализация с нуля:
#![allow(unused)] fn main() { use futures::stream::{StreamExt, iter}; // Вместо реализации Stream, используйте готовые строительные блоки let stream = iter(0..10) .filter(|&x| async move { x % 2 == 0 }) .map(|x| x * 2); }
Макрос async_stream для потоков
Крейт tokio-stream предоставляет макрос для создания потоков:
#![allow(unused)] fn main() { use tokio_stream::{StreamExt, wrappers::ReceiverStream}; use tokio::sync::mpsc; let (tx, rx) = mpsc::channel(32); let stream = ReceiverStream::new(rx); // Или с макросом (если доступен) // let stream = async_stream::stream! { // for i in 0..10 { // yield i; // tokio::time::sleep(Duration::from_millis(100)).await; // } // }; }
Приемники (Sinks)
Трейт Sink представляет собой потребителя значений, который может принимать их асинхронно:
#![allow(unused)] fn main() { use futures::sink::Sink; use std::pin::Pin; use std::task::{Context, Poll}; pub trait Sink<Item> { type Error; fn poll_ready( self: Pin<&mut Self>, cx: &mut Context<'_> ) -> Poll<Result<(), Self::Error>>; fn start_send( self: Pin<&mut Self>, item: Item ) -> Result<(), Self::Error>; fn poll_flush( self: Pin<&mut Self>, cx: &mut Context<'_> ) -> Poll<Result<(), Self::Error>>; fn poll_close( self: Pin<&mut Self>, cx: &mut Context<'_> ) -> Poll<Result<(), Self::Error>>; } }
Sinks используются для каналов, сокетов и других получателей данных.
Будущая работа
Текущий статус
RFC 2996 предлагает стандартизацию асинхронных итераторов:
https://rust-lang.github.io/rfcs/2996-async-iterator.html
async next vs poll
Текущие дебаты сосредоточены на том, должен ли трейт использовать async fn next() или метод poll_next.
Синтаксис асинхронной итерации
Возможный будущий синтаксис:
#![allow(unused)] fn main() { for await item in stream { println!("{}", item); } }
(Асинхронные) генераторы
Генераторы могут упростить создание потоков:
#![allow(unused)] fn main() { async fn* number_generator() -> impl AsyncIterator<Item = u32> { for i in 0.. { yield i; tokio::time::sleep(Duration::from_secs(1)).await; } } }
Заимствующие итераторы (lending iterators)
Также известные как "streaming iterators", они позволяют итераторам возвращать заимствованные данные, что особенно полезно для асинхронных сценариев с нулевым копированием.
Отмена и безопасность отмены
- Внутренняя vs внешняя отмена
- Потоки vs фьючерсы
drop = cancelтолько в точках await- Полезная функция, но все несколько резкая и неожиданная
- Другие механизмы отмены: abort, cancellation tokens
Безопасность отмены
- Не проблема безопасности памяти или состояния гонки
- Потеря данных или другие логические ошибки
- Разные определения/названия
- определение Tokio
- общее определение/безопасность остановки (halt safety)
- применение идеи реплицированного фьючерса
- Простая потеря данных
- Проблема возобновления
- Проблема с
selectили подобным в циклах - Разделение состояния между фьючерсом и контекстом как коренная причина
Отмена (cancellation) — это фундаментальная концепция в асинхронном программировании на Rust, которая имеет важные последствия для корректности программ.
Отмена и безопасность отмены
Внутренняя vs внешняя отмена
Внутренняя отмена происходит, когда фьючерс решает самостоятельно завершиться:
#![allow(unused)] fn main() { async fn may_cancel_internally() -> Option<String> { if some_condition().await { None // Внутренняя отмена } else { Some("result".to_string()) } } }
Внешняя отмена происходит, когда среда выполнения или другой код решает отменить фьючерс:
#![allow(unused)] fn main() { let handle = tokio::spawn(async_task()); handle.abort(); // Внешняя отмена }
Потоки vs фьючерсы
В отличие от потоков ОС, которые могут быть прерваны в любой точке выполнения, асинхронные задачи в Rust могут быть отменены только в точках await. Это делает отмену более предсказуемой, но все равно требующей осторожности.
drop = cancel только в точках await
Ключевое правило: фьючерс может быть отменен только когда он возвращает Poll::Pending. На практике это означает точки await:
#![allow(unused)] fn main() { async fn vulnerable_function() { step_one(); // Не может быть отменен здесь intermediate_work(); // Не может быть отменен здесь step_two().await; // МОЖЕТ быть отменен здесь ← more_work(); // Не может быть отменен здесь final_step().await; // МОЖЕТ быть отменен здесь ← } }
Полезная функция, но все несколько резкая и неожиданная
Отмена — это мощный инструмент для управления временем жизни задач, но она может быть неожиданной:
#![allow(unused)] fn main() { async fn process_data() -> Result<(), Error> { let data = read_data().await?; let processed = expensive_processing(data); // Вычислительно дорогая операция write_result(processed).await?; // Может быть отменена здесь! Ok(()) } }
Если отмена произойдет после expensive_processing но до write_result, результаты вычислений будут потеряны.
Другие механизмы отмены
abort для задач:
#![allow(unused)] fn main() { let handle = tokio::spawn(async_task()); // ... handle.abort(); // Немедленная отмена задачи }
Cancellation tokens для кооперативной отмены:
#![allow(unused)] fn main() { use tokio_util::sync::CancellationToken; let token = CancellationToken::new(); let cloned_token = token.clone(); tokio::spawn(async move { tokio::select! { _ = cloned_token.cancelled() => { println!("Задача отменена через токен"); } _ = async_work() => { println!("Задача завершена нормально"); } } }); // Позже... token.cancel(); // Сигнал отмены }
Безопасность отмены
Не проблема безопасности памяти или состояния гонки
Безопасность отмены не связана с безопасностью памяти Rust — отмена не может привести к неопределенному поведению. Однако она может привести к логическим ошибкам.
Потеря данных или другие логические ошибки
Пример потери данных:
#![allow(unused)] fn main() { async fn transfer_funds(from: &mut Account, to: &mut Account, amount: u64) -> Result<()> { from.balance -= amount; // Сняли деньги // ← Точка отмены! to.balance += amount; // Не выполнится, если отмена здесь Ok(()) } }
Разные определения/названия
Определение Tokio
Tokio определяет операцию как "безопасную к отмене", если она может быть безопасно отменена в любой точке await.
Общее определение/безопасность остановки (halt safety)
Более общее понятие — "безопасность остановки": код должен оставаться в согласованном состоянии, даже если его выполнение прервано в любой точке.
Применение идеи реплицированного фьючерса
Если фьючерс можно безопасно перезапустить после отмены (идемпотентность), то он безопасен к отмене.
Простая потеря данных
Самый простой вид небезопасности отмены — потеря данных:
#![allow(unused)] fn main() { async fn append_to_file(data: &[u8]) -> Result<()> { let mut file = tokio::fs::OpenOptions::new() .append(true) .open("log.txt") .await?; file.write_all(data).await?; // Может быть отменена после открытия файла file.sync_all().await?; // Но до синхронизации Ok(()) } }
Проблема возобновления
Код, который не может быть корректно возобновлен после отмены:
#![allow(unused)] fn main() { async fn process_stream(mut stream: impl Stream<Item = Data>) -> Result<()> { while let Some(data) = stream.next().await { // Отмена может произойти здесь process(data).await?; // Состояние обработки может быть потеряно } Ok(()) } }
Проблема с select или подобным в циклах
Использование select! в циклах особенно подвержено проблемам отмены:
#![allow(unused)] fn main() { async fn process_with_timeout(mut stream: impl Stream<Item = Data>) { loop { tokio::select! { Some(data) = stream.next() => { // Обрабатываем данные if let Err(_) = process(data).await { break; } } _ = tokio::time::sleep(Duration::from_secs(30)) => { println!("Таймаут"); break; } } } } }
Проблема: Если stream.next() завершится с ошибкой, но select! выберет ветку таймаута, ошибка будет потеряна.
Разделение состояния между фьючерсом и контекстом как коренная причина
Многие проблемы отмены возникают из-за разделения состояния:
#![allow(unused)] fn main() { struct Transaction { data: Vec<u8>, committed: bool, } impl Transaction { async fn commit(mut self) -> Result<()> { write_to_database(&self.data).await?; // ← Точка отмены! self.committed = true; // Не выполнится при отмене Ok(()) } } }
Решение: Атомарные операции или компенсирующие действия:
#![allow(unused)] fn main() { impl Transaction { async fn commit(mut self) -> Result<()> { // Атомарная операция - либо все, либо ничего let result = write_to_database(&self.data).await; match result { Ok(()) => { self.committed = true; Ok(()) } Err(e) => { // Откатываем изменения self.rollback().await; Err(e) } } } async fn rollback(&mut self) { // Компенсирующее действие if !self.committed { // Удаляем частично записанные данные delete_partial_data(&self.data).await; } } } }
Стратегии обеспечения безопасности отмены
1. Идемпотентные операции
Спроектируйте операции так, чтобы их можно было безопасно повторить:
#![allow(unused)] fn main() { async fn idempotent_operation(id: u64, data: &str) -> Result<()> { // Используем "upsert" вместо отдельного insert/update database.upsert(id, data).await } }
2. Компенсирующие действия
Для неидемпотентных операций предусмотрите откат:
#![allow(unused)] fn main() { async fn two_phase_operation() -> Result<()> { let temp_result = phase_one().await?; // Если отмена произойдет здесь, phase_one можно откатить match phase_two(temp_result).await { Ok(final_result) => Ok(final_result), Err(e) => { // Компенсирующее действие rollback_phase_one().await; Err(e) } } } }
3. Проверка состояния при возобновлении
#![allow(unused)] fn main() { async fn resumable_operation(state: &mut OperationState) -> Result<()> { // Проверяем, не была ли операция уже выполнена if state.is_completed { return Ok(()); } // Выполняем операцию perform_work().await?; state.is_completed = true; Ok(()) } }
4. Использование токенов отмены для кооперативного завершения
#![allow(unused)] fn main() { async fn cancellation_safe_operation( token: CancellationToken ) -> Result<()> { let checkpoint = || { if token.is_cancelled() { Err(Error::Cancelled) } else { Ok(()) } }; checkpoint()?; step_one().await; checkpoint()?; step_two().await; checkpoint()?; final_step().await; Ok(()) } }
Закрепление (Pinning)
Закрепление — это печально известная сложная концепция, обладающая некоторыми тонкими и запутанными свойствами. Этот раздел подробно рассмотрит тему (возможно, слишком подробно). Закрепление является ключевым для реализации асинхронного программирования в Rust1, но можно далеко продвинуться, никогда не сталкиваясь с закреплением и, конечно, не имея глубокого понимания.
Первый раздел даст краткое описание закрепления, которого, надеюсь, достаточно для большинства асинхронных программистов. Остальная часть этой главы предназначена для реализаторов, других, занимающихся продвинутым или низкоуровневым асинхронным программированием, и любознательных.
После краткого описания эта глава даст некоторую предысторию о семантике перемещения, прежде чем перейти к закреплению. Мы рассмотрим общую идею, затем типы Pin и Unpin, как закрепление достигает своих целей, и несколько тем о работе с закреплением на практике. Затем следуют разделы о закреплении и асинхронном программировании, а также некоторые альтернативы и расширения закрепления (для самых любознательных). В конце главы приведены ссылки на альтернативные объяснения и справочные материалы.
TL;DR
Pin помечает указатель как указывающий на объект, который не будет перемещаться до его удаления. Закрепление не встроено в язык или компилятор; оно работает путем простого ограничения доступа к изменяемым ссылкам на указываемый объект. Достаточно легко нарушить закрепление в небезопасном коде, но, как и все гарантии безопасности в небезопасном коде, ответственность за то, чтобы не делать этого, лежит на программисте.
Гарантируя, что объект не будет перемещен, закрепление делает безопасным наличие ссылок из одного поля структуры на другое (иногда называемых самоссылками). Это требуется для реализации асинхронных функций (которые реализуются как структуры данных, где переменные хранятся как поля, поскольку переменные могут ссылаться друг на друга, поля фьючерса, реализующего асинхронную функцию, должны иметь возможность ссылаться друг на друга). В основном программистам не нужно знать об этой детали, но при прямой работе с фьючерсами вам может понадобиться это, потому что сигнатура Future::poll требует, чтобы self был закреплен.
Если вы используете фьючерсы по ссылке, вам может понадобиться закрепить ссылку с помощью pin!(...), чтобы гарантировать, что ссылка все еще реализует трейт Future (это часто возникает с макросом select). Аналогично, если вы хотите вручную вызвать poll на фьючерсе (обычно потому, что вы реализуете другой фьючерс), вам понадобится закрепленная ссылка на него (используйте pin! или убедитесь, что аргументы имеют закрепленные типы). Если вы реализуете фьючерс или у вас есть закрепленная ссылка по какой-то другой причине, и вы хотите изменяемый доступ к внутренностям объекта, вам нужно будет понять раздел ниже о закрепленных полях, чтобы знать, как это сделать и когда это безопасно.
Семантика перемещения (Move semantics)
Полезной концепцией для обсуждения закрепления и связанных тем является идея мест (places). Место — это фрагмент памяти (с адресом), где может жить значение. Ссылка указывает не на фактическое значение, а на место. Вот почему *ref = ... имеет смысл: разыменование дает вам место, а не копию значения. Места хорошо известны реализаторам языков, но обычно неявны в языках программирования (в Rust они неявны). У программистов обычно есть хорошая интуиция для мест, но они могут не думать о них явно.
Помимо ссылок, переменные и обращения к полям вычисляются в места. Фактически, все, что может появиться в левой части присваивания, должно быть местом во время выполнения (поэтому места называются «lvalue» в жаргоне компиляторов).
В Rust изменяемость является свойством мест, как и «замороженность» в результате заимствования (мы можем сказать, что место заимствовано).
Присваивание в Rust перемещает данные (в основном, некоторые простые данные имеют семантику копирования, но это не так важно). Когда мы пишем let b = a;, данные, которые были в памяти в месте, идентифицируемом a, перемещаются в место, идентифицируемое b. Это означает, что после присваивания данные существуют в b, но больше не существуют в a. Или, другими словами, адрес объекта изменяется присваиванием2.
Если бы существовали указатели на место, из которого было перемещено, указатели были бы недействительны, поскольку они больше не указывают на объект. Вот почему заимствованные ссылки предотвращают перемещение: let r = &a; let b = a; незаконно, существование r предотвращает перемещение a.
Компилятор знает только о ссылках извне объекта в объект (как в приведенном выше примере, или ссылке на поле объекта). Ссылка полностью внутри объекта была бы невидима для компилятора. Представьте, если бы нам разрешили написать что-то вроде:
#![allow(unused)] fn main() { struct Bad { field: u64, r: &'self u64, } }
Мы могли бы иметь экземпляр b структуры Bad, где b.r указывает на b.field. В let a = b; внутренняя ссылка b.r на b.field невидима для компилятора, поэтому выглядит, что нет ссылок на b, и, следовательно, перемещение в a было бы нормально. Однако, если бы это произошло, то после перемещения a.r указывала бы не на a.field, как мы хотели, а на недействительную память по старому местоположению b.field, нарушая гарантии безопасности Rust.
Перемещение данных не ограничивается значениями. Данные также могут быть перемещены из уникальной ссылки. Разыменование Box перемещает данные из кучи в стек. take, replace и swap (все в std::mem) перемещают данные из изменяемой ссылки (&mut T). Перемещение из Box делает указываемое место недействительным. Перемещение из изменяемой ссылки оставляет место действительным, но содержащим другие данные.
Абстрактно, перемещение реализуется копированием битов из источника в назначение и последующим стиранием битов источника. Однако компилятор может оптимизировать это многими способами.
Закрепление (Pinning)
Важное примечание: я начну с обсуждения абстрактной концепции закрепления, которая не точно то, что выражается каким-либо конкретным типом. Мы будем делать концепцию более конкретной по мере продвижения и в итоге придем к точным определениям того, что означают разные типы, но ни один из этих типов не означает exactly то же, что и концепция закрепления, с которой мы начнем.
Объект закреплен, если он не будет перемещен или иным образом инвалидирован. Как я объяснил выше, это не новая концепция — заимствование объекта предотвращает перемещение объекта на время заимствования. Может ли объект быть перемещен или нет, не явно в типах Rust, хотя это известно компилятору (вот почему вы можете получать сообщения об ошибках «cannot move out of»). В отличие от заимствования (и временного ограничения на перемещения, вызванного заимствованием), закрепление является постоянным. Объект может измениться с незакрепленного на закрепленный, но once он закреплен, он должен оставаться закрепленным до его удаления3.
Так же, как типы указателей отражают владение и изменяемость указываемого объекта (например, Box vs &, &mut vs &), мы хотим отражать закрепленность в типах указателей тоже. Это не свойство указателя — указатель не закреплен или перемещаем — это свойство указываемого места: может ли указываемый объект быть перемещен из своего места.
Грубо, Pin<Box<T>> — это указатель на владеемый, закрепленный объект, а Pin<&mut T> — это указатель на уникально заимствованный, изменяемый, закрепленный объект (ср., &mut T, который является указателем на уникально заимствованный, изменяемый объект, который может быть закреплен или нет).
Концепция закрепления не была добавлена в Rust до версии 1.0, и по причинам обратной совместимости нет способа явно выразить, закреплен объект или нет. Мы можем only выразить, что ссылка указывает на закрепленный или не закрепленный объект.
Закрепление ортогонально изменяемости. Объект может быть изменяемым и либо закрепленным (Pin<&mut T>), либо нет (&mut T) (т.е. объект может быть изменен, и либо он закреплен на месте, либо может быть перемещен), или неизменяемым и либо закрепленным (Pin<&T>), либо нет (T) (т.е. объект не может быть изменен, и либо он не может быть перемещен, либо может быть перемещен, но не изменен). Обратите внимание, что &T не может быть изменен или перемещен, но не закреплен, потому что его неперемещаемость только временна.
Unpin
Хотя перемещение и неперемещение — это то, как мы ввели закрепление, и это somewhat подразумевается названием, Pin на самом деле мало что говорит вам о том, будет ли указываемый объект actually перемещен или нет.
Что? Вздох.
Закрепление на самом деле является контрактом о validity, а не о перемещении. Оно гарантирует, что если объект чувствителен к адресу, то его адрес не изменится (и, следовательно, адреса, производные от него, такие как адреса его полей, также не изменятся). Большинство данных в Rust не чувствительны к адресу. Их можно перемещать, и все будет в порядке. Pin гарантирует, что указываемый объект будет valid относительно своего адреса. Если указываемый объект чувствителен к адресу, то он не может быть перемещен; если он не чувствителен к адресу, то не имеет значения, перемещен он или нет.
Unpin — это трейт, который выражает, являются ли объекты чувствительными к адресу. Если объект реализует Unpin, то он не чувствителен к адресу. Если объект !Unpin, то он чувствителен к адресу. Альтернативно, если мы думаем о закреплении как о действии удержания объекта на его месте, то Unpin означает, что безопасно отменить это действие и позволить объекту быть перемещенным.
Unpin — это автотрейт, и большинство типов являются Unpin. Only типы, которые имеют поле !Unpin или которые явно отказываются, не являются Unpin. Вы можете отказаться, имея поле PhantomPinned или (если вы используете ночную версию) с impl !Unpin for ... {}.
Для типов, которые реализуют Unpin, Pin essentially ничего не делает. Pin<Box<T>> и Pin<&mut T> могут использоваться так же, как Box<T> и &mut T. Фактически, для типов Unpin закрепленные и обычные указатели могут быть свободно преобразованы друг в друга с помощью Pin::new и Pin::into_inner. Стоит повторить: Pin<...> не гарантирует, что указываемый объект не переместится, only что указываемый объект не переместится, если он !Unpin.
Практическое следствие вышесказанного заключается в том, что работа с типами Unpin и закреплением much проще, чем с типами, которые не Unpin, фактически маркер Pin basically не влияет на типы Unpin и указатели на типы Unpin, и вы можете basically игнорировать все гарантии и требования закрепления.
Unpin не следует понимать как свойство объекта alone; единственное, что Unpin меняет, — это то, как объект взаимодействует с Pin. Использование ограничения Unpin вне контекста закрепления не влияет на поведение компилятора или то, что можно сделать с объектом. Единственная причина использовать Unpin — в сочетании с закреплением или для распространения ограничения туда, где оно используется с закреплением.
Pin
Pin — это тип-маркер, он важен для проверки типов, но компилируется и не существует во время выполнения (Pin<Ptr> гарантированно имеет то же расположение в памяти и ABI, что и Ptr). Это обертка указателей (таких как Box), поэтому он ведет себя как тип указателя, но он не добавляет косвенности, Box<Foo> и Pin<Box<Foo>> одинаковы при запуске программы. Лучше думать о Pin как о модификаторе указателя, а не как об указателе самом по себе.
Pin<Ptr> означает, что указываемый объект Ptr (не сам Ptr) закреплен. То есть, Pin гарантирует, что указываемый объект (не указатель) останется valid относительно своего адреса до тех пор, пока указываемый объект не будет удален. Если указываемый объект чувствителен к адресу (т.е. !Unpin), то указываемый объект не будет перемещен.
Закрепление значений (Pinning values)
Объекты не создаются закрепленными. Объект начинается незакрепленным (и может свободно перемещаться), он становится закрепленным, когда создается закрепляющий указатель, указывающий на объект. Если объект Unpin, то это тривиально с использованием Pin::new, однако, если объект не Unpin, то его закрепление должно гарантировать, что он не может быть перемещен или инвалидирован через псевдоним.
Чтобы закрепить объект в куче, вы можете создать новый закрепляющий Box с помощью Box::pin или преобразовать существующий Box в закрепляющий Box с помощью Box::into_pin. В любом случае вы получите Pin<Box<T>>. Некоторые другие указатели (такие как Arc и Rc) имеют похожие механизмы. Для указателей, которые не имеют, или для ваших собственных типов указателей, вам нужно будет использовать Pin::new_unchecked для создания закрепленного указателя4. Это небезопасная функция, поэтому программист должен обеспечить соблюдение инвариантов Pin. То есть, что указываемый объект будет, при любых обстоятельствах, оставаться valid до вызова его деструктора. Есть некоторые тонкие детали для обеспечения этого, обратитесь к документации функции или разделу ниже как работает закрепление для получения дополнительной информации.
Box::pin закрепляет объект в месте в куче. Чтобы закрепить объект в стеке, вы можете использовать макрос pin для создания и закрепления изменяемой ссылки (Pin<&mut T>)5.
Tokio также имеет макрос pin, который делает то же самое, что и std макрос, и также поддерживает присваивание переменной внутри макроса. Крейты futures-rs и pin-utils имеют макрос pin_mut, который раньше часто использовался, но теперь устарел в пользу вышеупомянутых крейтов и функциональности, теперь находящейся в std.
Вы также можете использовать Pin::static_ref и Pin::static_mut для закрепления статической ссылки.
Использование закрепленных типов (Using pinned types)
Теоретически, использование закрепленных указателей похоже на использование любого другого типа указателя. Однако, поскольку это не самая интуитивная абстракция и поскольку у нее нет языковой поддержки, использование закрепленных указателей tends to быть довольно неэргономичным. Самый распространенный случай использования закрепления — при работе с фьючерсами и потоками, мы рассмотрим эти specifics более подробно ниже.
Использование закрепленного указателя как неизменяемо заимствованной ссылки тривиально из-за реализации Deref для Pin. Вы можете mostly просто обращаться с Pin<Ptr<T>> как с &T, используя явный deref() при необходимости. Аналогично, получение Pin<&T> довольно легко с использованием as_ref().
Самый распространенный способ работы с закрепленными типами — использование Pin<&mut T> (например, в Future::poll), однако, самый простой способ создать закрепленный объект — Box::pin, который дает Pin<Box<T>>. Вы можете преобразовать последнее в первое с помощью Pin::as_mut. Однако, без языковой поддержки для повторного использования ссылок (неявное перезаимствование), вам приходится продолжать вызывать as_mut вместо повторного использования результата. Например (из документации as_mut),
#![allow(unused)] fn main() { impl Type { fn method(self: Pin<&mut Self>) { // делаем что-то } fn call_method_twice(mut self: Pin<&mut Self>) { // `method` потребляет `self`, поэтому перезаимствуем `Pin<&mut Self>` через `as_mut`. self.as_mut().method(); self.as_mut().method(); } } }
Если вам нужно получить доступ к закрепленному указываемому объекту каким-либо другим способом, вы можете сделать это через Pin::into_inner_unchecked. Однако, это небезопасно, и вы должны быть очень осторожны, чтобы обеспечить соблюдение требований безопасности Pin.
Как работает закрепление (How pinning works)
Pin — это простая структура-обертка (aka, newtype) для указателей. Она обеспечивает работу only с указателями, требуя ограничения Deref на свой generic параметр для чего-либо полезного, однако, это только для выражения намерения, а не для сохранения безопасности. Как и большинство оберток newtype, Pin существует для выражения инварианта во время компиляции, а не для какого-либо эффекта во время выполнения. Действительно, в большинстве обстоятельств Pin и механизм закрепления completely исчезнут во время компиляции.
Если быть точным, инвариант, выраженный Pin, касается validity, а не just перемещаемости. Это также инвариант validity, который применяется only once указатель закреплен — до этого Pin не имеет эффекта и не предъявляет требований к тому, что происходит до того, как что-то закреплено. Once указатель закреплен, Pin требует (и гарантирует в безопасном коде), что указываемый объект останется valid по тому же адресу в памяти до вызова деструктора объекта.
Для неизменяемых указателей (например, заимствованных ссылок) Pin не имеет эффекта — поскольку указываемый объект не может быть изменен или заменен, нет опасности его инвалидации.
Для указателя, который позволяет изменение (например, Box или &mut), наличие прямого доступа к этому указателю или доступа к изменяемой ссылке (&mut) на указываемый объект может позволить изменить или переместить указываемый объект. Pin simply не предоставляет никакого (не-unsafe) способа получить прямой доступ к указателю или изменяемой ссылке. Обычный способ для указателя предоставить изменяемую ссылку на свой указываемый объект — реализовать DerefMut, Pin реализует DerefMut only если указываемый объект Unpin.
Эта реализация невероятно проста! Подведем итог: Pin — это структура-обертка вокруг указателя, которая предоставляет only неизменяемый доступ к указываемому объекту (и изменяемый доступ, если указываемый объект Unpin). Все остальное — детали (и тонкие инварианты для небезопасного кода). Для удобства Pin предоставляет возможность преобразования между типами Pin (всегда безопасно, поскольку указатель не может выйти из Pin) и т.д.
Pin также предоставляет небезопасные функции для создания закрепленных указателей и доступа к базовым данным. Как и все unsafe функции, поддержание инвариантов безопасности является ответственностью программиста, а не компилятора. К сожалению, инварианты безопасности для закрепления somewhat разбросаны, в том смысле, что они применяются в разных местах и их трудно описать глобально, унифицированно. Я не буду описывать их здесь подробно и отсылаю вас к документации, но попытаюсь обобщить (см. документацию модуля для подробного обзора):
- Создание нового закрепленного указателя
new_unchecked. Программист должен обеспечить, чтобы указываемый объект был закреплен (то есть соблюдал инварианты закрепления). Это требование может быть удовлетворено только типом указателя (например, в случаеBox) или может требовать участия типа указываемого объекта (например, в случае&mut). Это включает (но не ограничивается):- Не перемещение из
selfвDerefиDerefMut. - Правильную реализацию
Drop, см. гарантию drop. - Отказ от
Unpin(с использованиемPhantomPinned), если вам требуются гарантии закрепления. - Указываемый объект не может быть
#[repr(packed)].
- Не перемещение из
- Доступ к закрепленному значению
into_inner_unchecked,get_unchecked_mut,map_uncheckedиmap_unchecked_mut. Ответственность за обеспечение гарантий закрепления (включая неперемещение данных) ложится на программиста с момента доступа к данным до завершения работы их деструктора (обратите внимание, что эта область ответственности выходит за пределы небезопасного вызова и применяется к тому, что происходит с базовыми данными). - Не предоставление любого другого способа переместить данные из закрепленного типа (что потребовало бы небезопасной реализации).
Типы закрепляющих указателей (Pinning pointer types)
Мы сказали ранее, что Pin оборачивает тип указателя. Часто можно видеть Pin<Box<T>>, Pin<&T> и Pin<&mut T>. Технически, единственное требование к типу закрепляющего указателя — это то, что он реализует Deref. Однако, нет способов создать Pin<Ptr> для любых других типов указателей, кроме использования небезопасного кода (через new_unchecked). Это накладывает требования на тип указателя для обеспечения контракта закрепления:
- Реализации
DerefиDerefMutуказателя не должны перемещать данные из их указываемого объекта. - Должно быть невозможно получить ссылку
&mutна указываемый объект в любое время после созданияPin, даже после того, какPinбыл удален (вот почему нельзя безопасно построитьPin<&mut T>из&mut T). Это должно оставаться истинным через несколько шагов или через ссылки (что предотвращает использованиеRcилиArc). - Реализация
Dropдля указателя не должна перемещать (или иным образом инвалидировать) его указываемый объект.
См. документацию new_unchecked docs для большей детализации.
Pinning и Drop
Контракт закрепления применяется до тех пор, пока закрепленный объект не будет удален (технически, это означает, когда его метод drop возвращается, а не когда он вызывается). Обычно это довольно straightforward, поскольку drop вызывается автоматически при уничтожении объектов. Если вы делаете что-то вручную с жизненным циклом объекта, вам, возможно, придется подумать об этом дополнительно. Если у вас есть объект, который (или может быть) закреплен, и этот объект не Unpin, то вы должны вызвать его метод drop (используя drop_in_place) перед освобождением или повторным использованием памяти или адреса объекта. См. std docs для деталей.
Если вы реализуете тип, чувствительный к адресу (т.е. !Unpin), то вы должны быть особенно осторожны с реализацией Drop. Даже though тип self в drop — &mut Self, вы должны рассматривать тип self как Pin<&mut Self>. Другими словами, вы должны обеспечить, чтобы объект оставался valid до возврата из функции drop. Один из способов сделать это явным в исходном коде — следовать следующей идиоме:
#![allow(unused)] fn main() { impl Drop for Type { fn drop(&mut self) { // `new_unchecked` нормально, потому что мы знаем, что это значение никогда не используется // снова после удаления. inner_drop(unsafe { Pin::new_unchecked(self)}); fn inner_drop(this: Pin<&mut Self>) { // Фактический код удаления идет здесь. } } } }
Обратите внимание, что требования к validity будут зависеть от реализуемого типа. Рекомендуется точно определять эти требования, особенно касающиеся уничтожения объектов, особенно если может быть задействовано несколько объектов (например, интрузивный связный список). Обеспечение корректности здесь, вероятно, будет интересным!
Закрепленный self в методах (Pinned self in methods)
Вызов методов для закрепленных типов приводит к размышлениям о типе self в этих методах. Если метод не нуждается в изменении self, то вы все еще можете использовать &self, поскольку Pin<...> может разыменовываться в заимствованную ссылку. Однако, если вам нужно изменить self (и ваш тип не Unpin), то вам нужно выбирать между &mut self и self: Pin<&mut Self> (хотя закрепленные указатели не могут быть неявно приведены к последнему типу, их можно легко преобразовать с помощью Pin::as_mut).
Использование &mut self делает реализацию легкой, но означает, что метод не может быть вызван для закрепленного объекта. Использование self: Pin<&mut Self> означает необходимость учитывать проекцию закрепления (см. следующий раздел) и может быть вызван only для закрепленного объекта. Хотя все это немного сбивает с толку, это интуитивно имеет смысл, когда вы помните, что закрепление — это фазовое понятие — объекты начинаются незакрепленными, и в какой-то момент претерпевают фазовое изменение, чтобы стать закрепленными. Методы &mut self — это те, которые могут быть вызваны в первой (незакрепленной) фазе, а методы self: Pin<&mut Self> — это те, которые могут быть вызваны во второй (закрепленной) фазе.
Обратите внимание, что drop принимает &mut self (даже though он может быть вызван в любой фазе). Это связано с ограничением языка и желанием обратной совместимости. Это требует специального обращения в компиляторе и сопровождается требованиями безопасности.
Закрепленные поля, структурное закрепление и проекция закрепления
Учитывая, что объект закреплен, что это говорит нам о «закрепленности» его полей? Ответ зависит от выбора, сделанного реализатором типа данных, нет универсального ответа (действительно, он может быть разным для разных полей одного и того же объекта).
Если закрепленность объекта распространяется на поле, мы говорим, что поле проявляет «структурное закрепление» или что закрепление проецируется на поле. В этом случае должен быть метод проекции fn get_field(self: Pin<&mut Self>) -> Pin<&mut Field>. Если поле не структурно закреплено, то метод проекции должен иметь сигнатуру fn get_field(self: Pin<&mut Self>) -> &mut Field. Реализация любого метода (или реализация аналогичного кода) требует unsafe кода, и любой выбор имеет последствия для безопасности. Распространение закрепления должно быть consistent, поле должно always быть структурно закрепленным или нет, почти always небезопасно, чтобы поле было структурно закрепленным в одни времена и нет в другие.
Закрепление должно проецироваться на поле, если поле является чувствительной к адресу частью агрегатного типа данных. То есть, если закрепление агрегата зависит от закрепления поля, то закрепление должно проецироваться на это поле. Например, если есть ссылка из другой части агрегата в поле, или если есть самоссылка внутри поля, то закрепление должно проецироваться на поле. С другой стороны, для универсальной коллекции закрепление не needs проецироваться на ее содержимое, поскольку коллекция не relies на их поведение (это потому, что коллекция не может relies на реализацию универсальных элементов, которые она содержит, поэтому сама коллекция не может relies на адреса своих элементов).
При написании небезопасного кода вы можете only предполагать, что гарантии закрепления применяются к полям объекта, которые структурно закреплены. С другой стороны, вы можете безопасно относиться к не структурно закрепленным полям как к перемещаемым и не беспокоиться о требованиях закрепления для них. В частности, структура может быть Unpin, even if поле не является, as long as это поле always treated как не структурно закрепленное.
Если поле структурно закреплено, то требования закрепления на агрегатную структуру распространяются на поле. Ни при каких обстоятельствах код не может перемещать содержимое поля, пока агрегат закреплен (это always потребует небезопасного кода). Структурно закрепленные поля должны быть удалены before они перемещены (включая освобождение) even в случае паники, что означает, что необходимо проявлять осторожность внутри реализации Drop агрегата. Более того, агрегатная структура не может быть Unpin, unless все ее структурно закрепленные поля являются.
Макросы для проекции закрепления (Macros for pin projection)
Существуют макросы, доступные для помощи с проекцией закрепления.
Крейт pin-project предоставляет атрибутный макрос #[pin_project] (и вспомогательный атрибут #[pin]), который реализует безопасную проекцию закрепления для вас, создавая закрепленную версию аннотированного типа, к которой можно получить доступ с помощью метода project на аннотированном типе.
Pin-project-lite — это альтернатива, использующая декларативный макрос (pin_project!), который работает очень похоже на pin-project. Pin-project-lite является легковесным в том смысле, что это не процедурный макрос и, следовательно, не добавляет зависимостей для реализации процедурных макросов в ваш проект. Однако он менее выразителен, чем pin-project, и не дает пользовательских сообщений об ошибках. Pin-project-lite рекомендуется, если вы хотите избежать добавления зависимостей процедурных макросов, а pin-project рекомендуется в противном случае.
Pin-utils предоставляет макрос unsafe_pinned для помощи в реализации проекции закрепления, но весь крейт устарел в пользу вышеупомянутых крейтов и функциональности, теперь находящейся в std.
Присваивание закрепленному указателю
Generally безопасно присваивать в закрепленный указатель. Хотя это нельзя сделать обычным способом (*p = ...), это можно сделать с помощью Pin::set. Более generally, вы можете использовать небезопасный код для присваивания в поля указываемого объекта.
Использование Pin::set always безопасно, since ранее закрепленный указываемый объект будет удален, выполняя требования закрепления, и новый указываемый объект не закреплен, until перемещение в закрепленное место завершено. Присваивание в отдельные поля не automatically нарушает требования закрепления, но необходимо проявлять осторожность, чтобы обеспечить, чтобы объект в целом оставался valid. Например, если в поле присваивается значение, то любые другие поля, которые ссылаются на это поле, должны still быть valid с новым объектом (это не часть требований закрепления, но может быть частью других инвариантов объекта).
Копирование одного закрепленного объекта в другое закрепленное место может быть сделано only в небезопасном коде, как безопасность поддерживается, зависит от отдельного объекта. Нет general нарушения требований закрепления — объект, который заменяется, не перемещается, и объект, который копируется, тоже. Однако validity объекта, который заменяется, может иметь требования безопасности, которые usually защищаются закреплением, но в этом случае должны быть установлены программистом. Например, если у нас есть структура с двумя полями a и b, где b ссылается на a, эта ссылка требует закрепления, чтобы оставаться valid. Если такая структура копируется в другое место, то значение b должно быть обновлено, чтобы указывать на новый a, а не на старый.
Закрепление и асинхронное программирование
Надеюсь, вы можете делать все, что вы когда-либо хотите, с асинхронным Rust и никогда не беспокоиться о закреплении. Иногда вы столкнетесь с corner case, который требует использования закрепления, и если вы хотите реализовывать фьючерсы, среду выполнения или подобные вещи, вам нужно будет знать о закреплении. В этом разделе я объясню почему.
Асинхронные функции реализуются как фьючерсы (см. раздел TODO — это общий обзор, убедитесь, что мы объясняем более глубоко и с примерами в другом месте). В каждой точке await выполнение функции может быть приостановлено, и в течение этого времени значения живых переменных должны быть сохранены. Они essentially становятся полями структуры (которая является частью перечисления). Такие переменные могут ссылаться на другие переменные, которые сохранены в фьючерсе, например, рассмотрим:
#![allow(unused)] fn main() { async fn foo() { let a = ...; let b = &a; bar().await; // используем b } }
Сгенерированный объект фьючерса здесь будет чем-то вроде:
#![allow(unused)] fn main() { struct Foo { a: A, b: &'self A, // Инвариант `self.b == &self.a` } }
(Я немного упрощаю, игнорируя состояние выполнения и т.д., но важная часть — это переменные/поля).
Это интуитивно понятно, к сожалению, 'self не существует в Rust. И по хорошей причине! Помните, что объекты Rust могут быть перемещены, поэтому код типа следующего был бы небезопасен:
#![allow(unused)] fn main() { let f1 = Foo { ... }; // f1.b == &f1.a let f2 = f1; // f2.b == &f1.a, но f1 больше не существует, поскольку он переместился в f2 }
Обратите внимание, что это не просто проблема невозможности назвать время жизни, even if мы используем сырые указатели, такой код все равно был бы некорректен.
Однако, если мы знаем, что once он создан, экземпляр Foo никогда не переместится, то все Just Works. (Компилятор имеет концепцию, похожую на 'self, внутренне для таких случаев, как программист, нам пришлось бы использовать сырые указатели и небезопасный код). Эта концепция неперемещения — это точно то, что описывает закрепление.
Мы видим это требование в сигнатуре Future::poll, где тип self (фьючерса) — Pin<&mut Self>. В основном, при использовании async/await компилятор заботится о закреплении и откреплении, и как программисту вам не нужно об этом беспокоиться.
Ручное закрепление (Manual pinning)
Есть некоторые места, где закрепление просачивается через абстракцию async/await. В своей основе это связано с Pin в сигнатуре Future::poll и Stream::poll_next. При использовании фьючерсов и потоков directly (а не через async/await) нам, возможно, придется учитывать закрепление, чтобы все работало. Некоторые common причины, по которым могут понадобиться закрепленные типы:
- Опрос фьючерса или потока — либо в коде приложения, либо при реализации собственного фьючерса.
- Использование boxed фьючерсов. Если вы используете boxed фьючерсы (или потоки) и, следовательно, выписываете типы фьючерсов вместо использования асинхронных функций, вы, вероятно, увидите много
Pin<...>в этих типах и вам нужно будет использоватьBox::pinдля создания фьючерсов. - Реализация фьючерса — внутри
pollselfзакреплен, и поэтому вам нужно работать с проекцией закрепления и/или небезопасным кодом, чтобы получить изменяемый доступ к полямself. - Комбинирование фьючерсов или потоков. Это mostly просто работает, но если вам нужно взять ссылку на фьючерс, а затем опросить его (например, определить фьючерс вне цикла и использовать его в
select!внутри цикла), то вам нужно будет закрепить ссылку на фьючерс, чтобы использовать ссылку как фьючерс. - Работа с потоками — в настоящее время в Rust вокруг потоков меньше абстракции, чем вокруг фьючерсов, поэтому вы more likely использовать методы-комбинаторы (которые technically не требуют закрепления, но, кажется, делают проблемы, связанные со ссылками или созданием фьючерсов/потоков, more prevalent) или even
pollвручную, чем при работе с фьючерсами.
Альтернативы и расширения
Этот раздел для тех, кому любопытен дизайн языка вокруг закрепления. Вам absolutely не нужно читать этот раздел, если вы просто хотите читать, понимать и писать асинхронные программы.
Закрепление трудно понять и может казаться немного неуклюжим, поэтому люди часто задаются вопросом, есть ли лучшая альтернатива или вариация. Я рассмотрю несколько альтернатив и покажу, почему они либо не работают, либо более сложны, чем вы могли бы ожидать.
Однако before этого важно понять исторический контекст закрепления. Если вы разрабатываете совершенно новый язык и хотите поддержать async/await, самоссылки или неперемещаемые типы, certainly есть лучшие способы сделать это, чем закрепление в Rust. Однако async/await, фьючерсы и закрепление были добавлены в Rust после его релиза 1.0 и разработаны в контексте сильной гарантии обратной совместимости. Помимо этого жесткого требования, было требование желания разработать и реализовать эту функцию в разумные сроки. Некоторые решения (например, те, которые включают линейные типы) потребовали бы фундаментальных исследований, проектирования и реализации, которые реально измерялись бы десятилетиями с учетом ресурсов и ограничений проекта Rust.
Альтернативы
Во-первых, рассмотрим класс решений, которые делают типы Rust неперемещаемыми по умолчанию. Обратите внимание, что это значительное изменение фундаментальной семантики Rust; любое решение в этом классе, вероятно, потребовало бы значительных усилий для достижения обратной совместимости (я не буду строить догадки, возможно ли это для конкретных решений, но с такими техниками, как автотрейты, атрибуты derive, издания, инструменты миграции и т.д., это possibly возможно).
Одно предложение (на самом деле, группа предложений, поскольку есть различные способы определить семантику) — иметь маркерный трейт Move (аналогичный Copy), который помечает объекты как перемещаемые, и все другие типы были бы неперемещаемыми. В contrast с Pin, это свойство values, а не указателей, поэтому эффект much более далеко идущий, например, let a = b; было бы ошибкой, если b не реализует Move.
Фундаментальная проблема этого подхода заключается в том, что закрепление сегодня является фазовым понятием (место начинается незакрепленным и становится закрепленным), а типы применяются ко всему времени жизни значений. (Закрепление также лучше понимать как свойство places, а не values, но типы применяются к values, является ли это фундаментальной проблемой для любого подхода на основе трейтов, я не знаю). Это исследуется в этих двух постах в блоге: Two Ways Not to Move и Ergonomic Self-Referential Types for Rust.
Более того, любой трейт Move, вероятно, будет иметь проблемы с обратной совместимостью и приводить к «заразным ограничениям» (т.е. Move или !Move потребовались бы во many, many местах).
Другое предложение — поддержать конструкторы перемещения, подобные C++. Однако это нарушает фундаментальный инвариант Rust, что объекты всегда могут быть перемещены побитово. Это сделало бы Rust much менее предсказуемым и, следовательно, сделало бы программы Rust more трудными для понимания и отладки. Это обратно несовместимое изменение наихудшего рода, потому что оно молча сломало бы небезопасный код, поскольку изменяет фундаментальное предположение, которое могли сделать авторы кода. Более того, усилия по проектированию и реализации, необходимые для такого фундаментального изменения, были бы огромны. В дополнение к этим практическим проблемам, неясно, сработало ли бы это вообще: конструкторы перемещения могли бы использоваться для исправления ссылок в перемещаемом объекте, но могли бы быть ссылки на объект извне объекта, которые нельзя было бы исправить.
Потенциальное решение другого рода — идея смещенных ссылок (offset references). Это ссылка, которая является относительной, а не абсолютной, т.е. поле, которое является смещенной ссылкой на другое поле, always указывало бы внутри того же объекта, even if объект перемещен в памяти. Проблема со смещенными указателями заключается в том, что поле должно быть either смещенным указателем, либо абсолютным указателем. Но ссылки в асинхронной функции становятся полями, которые sometimes ссылаются на память внутри объекта фьючерса, а sometimes ссылаются на память вне его.
Расширения
Существует несколько предложений по повышению мощности и/или упрощению работы с закреплением. В основном это предложения сделать закрепление более first-class частью языка различными способами, а не чисто библиотечной концепцией (они often включают расширения std, а также языка). Я рассмотрю несколько более разработанных идей, они связаны друг с другом и все имеют общую цель улучшения эргономики закрепления за счет упрощения создания и использования закрепленных мест, в частности вокруг структурного закрепления и drop.
Pinned places развивает идею о том, что закрепление является свойством places, а не values или типов, и добавляет модификатор pin/pinned к ссылкам, аналогичный mut. Это интегрируется с перезаимствованием и разрешением методов для улучшения эргономики вызовов методов с закрепленным self.
UnpinCell расширяет идею закрепленных мест для поддержки нативной проекции закрепления полей. MinPin — это более минимальное (и обратно совместимое) предложение для нативной проекции закрепления и лучшей поддержки drop.
Трейт Overwrite — это предложенный трейт, который делает явным различие между разрешением на изменение части объекта (foo.f = ...) и разрешением на перезапись всего объекта (*foo = ...), оба из которых currently разрешены для всех изменяемых ссылок. Предложение также включает неизменяемые поля. Overwrite — это своего рода замена для Unpin, которая (вместе с некоторыми идеями из закрепленных мест) могла бы улучшить работу с закреплением. К сожалению, although оно могло бы быть принято обратно совместимо, переход был бы much более трудоемким, чем для других расширений.
Ссылки
- std docs источник истины для поведения и гарантий
Pinи т.д. Хорошая документация. - RFC 2349 RFC, предложившее закрепление. Стабилизированный API somewhat отличается от предложенного здесь, но в RFC есть хорошее объяснение основной концепции и обоснования.
- Некоторые посты в блогах или другие ресурсы, объясняющие закрепление:
- Pin от WithoutBoats (основного дизайнера закрепления) об истории, контексте и обосновании закрепления и почему это сложная концепция.
- Why is std::pin::Pin so weird? глубокое погружение в обоснование дизайна закрепления и использование закрепления на практике.
- Pin, Unpin, and why Rust needs them
- Pinning section of async/await
- Pin and suffering тщательный пост в блоге в очень разговорном стиле о понимании асинхронного кода и закрепления с множеством примеров.
- Книга Rust for Rustaceans от Jon Gjengset содержит excellent описание того, почему закрепление необходимо для реализации async/await и как работает закрепление.
-
Стоит отметить, что закрепление — это низкоуровневый строительный блок, разработанный специально для реализации асинхронного Rust. Хотя оно не связано напрямую с асинхронным Rust и может использоваться для других целей, оно не было предназначено для быть механизмом общего назначения и, в частности, не является готовым решением для самоссылающихся полей. Использование закрепления для чего-либо, кроме асинхронного кода, обычно работает только тогда, когда оно обернуто в толстые слои абстракции, поскольку потребует много возни и труднообъяснимого небезопасного кода. ↩
-
Мы немного смешиваем исходный код и время выполнения здесь. Чтобы быть абсолютно ясным, переменные не существуют во время выполнения. (Скомпилированный) фрагмент может выполняться несколько раз (например, если он в цикле или в функции, вызываемой несколько раз). Для каждого выполнения переменные в исходном коде будут представлены разными адресами во время выполнения. ↩
-
Постоянство не является фундаментальным аспектом закрепления, это часть формулировки закрепления в Rust и гарантий безопасности вокруг него. Было бы нормально, чтобы закрепление было временным, если бы это могло быть безопасно выражено и временная область видимости закрепления могла быть relied upon потребителями гарантий закрепления. Однако это невозможно с Rust сегодня или с любым разумным расширением. ↩
-
Нет специального отношения к
Box(или другим std указателям) ни в реализации закрепления, ни в компиляторе.Boxиспользует небезопасные функции в APIPinдля реализацииBox::pin. Требования безопасностиPinудовлетворяются благодаря гарантиям безопасностиBox. ↩ -
Это strictly закрепление в стеке only в неасинхронных функциях. В асинхронной функции все локальные переменные размещаются в псевдо-стеке async, поэтому закрепляемое место, вероятно, хранится в куче как часть фьючерса, лежащего в основе асинхронной функции. ↩
Структурированный параллелизм (Structured Concurrency)
Примечание автора (TODO): возможно, нам стоит обсудить некоторые части этой главы гораздо раньше в книге, в частности, как принципы проектирования (первое введение находится в guide/intro). Однако в интересах лучшего понимания темы и написания чего-то конкретного, я начинаю с отдельной главы. Она также все еще немного сырая.
(Примечание: первые несколько разделов говорят об абстрактной концепции структурированного параллелизма и не специфичны для Rust или асинхронного программирования (ср., синхронное параллельное программирование с потоками). Я использую «задача» (task) для обозначения любого потока, асинхронной задачи или другого подобного примитива параллелизма).
Структурированный параллелизм — это философия проектирования параллельных программ. Для того чтобы программы полностью соответствовали принципам структурированного параллелизма, требуются определенные языковые features и библиотеки, но многие преимущества доступны при следовании философии и без таких features. Структурированный параллелизм не зависит от языка и примитивов параллелизма (потоки vs async и т.д.). Многие люди нашли идеи из структурированного параллелизма полезными при программировании на асинхронном Rust.
Основная идея структурированного параллелизма заключается в том, что задачи организованы в дерево. Дочерние задачи начинаются после своих родителей и всегда завершаются до них. Это позволяет результатам и ошибкам всегда передаваться обратно родительским задачам и требует, чтобы отмена родительских задач всегда распространялась на дочерние задачи. В первую очередь, временная область видимости (temporal scope) следует за лексической областью видимости (lexical scope), что означает, что задача не должна переживать функцию или блок, в котором она создана. Однако это не является требованием структурированного параллелизма, пока более долгоживущие задачи реифицированы в программе каким-либо образом (обычно с использованием объекта для представления временной области видимости дочерней задачи внутри ее родительской задачи).
TODO диаграмма
Структурированный параллелизм назван по аналогии со структурированным программированием — идеей о том, что поток управления должен быть структурирован с использованием функций, циклов и т.д., а не произвольных переходов (goto).
Прежде чем мы рассмотрим структурированный параллелизм, полезно поразмышлять о том, в каком смысле распространенные параллельные designs неструктурированы. Типичный шаблон заключается в том, что задача запускается с помощью какого-либо оператора порождения (spawning). Эта задача затем выполняется до завершения параллельно с другими задачами в системе (включая задачу, которая ее породила). Нет никаких ограничений на то, какая задача завершится первой. Программа, по сути, представляет собой просто мешок задач, которые живут независимо и могут завершиться в любой момент. Любое общение или синхронизация задач происходит ad hoc, и программист не может предполагать, что любая другая задача все еще будет работать.
Практические недостатки неструктурированного параллелизма заключаются в том, что возврат результатов из задачи должен происходить экстралингвистическим образом без языковых гарантий относительно того, когда или как это происходит. Ошибки могут остаться неперехваченными, потому что механизмы обработки ошибок языков не могут быть применены к неограниченному потоку управления неструктурированного параллелизма. У нас также нет гарантий относительно относительного состояния задач — любая задача может выполняться, завершиться успешно или с ошибкой, или быть внешне отменена, независимо от состояния любых других1. Все это делает параллельные программы трудными для понимания и поддержки. Этот недостаток структуры является одной из причин, по которой параллельное программирование считается категорически более сложным, чем последовательное.
Стоит отметить, что структурированный параллелизм — это дисциплина программирования, которая накладывает ограничения на вашу программу. Так же, как функции и циклы менее гибки, чем goto, структурированный параллелизм менее гибок, чем простое порождение задач. Однако, как и в случае со структурированным программированием, затраты структурированного параллелизма на гибкость перевешиваются выигрышем в предсказуемости.
Принципы структурированного параллелизма
Ключевая идея структурированного параллелизма заключается в том, что все задачи (или потоки, или что-то еще) организованы в виде дерева. Т.е., каждая задача (кроме главной задачи, которая является корнем) имеет одного родителя, и нет циклов родителей. Дочерняя задача запускается своим родителем2 и должна всегда завершить выполнение до своего родителя. Между родственными задачами (siblings) нет ограничений. Родитель задачи не может измениться.
При рассуждении о программах, реализующих структурированный параллелизм, ключевым новым фактом является то, что если задача активна, то все ее задачи-предки также должны быть активны. Это не гарантирует, что они находятся в хорошем состоянии — они могут находиться в процессе завершения работы или обработки ошибки, но они должны работать в какой-то форме. Это означает, что для любой задачи (кроме корневой задачи) всегда есть активная задача, которой можно отправить результаты или ошибки. Действительно, идеальный подход заключается в том, что обработка ошибок языка расширяется так, что ошибки всегда распространяются на родительскую задачу. В Rust это должно применяться как к возврату Result::Err, так и к панике.
Более того, время жизни дочерних задач может быть представлено в родительской задаче. В общем случае время жизни задачи (ее временная область видимости) привязана к лексической области видимости, в которой она запущена. Например, все задачи, запущенные внутри функции, должны завершиться до возврата из функции. Это чрезвычайно мощный инструмент для рассуждений. Конечно, это слишком ограничительно для всех случаев, поэтому временная область видимости задач может выходить за пределы лексической области видимости с использованием объекта в программе (часто называемого «областью видимости» (scope) или «питомником» (nursery)). Такой объект может передаваться или храниться и, таким образом, иметь произвольное время жизни. У нас все еще есть важный инструмент для рассуждений: задачи, привязанные к этому объекту, не могут пережить его (в Rust это свойство позволяет нам интегрировать задачи с системой времени жизни).
Вышесказанное приводит к еще одному преимуществу структурированного параллелизма: он позволяет нам рассуждать об управлении ресурсами across несколько задач. Код очистки вызывается, когда ресурс больше не будет использоваться (например, закрытие файлового дескриптора). В последовательном коде проблема того, когда вызывать код очистки, решается путем обеспечения вызова деструкторов, когда объект выходит из области видимости. Однако в параллельном коде объект все еще может использоваться другой задачей, поэтому неясно, когда следует очищать (подсчет ссылок или сборка мусора являются решениями во многих случаях, но затрудняют рассуждения о времени жизни объектов, что может привести к ошибкам, а также имеет накладные расходы во время выполнения).
Принцип того, что родительская задача переживает своих детей, имеет важное следствие для отмены: если задача отменена, то все ее дочерние задачи должны быть отменены, и их отмена должна завершиться до завершения отмены родителя. Это, в свою очередь, имеет последствия для того, как отмена может быть реализована в структурно-параллельной системе.
Если задача завершается досрочно из-за ошибки (в Rust это может означать панику, а также досрочный возврат), то перед возвратом задача должна дождаться завершения всех своих дочерних задач. На практике досрочный возврат должен запускать отмену дочерних задач. Это аналогично панике в Rust: паника запускает деструкторы в текущей области видимости перед подъемом по стеку, вызывая деструкторы в каждой области видимости до завершения программы или перехвата паники. При структурном параллелизме досрочный возврат должен запускать отмену дочерних задач (и, следовательно, очистку объектов в этих задачах) и спускаться по дереву задач, отменяя все (транзитивные) дочерние задачи.
Некоторые designs очень естественно работают в условиях структурированного параллелизма (например, рабочие задачи с одной работой для выполнения), в то время как другие подходят не так хорошо. Обычно это шаблоны, в которых не быть привязанным к конкретной задаче является особенностью, например, пулы рабочих или фоновые потоки. Даже при использовании этих шаблонов задачи обычно не должны переживать всю программу, поэтому всегда есть одна задача, которая может быть родителем.
Реализация структурированного параллелизма
Примером реализации структурированного параллелизма является библиотека Python Trio. Trio — это библиотека общего назначения для асинхронного программирования и ввода-вывода, разработанная вокруг концепций структурированного параллелизма. Программы Trio используют конструкцию async with для определения лексической области видимости для порождения задач. Порожденные задачи связаны с объектом nursery (что несколько похоже на Scope в Rust). Время жизни задачи привязано к динамической временной области видимости ее питомника (nursery), и в общем случае — к лексической области видимости блока async with. Это обеспечивает отношение родитель/потомок между задачами и, следовательно, инвариант дерева структурированного параллелизма.
Обработка ошибок использует исключения Python, которые автоматически распространяются на родительские задачи.
Частично структурированный параллелизм
Как и многие методы программирования, полные преимущества структурированного параллелизма проявляются при его исключительном использовании. Если весь параллелизм структурирован, то это значительно облегчает рассуждения о поведении всей программы. Однако это предъявляет требования к языку, которые нелегко удовлетворить; достаточно легко делать неструктурированный параллелизм в Rust, например. Однако даже выборочное применение принципов структурированного параллелизма или мышление в терминах структурированного параллелизма может быть полезным.
Можно использовать структурированный параллелизм как дисциплину проектирования. При проектировании программы всегда учитывайте и документируйте отношения родитель-потомок между задачами и обеспечивайте, чтобы дочерняя задача завершалась до своего родителя. Обычно это довольно легко при нормальном выполнении, но может быть затруднительно при отмене и паниках.
Другой элемент структурированного параллелизма, который довольно легко принять, — это всегда распространять ошибки на родительскую задачу. Так же, как и при обычной обработке ошибок, лучшее, что можно сделать, — это проигнорировать ошибку, но это должно быть явно указано в коде родительской задачи.
Еще одна дисциплина программирования, которую следует усвоить из структурированного параллелизма, — это отменять все дочерние задачи в случае отмены родительской задачи. Это делает гарантии структурного параллелизма much более надежными и упрощает рассуждения об отмене в целом.
Практический структурированный параллелизм с асинхронным Rust
Параллелизм в Rust (будь то async или использование потоков) по своей природе неструктурирован. Задачи могут порождаться произвольно, ошибки и паники в других задачах могут игнорироваться, и отмена обычно мгновенна и не распространяется на другие задачи (см. ниже, почему эти проблемы не могут быть легко решены). Однако есть несколько способов получить некоторые преимущества структурированного параллелизма в ваших программах:
- Проектируйте свои программы на высоком уровне в соответствии со структурированным параллелизмом.
- По возможности придерживайтесь идиом структурированного параллелизма (и избегайте неструктурированных идиом).
- Используйте крейты, чтобы сделать структурированный параллелизм более эргономичным и надежным.
Одной из самых сложных проблем при использовании структурированного параллелизма с Rust является распространение отмены на дочерние фьючерсы/задачи. Если вы используете фьючерсы и компонуете их конкурентно, то это происходит естественным образом, хотя и резко (удаление фьючерса удаляет любые принадлежащие ему фьючерсы, отменяя их). Однако, когда задача удаляется, нет возможности отправить сигнал задачам, которые она породила (по крайней мере, не с Tokio3).
Следствием этого является то, что вы можете предполагать только более слабый инвариант, чем при «настоящем» структурированном параллелизме: вместо того, чтобы предполагать, что родительская задача всегда активна, вы можете предполагать, что родитель всегда активен, если он не был отменен или не запаниковал. Хотя это не оптимально, это все же может упростить программирование, потому что вам никогда не приходится обрабатывать случай отсутствия родителя для обработки какого-либо результата при нормальном выполнении.
TODO
- владение/время жизни, естественно ведущие к sc
- рассуждения о ресурсах
Применение структурированного параллелизма к проектированию асинхронных программ
С точки зрения проектирования программ, применение структурированного параллелизма имеет несколько последствий:
- Организация параллелизма программы в древовидную структуру, т.е. мышление в терминах родительских и дочерних задач.
- Временная область видимости должна, где это возможно, следовать лексической области видимости, или, конкретнее, функция не должна возвращаться (включая досрочные возвраты и паники), пока любые задачи, запущенные в функции, не завершатся.
- Данные обычно передаются от дочерних задач к родительским. Конечно, некоторые данные будут передаваться от родителей к детям или другими способами, но в основном задачи передают результаты своей работы своим родительским задачам для дальнейшей обработки. Это включает ошибки, поэтому родительские задачи должны обрабатывать ошибки своих детей.
Если вы пишете библиотеку и хотите использовать структурированный параллелизм (или хотите, чтобы библиотека могла использоваться в программе со структурированным параллелизмом), то важно, чтобы инкапсуляция компонента библиотеки включала временную инкапсуляцию. Т.е., она не должна запускать задачи, которые продолжают работать после возврата из API-функций.
Поскольку Rust не может обеспечить соблюдение правил структурированного параллелизма, важно осознавать и документировать, каким образом программа (или компонент) структурирована и где она нарушает дисциплину структурированного параллелизма.
Одним полезным компромиссным шаблоном является разрешение неструктурированного параллелизма только на самом высоком уровне абстракции и только для задач, порожденных из самых внешних функций главной задачи (в идеале только из функции main, но программы часто имеют некоторый код настройки или конфигурации, что означает, что логический «верхний уровень» программы на самом деле находится на несколько функций глубже). В соответствии с таким шаблоном из main порождается несколько задач, обычно с различными обязанностями и ограниченным взаимодействием друг с другом. Эти задачи могут быть перезапущены, новые задачи могут быть запущены любой другой задачей или иметь ограниченное время жизни, привязанное к клиентам или подобному, т.е. они неструктурированы в плане параллелизма. Внутри каждой из этих задач строго применяется структурированный параллелизм.
TODO почему это полезно?
TODO было бы здорово иметь здесь case study.
Структурированные и неструктурированные идиомы
Этот подраздел охватывает набор идиом, которые хорошо работают со структурированным подходом к параллелизму, и несколько тех, которые затрудняют структурирование параллелизма.
Самый простой способ следовать структурированному параллелизму — использовать фьючерсы и конкурентную композицию, а не задачи и порождение. Если вам нужны задачи для параллелизма, то вам нужно будет использовать JoinHandle или JoinSet. Вы должны позаботиться о том, чтобы дочерние задачи могли правильно очиститься, если родительская задача запаникует или будет отменена. Дескрипторы должны проверяться на ошибки, чтобы гарантировать, что ошибки в дочерних задачах правильно обрабатываются.
Один из способов обойти отсутствие распространения отмены — избегать резкой отмены (удаления) любой задачи, которая может иметь дочерние элементы. Вместо этого используйте сигнал (например, токен отмены), чтобы задача могла отменить своих детей перед завершением. К сожалению, это несовместимо с select.
Для обработки завершения работы программы (или компонента) используйте явный метод завершения работы вместо удаления компонента, чтобы функция завершения работы могла дождаться завершения дочерних задач или отменить их (поскольку drop не может быть async).
Несколько идиом плохо сочетаются со структурированным параллелизмом:
- Порождение задач без ожидания их завершения через дескриптор соединения или удаление этих дескрипторов.
- Макросы/функции
selectилиrace. Они не являются inherently структурированными, но поскольку они резко отменяют фьючерсы, это common источник неструктурированной отмены. - Рабочие задачи или пулы. Для асинхронных задач накладные расходы на запуск/завершение задач настолько низки, что использование пула задач, вероятно, будет иметь очень мало преимуществ по сравнению с пулом «данных», например, пулом соединений.
- Данные без четкой структуры владения — это не обязательно противоречит структурированному параллелизму, но часто приводит к проблемам проектирования.
Крейты для структурированного параллелизма
TODO
- крейты: moro, async-nursery
- futures-concurrency
Связанные темы
Этот раздел не обязателен для знания, чтобы использовать структурированный параллелизм с асинхронным Rust, но является полезным контекстом для любознательных.
Области видимости для потоков (Scoped threads)
Структурированный параллелизм с потоками Rust работает довольно хорошо. Хотя вы не можете предотвратить порождение потоков с неограниченным временем жизни, этого легко избежать. Вместо этого ограничьтесь использованием потоков с областью видимости, см. документацию по функции scope. Использование потоков с областью видимости ограничивает время жизни дочерних потоков и автоматически распространяет паники обратно на родительский поток. Родительский поток должен проверять результаты дочерних потоков для обработки ошибок. Вы даже можете передавать объект Scope как питомник (nursery) в Trio. Отмена обычно не является проблемой для потоков Rust, но если вы используете отмену потоков, вам придется интегрировать это с потоками с областью видимости вручную.
Специфично для Rust, потоки с областью видимости позволяют дочерним потокам заимствовать данные из родительского потока, что невозможно с неструктурированными параллельными потоками. Это может быть очень полезно и показывает, насколько хорошо структурированный параллелизм и управление ресурсами в стиле владения Rust могут работать вместе.
Асинхронный Drop и задачи с областью видимости
В Rust деструкторы (drop) используются для обеспечения очистки ресурсов, когда время жизни объекта заканчивается. Поскольку фьючерсы — это просто объекты, их деструктор был бы очевидным местом для обеспечения отмены дочерних фьючерсов. Однако в асинхронной программе очень часто желательно, чтобы действия по очистке были асинхронными (если этого не делать, это может блокировать другие задачи). К сожалению, Rust в настоящее время не поддерживает асинхронные деструкторы (async drop). Ведутся работы по их поддержке, но это сложно по ряду причин, включая то, что объект с асинхронным деструктором может быть удален из неасинхронного контекста, и поскольку вызов drop является неявным, нет места, где можно было бы написать явный await.
Учитывая, насколько полезны потоки с областью видимости (как в общем, так и для структурированного параллелизма), возникает другой хороший вопрос: почему нет аналогичной конструкции для асинхронного программирования («задачи с областью видимости»)? TODO ответить на это
Ссылки
Если вам интересно, вот несколько хороших постов в блогах для дальнейшего чтения:
-
Использование дескрипторов соединения (join handles) несколько смягчает эти недостатки, но это ad hoc механизм без надежных гарантий. Чтобы получить полные преимущества структурированного параллелизма, вы должны быть дотошны в их постоянном использовании, а также в правильной обработке отмены и ошибок. Это сложно без поддержки языка или библиотеки; мы обсудим это немного подробнее ниже. ↩
-
На самом деле это не является жестким требованием для структурированного параллелизма. Если временная область видимости задачи может быть представлена в программе и передаваться между задачами, то дочерняя задача может быть запущена одной задачей, но иметь другую в качестве родителя. ↩
-
Семантика
JoinHandleв Tokio такова, что если дескриптор удален, то базовая задача «освобождается» (ср., удаляется), т.е. результат дочерней задачи не обрабатывается никакой другой задачей. ↩
Начало работы
Добро пожаловать в асинхронное программирование в Rust! Если вы хотите начать писать асинхронный код на Rust, вы пришли по правильному адресу. Независимо от того, создаёте ли вы веб-сервер, базу данных или операционную систему, эта книга покажет вам, как использовать инструменты асинхронного программирования Rust, чтобы максимально эффективно использовать ваше оборудование.
О чём эта книга
Эта книга призвана быть всеобъемлющим, современным руководством по использованию асинхронных возможностей языка Rust и соответствующих библиотек, подходящим как для начинающих, так и для опытных разработчиков.
-
Первые главы дают введение в асинхронное программирование в целом и в конкретную реализацию этого подхода в Rust.
-
Средние главы обсуждают ключевые утилиты и инструменты управления потоком выполнения, которые вы можете использовать при написании асинхронного кода, а также описывают лучшие практики для структурирования библиотек и приложений с целью максимизации производительности и переиспользуемости.
-
Последний раздел книги охватывает экосистему асинхронного программирования в целом и предоставляет ряд примеров того, как выполнять распространённые задачи.
Без лишних слов, давайте исследуем увлекательный мир асинхронного программирования в Rust!
Зачем нужна асинхронность?
Мы все любим Rust за то, что он позволяет нам писать быстрое и безопасное программное обеспечение. Но как асинхронное программирование вписывается в эту картину?
Асинхронное программирование (сокращённо async) — это модель параллельного программирования,
которая поддерживается всё большим количеством языков программирования.
Оно позволяет запускать большое количество параллельных
задач на небольшом количестве потоков операционной системы, сохраняя при этом большую часть
внешнего вида и удобства обычного синхронного программирования благодаря синтаксису
async/await.
Async против других моделей параллелизма
Параллельное программирование менее зрелое и «стандартизированное», чем обычное, последовательное программирование. Как следствие, мы выражаем параллелизм по-разному в зависимости от того, какую модель параллельного программирования поддерживает язык. Краткий обзор самых популярных моделей параллелизма может помочь вам понять, как асинхронное программирование вписывается в более широкую область параллельного программирования:
- Потоки ОС не требуют изменений в модели программирования, что позволяет очень легко выражать параллелизм. Однако синхронизация между потоками может быть сложной, а накладные расходы на производительность велики. Пулы потоков могут смягчить некоторые из этих затрат, но недостаточно, чтобы поддерживать огромные нагрузки, ограниченные вводом-выводом (IO-bound).
- Событийно-ориентированное программирование в сочетании с обратными вызовами (callback) может быть очень производительным, но часто приводит к многословному, "нелинейному" потоку управления. Поток данных и распространение ошибок часто трудно отслеживать.
- Корутины, как и потоки, не требуют изменений в модели программирования, что делает их простыми в использовании. Как и async, они также могут поддерживать большое количество задач. Однако они абстрагируют низкоуровневые детали, которые важны для системного программирования и создателей пользовательских сред выполнения (runtime).
- Модель акторов разделяет все параллельные вычисления на единицы, называемые акторами, которые общаются через ненадёжную передачу сообщений, во многом как в распределённых системах. Модель акторов может быть эффективно реализована, но она оставляет без ответа многие практические вопросы, такие как управление потоком (flow control) и логика повторных попыток.
В итоге, асинхронное программирование позволяет создавать высокопроизводительные реализации, которые подходят для низкоуровневых языков, таких как Rust, одновременно предоставляя большинство эргономических преимуществ потоков и корутин.
Async в Rust против других языков
Хотя асинхронное программирование поддерживается во многих языках, некоторые детали различаются в разных реализациях. Реализация async в Rust отличается от большинства языков несколькими способами:
- Фьючерсы (Futures) в Rust инертны и прогрессируют только при опросе (polled). Удаление (Dropping) фьючерса останавливает его дальнейшее выполнение.
- Async в Rust бесплатен (zero-cost), что означает, что вы платите только за то, что используете. В частности, вы можете использовать async без выделения памяти в куче (heap allocations) и динамической диспетчеризации (dynamic dispatch), что отлично для производительности! Это также позволяет использовать async в ограниченных средах, таких как встраиваемые системы (embedded systems).
- В Rust нет встроенной среды выполнения (runtime). Вместо этого среды выполнения предоставляются крейтами, поддерживаемыми сообществом.
- В Rust доступны как однопоточные, так и многопоточные среды выполнения, которые имеют разные сильные и слабые стороны.
Async против потоков в Rust
Основная альтернатива async в Rust — использование потоков ОС, либо
напрямую через std::thread,
либо косвенно через пул потоков.
Переход с потоков на async или наоборот
обычно требует значительной работы по рефакторингу, как с точки зрения реализации, так и
(если вы создаёте библиотеку) любых открытых публичных интерфейсов. Как следствие,
выбор модели, которая подходит для ваших нужд на раннем этапе, может сэкономить много времени на разработку.
Потоки ОС подходят для небольшого количества задач, поскольку потоки несут накладные расходы на ЦП и память. Создание и переключение между потоками довольно дорого, так как даже бездействующие потоки потребляют системные ресурсы. Библиотека пула потоков может помочь смягчить некоторые из этих затрат, но не все. Однако потоки позволяют повторно использовать существующий синхронный код без значительных изменений кода — не требуется какая-либо конкретная модель программирования. В некоторых операционных системах вы также можете изменить приоритет потока, что полезно для драйверов и других приложений, чувствительных к задержкам.
Async обеспечивает значительное снижение нагрузки на ЦП и память, особенно для рабочих нагрузок с большим количеством задач, ограниченных вводом-выводом (IO-bound), таких как серверы и базы данных. При прочих равных условиях, у вас может быть на порядки больше задач, чем потоков ОС, потому что асинхронная среда выполнения использует небольшое количество (дорогих) потоков для обработки большого количества (дешёвых) задач. Однако асинхронный Rust приводит к увеличению размера двоичных файлов из-за генерируемых из асинхронных функций автоматов состояний (state machines) и из-за того, что каждый исполняемый файл включает в себя асинхронную среду выполнения.
В заключение отметим, что асинхронное программирование не лучше потоков, а просто другое. Если вам не нужна async по соображениям производительности, потоки часто могут быть более простой альтернативой.
Пример: Concurrent downloading
В этом примере наша цель — загрузить две веб-страницы параллельно. В типичном многопоточном приложении нам нужно создавать потоки для достижения параллелизма:
fn get_two_sites() {
// Spawn two threads to do work.
let thread_one = thread::spawn(|| download("https://www.foo.com"));
let thread_two = thread::spawn(|| download("https://www.bar.com"));
// Wait for both threads to complete.
thread_one.join().expect("thread one panicked");
thread_two.join().expect("thread two panicked");
}
Однако загрузка веб-страницы — это небольшая задача; создание потока для такого небольшого объёма работы довольно расточительно. Для более крупного приложения это может легко стать узким местом. В асинхронном Rust мы можем запускать эти задачи параллельно без дополнительных потоков:
async fn get_two_sites_async() {
// Create two different "futures" which, when run to completion,
// will asynchronously download the webpages.
let future_one = download_async("https://www.foo.com");
let future_two = download_async("https://www.bar.com");
// Run both futures to completion at the same time.
join!(future_one, future_two);
}
Здесь не создаётся дополнительных потоков. Более того, все вызовы функций являются статически диспетчеризируемыми (statically dispatched), и нет выделений памяти в куче (heap allocations)! Однако изначально нам нужно написать код асинхронным, чего вы достигнете с помощью этой книги.
Пользовательские модели параллелизма в Rust
В заключение отметим, что Rust не заставляет вас выбирать между потоками и async. Вы можете использовать обе модели в одном приложении, что может быть полезно, когда у вас есть смешанные зависимости (одни используют потоки, другие — async). На самом деле, вы даже можете использовать совершенно другую модель параллелизма, такую как событийно-ориентированное программирование, при условии, что вы найдёте библиотеку, которая её реализует.
The State of Asynchronous Rust
Parts of async Rust are supported with the same stability guarantees as synchronous Rust. Other parts are still maturing and will change over time. With async Rust, you can expect:
- Outstanding runtime performance for typical concurrent workloads.
- More frequent interaction with advanced language features, such as lifetimes and pinning.
- Some compatibility constraints, both between sync and async code, and between different async runtimes.
- Higher maintenance burden, due to the ongoing evolution of async runtimes and language support.
In short, async Rust is more difficult to use and can result in a higher maintenance burden than synchronous Rust, but gives you best-in-class performance in return. All areas of async Rust are constantly improving, so the impact of these issues will wear off over time.
Language and library support
While asynchronous programming is supported by Rust itself, most async applications depend on functionality provided by community crates. As such, you need to rely on a mixture of language features and library support:
- The most fundamental traits, types and functions, such as the
Futuretrait are provided by the standard library. - The
async/awaitsyntax is supported directly by the Rust compiler. - Many utility types, macros and functions are provided by the
futurescrate. They can be used in any async Rust application. - Execution of async code, IO and task spawning are provided by "async runtimes", such as Tokio and async-std. Most async applications, and some async crates, depend on a specific runtime. See "The Async Ecosystem" section for more details.
Some language features you may be used to from synchronous Rust are not yet available in async Rust. Notably, Rust did not let you declare async functions in traits until 1.75.0 stable (and still has limitations on dynamic dispatch for those traits). Instead, you need to use workarounds to achieve the same result, which can be more verbose.
Compiling and debugging
For the most part, compiler- and runtime errors in async Rust work the same way as they have always done in Rust. There are a few noteworthy differences:
Compilation errors
Compilation errors in async Rust conform to the same high standards as synchronous Rust, but since async Rust often depends on more complex language features, such as lifetimes and pinning, you may encounter these types of errors more frequently.
Runtime errors
Whenever the compiler encounters an async function, it generates a state machine under the hood. Stack traces in async Rust typically contain details from these state machines, as well as function calls from the runtime. As such, interpreting stack traces can be a bit more involved than it would be in synchronous Rust.
New failure modes
A few novel failure modes are possible in async Rust, for instance
if you call a blocking function from an async context or if you implement
the Future trait incorrectly. Such errors can silently pass both the
compiler and sometimes even unit tests. Having a firm understanding
of the underlying concepts, which this book aims to give you, can help you
avoid these pitfalls.
Compatibility considerations
Asynchronous and synchronous code cannot always be combined freely. For instance, you can't directly call an async function from a sync function. Sync and async code also tend to promote different design patterns, which can make it difficult to compose code intended for the different environments.
Even async code cannot always be combined freely. Some crates depend on a specific async runtime to function. If so, it is usually specified in the crate's dependency list.
These compatibility issues can limit your options, so make sure to research which async runtime and what crates you may need early. Once you have settled in with a runtime, you won't have to worry much about compatibility.
Performance characteristics
The performance of async Rust depends on the implementation of the async runtime you're using. Even though the runtimes that power async Rust applications are relatively new, they perform exceptionally well for most practical workloads.
That said, most of the async ecosystem assumes a multi-threaded runtime. This makes it difficult to enjoy the theoretical performance benefits of single-threaded async applications, namely cheaper synchronization. Another overlooked use-case is latency sensitive tasks, which are important for drivers, GUI applications and so on. Such tasks depend on runtime and/or OS support in order to be scheduled appropriately. You can expect better library support for these use cases in the future.
async/.await Primer
async/.await is Rust's built-in tool for writing asynchronous functions
that look like synchronous code. async transforms a block of code into a
state machine that implements a trait called Future. Whereas calling a
blocking function in a synchronous method would block the whole thread,
blocked Futures will yield control of the thread, allowing other
Futures to run.
Let's add some dependencies to the Cargo.toml file:
[dependencies]
futures = "0.3"
To create an asynchronous function, you can use the async fn syntax:
#![allow(unused)] fn main() { async fn do_something() { /* ... */ } }
The value returned by async fn is a Future. For anything to happen,
the Future needs to be run on an executor.
// `block_on` blocks the current thread until the provided future has run to // completion. Other executors provide more complex behavior, like scheduling // multiple futures onto the same thread. use futures::executor::block_on; async fn hello_world() { println!("hello, world!"); } fn main() { let future = hello_world(); // Nothing is printed block_on(future); // `future` is run and "hello, world!" is printed }
Inside an async fn, you can use .await to wait for the completion of
another type that implements the Future trait, such as the output of
another async fn. Unlike block_on, .await doesn't block the current
thread, but instead asynchronously waits for the future to complete, allowing
other tasks to run if the future is currently unable to make progress.
For example, imagine that we have three async fn: learn_song, sing_song,
and dance:
async fn learn_song() -> Song { /* ... */ }
async fn sing_song(song: Song) { /* ... */ }
async fn dance() { /* ... */ }
One way to do learn, sing, and dance would be to block on each of these individually:
fn main() {
let song = block_on(learn_song());
block_on(sing_song(song));
block_on(dance());
}
However, we're not giving the best performance possible this way—we're
only ever doing one thing at once! Clearly we have to learn the song before
we can sing it, but it's possible to dance at the same time as learning and
singing the song. To do this, we can create two separate async fn which
can be run concurrently:
async fn learn_and_sing() {
// Wait until the song has been learned before singing it.
// We use `.await` here rather than `block_on` to prevent blocking the
// thread, which makes it possible to `dance` at the same time.
let song = learn_song().await;
sing_song(song).await;
}
async fn async_main() {
let f1 = learn_and_sing();
let f2 = dance();
// `join!` is like `.await` but can wait for multiple futures concurrently.
// If we're temporarily blocked in the `learn_and_sing` future, the `dance`
// future will take over the current thread. If `dance` becomes blocked,
// `learn_and_sing` can take back over. If both futures are blocked, then
// `async_main` is blocked and will yield to the executor.
futures::join!(f1, f2);
}
fn main() {
block_on(async_main());
}
In this example, learning the song must happen before singing the song, but
both learning and singing can happen at the same time as dancing. If we used
block_on(learn_song()) rather than learn_song().await in learn_and_sing,
the thread wouldn't be able to do anything else while learn_song was running.
This would make it impossible to dance at the same time. By .await-ing
the learn_song future, we allow other tasks to take over the current thread
if learn_song is blocked. This makes it possible to run multiple futures
to completion concurrently on the same thread.
Under the Hood: Executing Futures and Tasks
In this section, we'll cover the underlying structure of how Futures and
asynchronous tasks are scheduled. If you're only interested in learning
how to write higher-level code that uses existing Future types and aren't
interested in the details of how Future types work, you can skip ahead to
the async/await chapter. However, several of the topics discussed in this
chapter are useful for understanding how async/await code works,
understanding the runtime and performance properties of async/await code,
and building new asynchronous primitives. If you decide to skip this section
now, you may want to bookmark it to revisit in the future.
Now, with that out of the way, let's talk about the Future trait.
The Future Trait
The Future trait is at the center of asynchronous programming in Rust.
A Future is an asynchronous computation that can produce a value
(although that value may be empty, e.g. ()). A simplified version of
the future trait might look something like this:
#![allow(unused)] fn main() { trait SimpleFuture { type Output; fn poll(&mut self, wake: fn()) -> Poll<Self::Output>; } enum Poll<T> { Ready(T), Pending, } }
Futures can be advanced by calling the poll function, which will drive the
future as far towards completion as possible. If the future completes, it
returns Poll::Ready(result). If the future is not able to complete yet, it
returns Poll::Pending and arranges for the wake() function to be called
when the Future is ready to make more progress. When wake() is called, the
executor driving the Future will call poll again so that the Future can
make more progress.
Without wake(), the executor would have no way of knowing when a particular
future could make progress, and would have to be constantly polling every
future. With wake(), the executor knows exactly which futures are ready to
be polled.
For example, consider the case where we want to read from a socket that may
or may not have data available already. If there is data, we can read it
in and return Poll::Ready(data), but if no data is ready, our future is
blocked and can no longer make progress. When no data is available, we
must register wake to be called when data becomes ready on the socket,
which will tell the executor that our future is ready to make progress.
A simple SocketRead future might look something like this:
pub struct SocketRead<'a> {
socket: &'a Socket,
}
impl SimpleFuture for SocketRead<'_> {
type Output = Vec<u8>;
fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
if self.socket.has_data_to_read() {
// The socket has data -- read it into a buffer and return it.
Poll::Ready(self.socket.read_buf())
} else {
// The socket does not yet have data.
//
// Arrange for `wake` to be called once data is available.
// When data becomes available, `wake` will be called, and the
// user of this `Future` will know to call `poll` again and
// receive data.
self.socket.set_readable_callback(wake);
Poll::Pending
}
}
}
This model of Futures allows for composing together multiple asynchronous
operations without needing intermediate allocations. Running multiple futures
at once or chaining futures together can be implemented via allocation-free
state machines, like this:
/// A SimpleFuture that runs two other futures to completion concurrently.
///
/// Concurrency is achieved via the fact that calls to `poll` each future
/// may be interleaved, allowing each future to advance itself at its own pace.
pub struct Join<FutureA, FutureB> {
// Each field may contain a future that should be run to completion.
// If the future has already completed, the field is set to `None`.
// This prevents us from polling a future after it has completed, which
// would violate the contract of the `Future` trait.
a: Option<FutureA>,
b: Option<FutureB>,
}
impl<FutureA, FutureB> SimpleFuture for Join<FutureA, FutureB>
where
FutureA: SimpleFuture<Output = ()>,
FutureB: SimpleFuture<Output = ()>,
{
type Output = ();
fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
// Attempt to complete future `a`.
if let Some(a) = &mut self.a {
if let Poll::Ready(()) = a.poll(wake) {
self.a.take();
}
}
// Attempt to complete future `b`.
if let Some(b) = &mut self.b {
if let Poll::Ready(()) = b.poll(wake) {
self.b.take();
}
}
if self.a.is_none() && self.b.is_none() {
// Both futures have completed -- we can return successfully
Poll::Ready(())
} else {
// One or both futures returned `Poll::Pending` and still have
// work to do. They will call `wake()` when progress can be made.
Poll::Pending
}
}
}
This shows how multiple futures can be run simultaneously without needing separate allocations, allowing for more efficient asynchronous programs. Similarly, multiple sequential futures can be run one after another, like this:
/// A SimpleFuture that runs two futures to completion, one after another.
//
// Note: for the purposes of this simple example, `AndThenFut` assumes both
// the first and second futures are available at creation-time. The real
// `AndThen` combinator allows creating the second future based on the output
// of the first future, like `get_breakfast.and_then(|food| eat(food))`.
pub struct AndThenFut<FutureA, FutureB> {
first: Option<FutureA>,
second: FutureB,
}
impl<FutureA, FutureB> SimpleFuture for AndThenFut<FutureA, FutureB>
where
FutureA: SimpleFuture<Output = ()>,
FutureB: SimpleFuture<Output = ()>,
{
type Output = ();
fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
if let Some(first) = &mut self.first {
match first.poll(wake) {
// We've completed the first future -- remove it and start on
// the second!
Poll::Ready(()) => self.first.take(),
// We couldn't yet complete the first future.
// Notice that we disrupt the flow of the `poll` function with the `return` statement.
Poll::Pending => return Poll::Pending,
};
}
// Now that the first future is done, attempt to complete the second.
self.second.poll(wake)
}
}
These examples show how the Future trait can be used to express asynchronous
control flow without requiring multiple allocated objects and deeply nested
callbacks. With the basic control-flow out of the way, let's talk about the
real Future trait and how it is different.
trait Future {
type Output;
fn poll(
// Note the change from `&mut self` to `Pin<&mut Self>`:
self: Pin<&mut Self>,
// and the change from `wake: fn()` to `cx: &mut Context<'_>`:
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}
The first change you'll notice is that our self type is no longer &mut Self,
but has changed to Pin<&mut Self>. We'll talk more about pinning in a later
section, but for now know that it allows us to create futures that
are immovable. Immovable objects can store pointers between their fields,
e.g. struct MyFut { a: i32, ptr_to_a: *const i32 }. Pinning is necessary
to enable async/await.
Secondly, wake: fn() has changed to &mut Context<'_>. In SimpleFuture,
we used a call to a function pointer (fn()) to tell the future executor that
the future in question should be polled. However, since fn() is just a
function pointer, it can't store any data about which Future called wake.
In a real-world scenario, a complex application like a web server may have
thousands of different connections whose wakeups should all be
managed separately. The Context type solves this by providing access to
a value of type Waker, which can be used to wake up a specific task.
Task Wakeups with Waker
It's common that futures aren't able to complete the first time they are
polled. When this happens, the future needs to ensure that it is polled
again once it is ready to make more progress. This is done with the Waker
type.
Each time a future is polled, it is polled as part of a "task". Tasks are the top-level futures that have been submitted to an executor.
Waker provides a wake() method that can be used to tell the executor that
the associated task should be awoken. When wake() is called, the executor
knows that the task associated with the Waker is ready to make progress, and
its future should be polled again.
Waker also implements clone() so that it can be copied around and stored.
Let's try implementing a simple timer future using Waker.
Applied: Build a Timer
For the sake of the example, we'll just spin up a new thread when the timer is created, sleep for the required time, and then signal the timer future when the time window has elapsed.
First, start a new project with cargo new --lib timer_future and add the imports
we'll need to get started to src/lib.rs:
#![allow(unused)] fn main() { use std::{ future::Future, pin::Pin, sync::{Arc, Mutex}, task::{Context, Poll, Waker}, thread, time::Duration, }; }
Let's start by defining the future type itself. Our future needs a way for the
thread to communicate that the timer has elapsed and the future should complete.
We'll use a shared Arc<Mutex<..>> value to communicate between the thread and
the future.
pub struct TimerFuture {
shared_state: Arc<Mutex<SharedState>>,
}
/// Shared state between the future and the waiting thread
struct SharedState {
/// Whether or not the sleep time has elapsed
completed: bool,
/// The waker for the task that `TimerFuture` is running on.
/// The thread can use this after setting `completed = true` to tell
/// `TimerFuture`'s task to wake up, see that `completed = true`, and
/// move forward.
waker: Option<Waker>,
}
Now, let's actually write the Future implementation!
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// Look at the shared state to see if the timer has already completed.
let mut shared_state = self.shared_state.lock().unwrap();
if shared_state.completed {
Poll::Ready(())
} else {
// Set waker so that the thread can wake up the current task
// when the timer has completed, ensuring that the future is polled
// again and sees that `completed = true`.
//
// It's tempting to do this once rather than repeatedly cloning
// the waker each time. However, the `TimerFuture` can move between
// tasks on the executor, which could cause a stale waker pointing
// to the wrong task, preventing `TimerFuture` from waking up
// correctly.
//
// N.B. it's possible to check for this using the `Waker::will_wake`
// function, but we omit that here to keep things simple.
shared_state.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
Pretty simple, right? If the thread has set shared_state.completed = true,
we're done! Otherwise, we clone the Waker for the current task and pass it to
shared_state.waker so that the thread can wake the task back up.
Importantly, we have to update the Waker every time the future is polled
because the future may have moved to a different task with a different
Waker. This will happen when futures are passed around between tasks after
being polled.
Finally, we need the API to actually construct the timer and start the thread:
impl TimerFuture {
/// Create a new `TimerFuture` which will complete after the provided
/// timeout.
pub fn new(duration: Duration) -> Self {
let shared_state = Arc::new(Mutex::new(SharedState {
completed: false,
waker: None,
}));
// Spawn the new thread
let thread_shared_state = shared_state.clone();
thread::spawn(move || {
thread::sleep(duration);
let mut shared_state = thread_shared_state.lock().unwrap();
// Signal that the timer has completed and wake up the last
// task on which the future was polled, if one exists.
shared_state.completed = true;
if let Some(waker) = shared_state.waker.take() {
waker.wake()
}
});
TimerFuture { shared_state }
}
}
Woot! That's all we need to build a simple timer future. Now, if only we had an executor to run the future on...
Applied: Build an Executor
Rust's Futures are lazy: they won't do anything unless actively driven to
completion. One way to drive a future to completion is to .await it inside
an async function, but that just pushes the problem one level up: who will
run the futures returned from the top-level async functions? The answer is
that we need a Future executor.
Future executors take a set of top-level Futures and run them to completion
by calling poll whenever the Future can make progress. Typically, an
executor will poll a future once to start off. When Futures indicate that
they are ready to make progress by calling wake(), they are placed back
onto a queue and poll is called again, repeating until the Future has
completed.
In this section, we'll write our own simple executor capable of running a large number of top-level futures to completion concurrently.
For this example, we depend on the futures crate for the ArcWake trait,
which provides an easy way to construct a Waker. Edit Cargo.toml to add
a new dependency:
[package]
name = "timer_future"
version = "0.1.0"
authors = ["XYZ Author"]
edition = "2021"
[dependencies]
futures = "0.3"
Next, we need the following imports at the top of src/main.rs:
use futures::{
future::{BoxFuture, FutureExt},
task::{waker_ref, ArcWake},
};
use std::{
future::Future,
sync::mpsc::{sync_channel, Receiver, SyncSender},
sync::{Arc, Mutex},
task::Context,
time::Duration,
};
// The timer we wrote in the previous section:
use timer_future::TimerFuture;
Our executor will work by sending tasks to run over a channel. The executor will pull events off of the channel and run them. When a task is ready to do more work (is awoken), it can schedule itself to be polled again by putting itself back onto the channel.
In this design, the executor itself just needs the receiving end of the task channel. The user will get a sending end so that they can spawn new futures. Tasks themselves are just futures that can reschedule themselves, so we'll store them as a future paired with a sender that the task can use to requeue itself.
/// Task executor that receives tasks off of a channel and runs them.
struct Executor {
ready_queue: Receiver<Arc<Task>>,
}
/// `Spawner` spawns new futures onto the task channel.
#[derive(Clone)]
struct Spawner {
task_sender: SyncSender<Arc<Task>>,
}
/// A future that can reschedule itself to be polled by an `Executor`.
struct Task {
/// In-progress future that should be pushed to completion.
///
/// The `Mutex` is not necessary for correctness, since we only have
/// one thread executing tasks at once. However, Rust isn't smart
/// enough to know that `future` is only mutated from one thread,
/// so we need to use the `Mutex` to prove thread-safety. A production
/// executor would not need this, and could use `UnsafeCell` instead.
future: Mutex<Option<BoxFuture<'static, ()>>>,
/// Handle to place the task itself back onto the task queue.
task_sender: SyncSender<Arc<Task>>,
}
fn new_executor_and_spawner() -> (Executor, Spawner) {
// Maximum number of tasks to allow queueing in the channel at once.
// This is just to make `sync_channel` happy, and wouldn't be present in
// a real executor.
const MAX_QUEUED_TASKS: usize = 10_000;
let (task_sender, ready_queue) = sync_channel(MAX_QUEUED_TASKS);
(Executor { ready_queue }, Spawner { task_sender })
}
Let's also add a method to spawner to make it easy to spawn new futures.
This method will take a future type, box it, and create a new Arc<Task> with
it inside which can be enqueued onto the executor.
impl Spawner {
fn spawn(&self, future: impl Future<Output = ()> + 'static + Send) {
let future = future.boxed();
let task = Arc::new(Task {
future: Mutex::new(Some(future)),
task_sender: self.task_sender.clone(),
});
self.task_sender.try_send(task).expect("too many tasks queued");
}
}
To poll futures, we'll need to create a Waker.
As discussed in the task wakeups section, Wakers are responsible
for scheduling a task to be polled again once wake is called. Remember that
Wakers tell the executor exactly which task has become ready, allowing
them to poll just the futures that are ready to make progress. The easiest way
to create a new Waker is by implementing the ArcWake trait and then using
the waker_ref or .into_waker() functions to turn an Arc<impl ArcWake>
into a Waker. Let's implement ArcWake for our tasks to allow them to be
turned into Wakers and awoken:
impl ArcWake for Task {
fn wake_by_ref(arc_self: &Arc<Self>) {
// Implement `wake` by sending this task back onto the task channel
// so that it will be polled again by the executor.
let cloned = arc_self.clone();
arc_self
.task_sender
.try_send(cloned)
.expect("too many tasks queued");
}
}
When a Waker is created from an Arc<Task>, calling wake() on it will
cause a copy of the Arc to be sent onto the task channel. Our executor then
needs to pick up the task and poll it. Let's implement that:
impl Executor {
fn run(&self) {
while let Ok(task) = self.ready_queue.recv() {
// Take the future, and if it has not yet completed (is still Some),
// poll it in an attempt to complete it.
let mut future_slot = task.future.lock().unwrap();
if let Some(mut future) = future_slot.take() {
// Create a `LocalWaker` from the task itself
let waker = waker_ref(&task);
let context = &mut Context::from_waker(&waker);
// `BoxFuture<T>` is a type alias for
// `Pin<Box<dyn Future<Output = T> + Send + 'static>>`.
// We can get a `Pin<&mut dyn Future + Send + 'static>`
// from it by calling the `Pin::as_mut` method.
if future.as_mut().poll(context).is_pending() {
// We're not done processing the future, so put it
// back in its task to be run again in the future.
*future_slot = Some(future);
}
}
}
}
}
Congratulations! We now have a working futures executor. We can even use it
to run async/.await code and custom futures, such as the TimerFuture we
wrote earlier:
fn main() {
let (executor, spawner) = new_executor_and_spawner();
// Spawn a task to print before and after waiting on a timer.
spawner.spawn(async {
println!("howdy!");
// Wait for our timer future to complete after two seconds.
TimerFuture::new(Duration::new(2, 0)).await;
println!("done!");
});
// Drop the spawner so that our executor knows it is finished and won't
// receive more incoming tasks to run.
drop(spawner);
// Run the executor until the task queue is empty.
// This will print "howdy!", pause, and then print "done!".
executor.run();
}
Executors and System IO
In the previous section on The Future Trait, we discussed this example of
a future that performed an asynchronous read on a socket:
pub struct SocketRead<'a> {
socket: &'a Socket,
}
impl SimpleFuture for SocketRead<'_> {
type Output = Vec<u8>;
fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
if self.socket.has_data_to_read() {
// The socket has data -- read it into a buffer and return it.
Poll::Ready(self.socket.read_buf())
} else {
// The socket does not yet have data.
//
// Arrange for `wake` to be called once data is available.
// When data becomes available, `wake` will be called, and the
// user of this `Future` will know to call `poll` again and
// receive data.
self.socket.set_readable_callback(wake);
Poll::Pending
}
}
}
This future will read available data on a socket, and if no data is available,
it will yield to the executor, requesting that its task be awoken when the
socket becomes readable again. However, it's not clear from this example how
the Socket type is implemented, and in particular it isn't obvious how the
set_readable_callback function works. How can we arrange for wake()
to be called once the socket becomes readable? One option would be to have
a thread that continually checks whether socket is readable, calling
wake() when appropriate. However, this would be quite inefficient, requiring
a separate thread for each blocked IO future. This would greatly reduce the
efficiency of our async code.
In practice, this problem is solved through integration with an IO-aware
system blocking primitive, such as epoll on Linux, kqueue on FreeBSD and
Mac OS, IOCP on Windows, and ports on Fuchsia (all of which are exposed
through the cross-platform Rust crate mio). These primitives all allow
a thread to block on multiple asynchronous IO events, returning once one of
the events completes. In practice, these APIs usually look something like
this:
struct IoBlocker {
/* ... */
}
struct Event {
// An ID uniquely identifying the event that occurred and was listened for.
id: usize,
// A set of signals to wait for, or which occurred.
signals: Signals,
}
impl IoBlocker {
/// Create a new collection of asynchronous IO events to block on.
fn new() -> Self { /* ... */ }
/// Express an interest in a particular IO event.
fn add_io_event_interest(
&self,
/// The object on which the event will occur
io_object: &IoObject,
/// A set of signals that may appear on the `io_object` for
/// which an event should be triggered, paired with
/// an ID to give to events that result from this interest.
event: Event,
) { /* ... */ }
/// Block until one of the events occurs.
fn block(&self) -> Event { /* ... */ }
}
let mut io_blocker = IoBlocker::new();
io_blocker.add_io_event_interest(
&socket_1,
Event { id: 1, signals: READABLE },
);
io_blocker.add_io_event_interest(
&socket_2,
Event { id: 2, signals: READABLE | WRITABLE },
);
let event = io_blocker.block();
// prints e.g. "Socket 1 is now READABLE" if socket one became readable.
println!("Socket {:?} is now {:?}", event.id, event.signals);
Futures executors can use these primitives to provide asynchronous IO objects
such as sockets that can configure callbacks to be run when a particular IO
event occurs. In the case of our SocketRead example above, the
Socket::set_readable_callback function might look like the following pseudocode:
impl Socket {
fn set_readable_callback(&self, waker: Waker) {
// `local_executor` is a reference to the local executor.
// This could be provided at creation of the socket, but in practice
// many executor implementations pass it down through thread local
// storage for convenience.
let local_executor = self.local_executor;
// Unique ID for this IO object.
let id = self.id;
// Store the local waker in the executor's map so that it can be called
// once the IO event arrives.
local_executor.event_map.insert(id, waker);
local_executor.add_io_event_interest(
&self.socket_file_descriptor,
Event { id, signals: READABLE },
);
}
}
We can now have just one executor thread which can receive and dispatch any
IO event to the appropriate Waker, which will wake up the corresponding
task, allowing the executor to drive more tasks to completion before returning
to check for more IO events (and the cycle continues...).
async/.await
In the first chapter, we took a brief look at async/.await.
This chapter will discuss async/.await in
greater detail, explaining how it works and how async code differs from
traditional Rust programs.
async/.await are special pieces of Rust syntax that make it possible to
yield control of the current thread rather than blocking, allowing other
code to make progress while waiting on an operation to complete.
There are two main ways to use async: async fn and async blocks.
Each returns a value that implements the Future trait:
// `foo()` returns a type that implements `Future<Output = u8>`.
// `foo().await` will result in a value of type `u8`.
async fn foo() -> u8 { 5 }
fn bar() -> impl Future<Output = u8> {
// This `async` block results in a type that implements
// `Future<Output = u8>`.
async {
let x: u8 = foo().await;
x + 5
}
}
As we saw in the first chapter, async bodies and other futures are lazy:
they do nothing until they are run. The most common way to run a Future
is to .await it. When .await is called on a Future, it will attempt
to run it to completion. If the Future is blocked, it will yield control
of the current thread. When more progress can be made, the Future will be picked
up by the executor and will resume running, allowing the .await to resolve.
async Lifetimes
Unlike traditional functions, async fns which take references or other
non-'static arguments return a Future which is bounded by the lifetime of
the arguments:
// This function:
async fn foo(x: &u8) -> u8 { *x }
// Is equivalent to this function:
fn foo_expanded<'a>(x: &'a u8) -> impl Future<Output = u8> + 'a {
async move { *x }
}
This means that the future returned from an async fn must be .awaited
while its non-'static arguments are still valid. In the common
case of .awaiting the future immediately after calling the function
(as in foo(&x).await) this is not an issue. However, if storing the future
or sending it over to another task or thread, this may be an issue.
One common workaround for turning an async fn with references-as-arguments
into a 'static future is to bundle the arguments with the call to the
async fn inside an async block:
fn bad() -> impl Future<Output = u8> {
let x = 5;
borrow_x(&x) // ERROR: `x` does not live long enough
}
fn good() -> impl Future<Output = u8> {
async {
let x = 5;
borrow_x(&x).await
}
}
By moving the argument into the async block, we extend its lifetime to match
that of the Future returned from the call to good.
async move
async blocks and closures allow the move keyword, much like normal
closures. An async move block will take ownership of the variables it
references, allowing it to outlive the current scope, but giving up the ability
to share those variables with other code:
/// `async` block:
///
/// Multiple different `async` blocks can access the same local variable
/// so long as they're executed within the variable's scope
async fn blocks() {
let my_string = "foo".to_string();
let future_one = async {
// ...
println!("{my_string}");
};
let future_two = async {
// ...
println!("{my_string}");
};
// Run both futures to completion, printing "foo" twice:
let ((), ()) = futures::join!(future_one, future_two);
}
/// `async move` block:
///
/// Only one `async move` block can access the same captured variable, since
/// captures are moved into the `Future` generated by the `async move` block.
/// However, this allows the `Future` to outlive the original scope of the
/// variable:
fn move_block() -> impl Future<Output = ()> {
let my_string = "foo".to_string();
async move {
// ...
println!("{my_string}");
}
}
.awaiting on a Multithreaded Executor
Note that, when using a multithreaded Future executor, a Future may move
between threads, so any variables used in async bodies must be able to travel
between threads, as any .await can potentially result in a switch to a new
thread.
This means that it is not safe to use Rc, &RefCell or any other types
that don't implement the Send trait, including references to types that don't
implement the Sync trait.
(Caveat: it is possible to use these types as long as they aren't in scope
during a call to .await.)
Similarly, it isn't a good idea to hold a traditional non-futures-aware lock
across an .await, as it can cause the threadpool to lock up: one task could
take out a lock, .await and yield to the executor, allowing another task to
attempt to take the lock and cause a deadlock. To avoid this, use the Mutex
in futures::lock rather than the one from std::sync.
The Stream Trait
The Stream trait is similar to Future but can yield multiple values before
completing, similar to the Iterator trait from the standard library:
trait Stream {
/// The type of the value yielded by the stream.
type Item;
/// Attempt to resolve the next item in the stream.
/// Returns `Poll::Pending` if not ready, `Poll::Ready(Some(x))` if a value
/// is ready, and `Poll::Ready(None)` if the stream has completed.
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Option<Self::Item>>;
}
One common example of a Stream is the Receiver for the channel type from
the futures crate. It will yield Some(val) every time a value is sent
from the Sender end, and will yield None once the Sender has been
dropped and all pending messages have been received:
async fn send_recv() {
const BUFFER_SIZE: usize = 10;
let (mut tx, mut rx) = mpsc::channel::<i32>(BUFFER_SIZE);
tx.send(1).await.unwrap();
tx.send(2).await.unwrap();
drop(tx);
// `StreamExt::next` is similar to `Iterator::next`, but returns a
// type that implements `Future<Output = Option<T>>`.
assert_eq!(Some(1), rx.next().await);
assert_eq!(Some(2), rx.next().await);
assert_eq!(None, rx.next().await);
}
Iteration and Concurrency
Similar to synchronous Iterators, there are many different ways to iterate
over and process the values in a Stream. There are combinator-style methods
such as map, filter, and fold, and their early-exit-on-error cousins
try_map, try_filter, and try_fold.
Unfortunately, for loops are not usable with Streams, but for
imperative-style code, while let and the next/try_next functions can
be used:
async fn sum_with_next(mut stream: Pin<&mut dyn Stream<Item = i32>>) -> i32 {
use futures::stream::StreamExt; // for `next`
let mut sum = 0;
while let Some(item) = stream.next().await {
sum += item;
}
sum
}
async fn sum_with_try_next(
mut stream: Pin<&mut dyn Stream<Item = Result<i32, io::Error>>>,
) -> Result<i32, io::Error> {
use futures::stream::TryStreamExt; // for `try_next`
let mut sum = 0;
while let Some(item) = stream.try_next().await? {
sum += item;
}
Ok(sum)
}
However, if we're just processing one element at a time, we're potentially
leaving behind opportunity for concurrency, which is, after all, why we're
writing async code in the first place. To process multiple items from a stream
concurrently, use the for_each_concurrent and try_for_each_concurrent
methods:
async fn jump_around(
mut stream: Pin<&mut dyn Stream<Item = Result<u8, io::Error>>>,
) -> Result<(), io::Error> {
use futures::stream::TryStreamExt; // for `try_for_each_concurrent`
const MAX_CONCURRENT_JUMPERS: usize = 100;
stream.try_for_each_concurrent(MAX_CONCURRENT_JUMPERS, |num| async move {
jump_n_times(num).await?;
report_n_jumps(num).await?;
Ok(())
}).await?;
Ok(())
}
Executing Multiple Futures at a Time
Up until now, we've mostly executed futures by using .await, which blocks
the current task until a particular Future completes. However, real
asynchronous applications often need to execute several different
operations concurrently.
In this chapter, we'll cover some ways to execute multiple asynchronous operations at the same time:
join!: waits for futures to all completeselect!: waits for one of several futures to complete- Spawning: creates a top-level task which ambiently runs a future to completion
FuturesUnordered: a group of futures which yields the result of each subfuture
join!
The futures::join macro makes it possible to wait for multiple different
futures to complete while executing them all concurrently.
join!
When performing multiple asynchronous operations, it's tempting to simply
.await them in a series:
async fn get_book_and_music() -> (Book, Music) {
let book = get_book().await;
let music = get_music().await;
(book, music)
}
However, this will be slower than necessary, since it won't start trying to
get_music until after get_book has completed. In some other languages,
futures are ambiently run to completion, so two operations can be
run concurrently by first calling each async fn to start the futures, and
then awaiting them both:
// WRONG -- don't do this
async fn get_book_and_music() -> (Book, Music) {
let book_future = get_book();
let music_future = get_music();
(book_future.await, music_future.await)
}
However, Rust futures won't do any work until they're actively .awaited.
This means that the two code snippets above will both run
book_future and music_future in series rather than running them
concurrently. To correctly run the two futures concurrently, use
futures::join!:
use futures::join;
async fn get_book_and_music() -> (Book, Music) {
let book_fut = get_book();
let music_fut = get_music();
join!(book_fut, music_fut)
}
The value returned by join! is a tuple containing the output of each
Future passed in.
try_join!
For futures which return Result, consider using try_join! rather than
join!. Since join! only completes once all subfutures have completed,
it'll continue processing other futures even after one of its subfutures
has returned an Err.
Unlike join!, try_join! will complete immediately if one of the subfutures
returns an error.
use futures::try_join;
async fn get_book() -> Result<Book, String> { /* ... */ Ok(Book) }
async fn get_music() -> Result<Music, String> { /* ... */ Ok(Music) }
async fn get_book_and_music() -> Result<(Book, Music), String> {
let book_fut = get_book();
let music_fut = get_music();
try_join!(book_fut, music_fut)
}
Note that the futures passed to try_join! must all have the same error type.
Consider using the .map_err(|e| ...) and .err_into() functions from
futures::future::TryFutureExt to consolidate the error types:
use futures::{
future::TryFutureExt,
try_join,
};
async fn get_book() -> Result<Book, ()> { /* ... */ Ok(Book) }
async fn get_music() -> Result<Music, String> { /* ... */ Ok(Music) }
async fn get_book_and_music() -> Result<(Book, Music), String> {
let book_fut = get_book().map_err(|()| "Unable to get book".to_string());
let music_fut = get_music();
try_join!(book_fut, music_fut)
}
select!
The futures::select macro runs multiple futures simultaneously, allowing
the user to respond as soon as any future completes.
#![allow(unused)] fn main() { use futures::{ future::FutureExt, // for `.fuse()` pin_mut, select, }; async fn task_one() { /* ... */ } async fn task_two() { /* ... */ } async fn race_tasks() { let t1 = task_one().fuse(); let t2 = task_two().fuse(); pin_mut!(t1, t2); select! { () = t1 => println!("task one completed first"), () = t2 => println!("task two completed first"), } } }
The function above will run both t1 and t2 concurrently. When either
t1 or t2 finishes, the corresponding handler will call println!, and
the function will end without completing the remaining task.
The basic syntax for select is <pattern> = <expression> => <code>,,
repeated for as many futures as you would like to select over.
default => ... and complete => ...
select also supports default and complete branches.
A default branch will run if none of the futures being selected
over are yet complete. A select with a default branch will
therefore always return immediately, since default will be run
if none of the other futures are ready.
complete branches can be used to handle the case where all futures
being selected over have completed and will no longer make progress.
This is often handy when looping over a select!.
#![allow(unused)] fn main() { use futures::{future, select}; async fn count() { let mut a_fut = future::ready(4); let mut b_fut = future::ready(6); let mut total = 0; loop { select! { a = a_fut => total += a, b = b_fut => total += b, complete => break, default => unreachable!(), // never runs (futures are ready, then complete) }; } assert_eq!(total, 10); } }
Interaction with Unpin and FusedFuture
One thing you may have noticed in the first example above is that we
had to call .fuse() on the futures returned by the two async fns,
as well as pinning them with pin_mut. Both of these calls are necessary
because the futures used in select must implement both the Unpin
trait and the FusedFuture trait.
Unpin is necessary because the futures used by select are not
taken by value, but by mutable reference. By not taking ownership
of the future, uncompleted futures can be used again after the
call to select.
Similarly, the FusedFuture trait is required because select must
not poll a future after it has completed. FusedFuture is implemented
by futures which track whether or not they have completed. This makes
it possible to use select in a loop, only polling the futures which
still have yet to complete. This can be seen in the example above,
where a_fut or b_fut will have completed the second time through
the loop. Because the future returned by future::ready implements
FusedFuture, it's able to tell select not to poll it again.
Note that streams have a corresponding FusedStream trait. Streams
which implement this trait or have been wrapped using .fuse()
will yield FusedFuture futures from their
.next() / .try_next() combinators.
#![allow(unused)] fn main() { use futures::{ stream::{Stream, StreamExt, FusedStream}, select, }; async fn add_two_streams( mut s1: impl Stream<Item = u8> + FusedStream + Unpin, mut s2: impl Stream<Item = u8> + FusedStream + Unpin, ) -> u8 { let mut total = 0; loop { let item = select! { x = s1.next() => x, x = s2.next() => x, complete => break, }; if let Some(next_num) = item { total += next_num; } } total } }
Concurrent tasks in a select loop with Fuse and FuturesUnordered
One somewhat hard-to-discover but handy function is Fuse::terminated(),
which allows constructing an empty future which is already terminated,
and can later be filled in with a future that needs to be run.
This can be handy when there's a task that needs to be run during a select
loop but which is created inside the select loop itself.
Note the use of the .select_next_some() function. This can be
used with select to only run the branch for Some(_) values
returned from the stream, ignoring Nones.
#![allow(unused)] fn main() { use futures::{ future::{Fuse, FusedFuture, FutureExt}, stream::{FusedStream, Stream, StreamExt}, pin_mut, select, }; async fn get_new_num() -> u8 { /* ... */ 5 } async fn run_on_new_num(_: u8) { /* ... */ } async fn run_loop( mut interval_timer: impl Stream<Item = ()> + FusedStream + Unpin, starting_num: u8, ) { let run_on_new_num_fut = run_on_new_num(starting_num).fuse(); let get_new_num_fut = Fuse::terminated(); pin_mut!(run_on_new_num_fut, get_new_num_fut); loop { select! { () = interval_timer.select_next_some() => { // The timer has elapsed. Start a new `get_new_num_fut` // if one was not already running. if get_new_num_fut.is_terminated() { get_new_num_fut.set(get_new_num().fuse()); } }, new_num = get_new_num_fut => { // A new number has arrived -- start a new `run_on_new_num_fut`, // dropping the old one. run_on_new_num_fut.set(run_on_new_num(new_num).fuse()); }, // Run the `run_on_new_num_fut` () = run_on_new_num_fut => {}, // panic if everything completed, since the `interval_timer` should // keep yielding values indefinitely. complete => panic!("`interval_timer` completed unexpectedly"), } } } }
When many copies of the same future need to be run simultaneously,
use the FuturesUnordered type. The following example is similar
to the one above, but will run each copy of run_on_new_num_fut
to completion, rather than aborting them when a new one is created.
It will also print out a value returned by run_on_new_num_fut.
#![allow(unused)] fn main() { use futures::{ future::{Fuse, FusedFuture, FutureExt}, stream::{FusedStream, FuturesUnordered, Stream, StreamExt}, pin_mut, select, }; async fn get_new_num() -> u8 { /* ... */ 5 } async fn run_on_new_num(_: u8) -> u8 { /* ... */ 5 } async fn run_loop( mut interval_timer: impl Stream<Item = ()> + FusedStream + Unpin, starting_num: u8, ) { let mut run_on_new_num_futs = FuturesUnordered::new(); run_on_new_num_futs.push(run_on_new_num(starting_num)); let get_new_num_fut = Fuse::terminated(); pin_mut!(get_new_num_fut); loop { select! { () = interval_timer.select_next_some() => { // The timer has elapsed. Start a new `get_new_num_fut` // if one was not already running. if get_new_num_fut.is_terminated() { get_new_num_fut.set(get_new_num().fuse()); } }, new_num = get_new_num_fut => { // A new number has arrived -- start a new `run_on_new_num_fut`. run_on_new_num_futs.push(run_on_new_num(new_num)); }, // Run the `run_on_new_num_futs` and check if any have completed res = run_on_new_num_futs.select_next_some() => { println!("run_on_new_num_fut returned {:?}", res); }, // panic if everything completed, since the `interval_timer` should // keep yielding values indefinitely. complete => panic!("`interval_timer` completed unexpectedly"), } } } }
Spawning
Spawning allows you to run a new asynchronous task in the background. This allows us to continue executing other code while it runs.
Say we have a web server that wants to accept connections without blocking the main thread.
To achieve this, we can use the async_std::task::spawn function to create and run a new task that handles the
connections. This function takes a future and returns a JoinHandle, which can be used to wait for the result of the
task once it's completed.
use async_std::{task, net::TcpListener, net::TcpStream}; use futures::AsyncWriteExt; async fn process_request(stream: &mut TcpStream) -> Result<(), std::io::Error>{ stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n").await?; stream.write_all(b"Hello World").await?; Ok(()) } async fn main() { let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); loop { // Accept a new connection let (mut stream, _) = listener.accept().await.unwrap(); // Now process this request without blocking the main loop task::spawn(async move {process_request(&mut stream).await}); } }
The JoinHandle returned by spawn implements the Future trait, so we can .await it to get the result of the task.
This will block the current task until the spawned task completes. If the task is not awaited, your program will
continue executing without waiting for the task, cancelling it if the function is completed before the task is finished.
#![allow(unused)] fn main() { use futures::future::join_all; async fn task_spawner(){ let tasks = vec![ task::spawn(my_task(Duration::from_secs(1))), task::spawn(my_task(Duration::from_secs(2))), task::spawn(my_task(Duration::from_secs(3))), ]; // If we do not await these tasks and the function finishes, they will be dropped join_all(tasks).await; } }
To communicate between the main task and the spawned task, we can use channels provided by the async runtime used.
Workarounds to Know and Love
Rust's async support is still fairly new, and there are a handful of
highly-requested features still under active development, as well
as some subpar diagnostics. This chapter will discuss some common pain
points and explain how to work around them.
Send Approximation
Some async fn state machines are safe to be sent across threads, while
others are not. Whether or not an async fn Future is Send is determined
by whether a non-Send type is held across an .await point. The compiler
does its best to approximate when values may be held across an .await
point, but this analysis is too conservative in a number of places today.
For example, consider a simple non-Send type, perhaps a type
which contains an Rc:
#![allow(unused)] fn main() { use std::rc::Rc; #[derive(Default)] struct NotSend(Rc<()>); }
Variables of type NotSend can briefly appear as temporaries in async fns
even when the resulting Future type returned by the async fn must be Send:
use std::rc::Rc; #[derive(Default)] struct NotSend(Rc<()>); async fn bar() {} async fn foo() { NotSend::default(); bar().await; } fn require_send(_: impl Send) {} fn main() { require_send(foo()); }
However, if we change foo to store NotSend in a variable, this example no
longer compiles:
use std::rc::Rc; #[derive(Default)] struct NotSend(Rc<()>); async fn bar() {} async fn foo() { let x = NotSend::default(); bar().await; } fn require_send(_: impl Send) {} fn main() { require_send(foo()); }
error[E0277]: `std::rc::Rc<()>` cannot be sent between threads safely
--> src/main.rs:15:5
|
15 | require_send(foo());
| ^^^^^^^^^^^^ `std::rc::Rc<()>` cannot be sent between threads safely
|
= help: within `impl std::future::Future`, the trait `std::marker::Send` is not implemented for `std::rc::Rc<()>`
= note: required because it appears within the type `NotSend`
= note: required because it appears within the type `{NotSend, impl std::future::Future, ()}`
= note: required because it appears within the type `[static generator@src/main.rs:7:16: 10:2 {NotSend, impl std::future::Future, ()}]`
= note: required because it appears within the type `std::future::GenFuture<[static generator@src/main.rs:7:16: 10:2 {NotSend, impl std::future::Future, ()}]>`
= note: required because it appears within the type `impl std::future::Future`
= note: required because it appears within the type `impl std::future::Future`
note: required by `require_send`
--> src/main.rs:12:1
|
12 | fn require_send(_: impl Send) {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0277`.
This error is correct. If we store x into a variable, it won't be dropped
until after the .await, at which point the async fn may be running on
a different thread. Since Rc is not Send, allowing it to travel across
threads would be unsound. One simple solution to this would be to drop
the Rc before the .await, but unfortunately that does not work today.
In order to successfully work around this issue, you may have to introduce
a block scope encapsulating any non-Send variables. This makes it easier
for the compiler to tell that these variables do not live across an
.await point.
use std::rc::Rc; #[derive(Default)] struct NotSend(Rc<()>); async fn bar() {} async fn foo() { { let x = NotSend::default(); } bar().await; } fn require_send(_: impl Send) {} fn main() { require_send(foo()); }
Recursion
Internally, async fn creates a state machine type containing each
sub-Future being .awaited. This makes recursive async fns a little
tricky, since the resulting state machine type has to contain itself:
#![allow(unused)] fn main() { async fn step_one() { /* ... */ } async fn step_two() { /* ... */ } struct StepOne; struct StepTwo; // This function: async fn foo() { step_one().await; step_two().await; } // generates a type like this: enum Foo { First(StepOne), Second(StepTwo), } // So this function: async fn recursive() { recursive().await; recursive().await; } // generates a type like this: enum Recursive { First(Recursive), Second(Recursive), } }
This won't work—we've created an infinitely-sized type! The compiler will complain:
error[E0733]: recursion in an async fn requires boxing
--> src/lib.rs:1:1
|
1 | async fn recursive() {
| ^^^^^^^^^^^^^^^^^^^^
|
= note: a recursive `async fn` call must introduce indirection such as `Box::pin` to avoid an infinitely sized future
In order to allow this, we have to introduce an indirection using Box.
Prior to Rust 1.77, due to compiler limitations, just wrapping the calls to
recursive() in Box::pin isn't enough. To make this work, we have
to make recursive into a non-async function which returns a .boxed()
async block:
#![allow(unused)] fn main() { use futures::future::{BoxFuture, FutureExt}; fn recursive() -> BoxFuture<'static, ()> { async move { recursive().await; recursive().await; }.boxed() } }
In newer version of Rust, that compiler limitation has been lifted.
Since Rust 1.77, support for recursion in async fn with allocation
indirection becomes stable, so recursive calls are permitted so long as they
use some form of indirection to avoid an infinite size for the state of the
function.
This means that code like this now works:
#![allow(unused)] fn main() { async fn recursive_pinned() { Box::pin(recursive_pinned()).await; Box::pin(recursive_pinned()).await; } }
async in Traits
Currently, async fn cannot be used in traits on the stable release of Rust.
Since the 17th November 2022, an MVP of async-fn-in-trait is available on the nightly
version of the compiler tool chain, see here for details.
In the meantime, there is a work around for the stable tool chain using the async-trait crate from crates.io.
Note that using these trait methods will result in a heap allocation per-function-call. This is not a significant cost for the vast majority of applications, but should be considered when deciding whether to use this functionality in the public API of a low-level function that is expected to be called millions of times a second.
Last updates: https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html
The Async Ecosystem
Rust currently provides only the bare essentials for writing async code. Importantly, executors, tasks, reactors, combinators, and low-level I/O futures and traits are not yet provided in the standard library. In the meantime, community-provided async ecosystems fill in these gaps.
The Async Foundations Team is interested in extending examples in the Async Book to cover multiple runtimes. If you're interested in contributing to this project, please reach out to us on Zulip.
Async Runtimes
Async runtimes are libraries used for executing async applications. Runtimes usually bundle together a reactor with one or more executors. Reactors provide subscription mechanisms for external events, like async I/O, interprocess communication, and timers. In an async runtime, subscribers are typically futures representing low-level I/O operations. Executors handle the scheduling and execution of tasks. They keep track of running and suspended tasks, poll futures to completion, and wake tasks when they can make progress. The word "executor" is frequently used interchangeably with "runtime". Here, we use the word "ecosystem" to describe a runtime bundled with compatible traits and features.
Community-Provided Async Crates
The Futures Crate
The futures crate contains traits and functions useful for writing async code.
This includes the Stream, Sink, AsyncRead, and AsyncWrite traits, and utilities such as combinators.
These utilities and traits may eventually become part of the standard library.
futures has its own executor, but not its own reactor, so it does not support execution of async I/O or timer futures.
For this reason, it's not considered a full runtime.
A common choice is to use utilities from futures with an executor from another crate.
Popular Async Runtimes
There is no asynchronous runtime in the standard library, and none are officially recommended. The following crates provide popular runtimes.
- Tokio: A popular async ecosystem with HTTP, gRPC, and tracing frameworks.
- async-std: A crate that provides asynchronous counterparts to standard library components.
- smol: A small, simplified async runtime.
Provides the
Asynctrait that can be used to wrap structs likeUnixStreamorTcpListener. - fuchsia-async: An executor for use in the Fuchsia OS.
Determining Ecosystem Compatibility
Not all async applications, frameworks, and libraries are compatible with each other, or with every OS or platform. Most async code can be used with any ecosystem, but some frameworks and libraries require the use of a specific ecosystem. Ecosystem constraints are not always documented, but there are several rules of thumb to determine whether a library, trait, or function depends on a specific ecosystem.
Any async code that interacts with async I/O, timers, interprocess communication, or tasks generally depends on a specific async executor or reactor. All other async code, such as async expressions, combinators, synchronization types, and streams are usually ecosystem independent, provided that any nested futures are also ecosystem independent. Before beginning a project, it's recommended to research relevant async frameworks and libraries to ensure compatibility with your chosen runtime and with each other.
Notably, Tokio uses the mio reactor and defines its own versions of async I/O traits,
including AsyncRead and AsyncWrite.
On its own, it's not compatible with async-std and smol,
which rely on the async-executor crate, and the AsyncRead and AsyncWrite
traits defined in futures.
Conflicting runtime requirements can sometimes be resolved by compatibility layers
that allow you to call code written for one runtime within another.
For example, the async_compat crate provides a compatibility layer between
Tokio and other runtimes.
Libraries exposing async APIs should not depend on a specific executor or reactor, unless they need to spawn tasks or define their own async I/O or timer futures. Ideally, only binaries should be responsible for scheduling and running tasks.
Single Threaded vs Multi-Threaded Executors
Async executors can be single-threaded or multi-threaded.
For example, the async-executor crate has both a single-threaded LocalExecutor and a multi-threaded Executor.
A multi-threaded executor makes progress on several tasks simultaneously. It can speed up the execution greatly for workloads with many tasks, but synchronizing data between tasks is usually more expensive. It is recommended to measure performance for your application when you are choosing between a single- and a multi-threaded runtime.
Tasks can either be run on the thread that created them or on a separate thread.
Async runtimes often provide functionality for spawning tasks onto separate threads.
Even if tasks are executed on separate threads, they should still be non-blocking.
In order to schedule tasks on a multi-threaded executor, they must also be Send.
Some runtimes provide functions for spawning non-Send tasks,
which ensures every task is executed on the thread that spawned it.
They may also provide functions for spawning blocking tasks onto dedicated threads,
which is useful for running blocking synchronous code from other libraries.
Final Project: Building a Concurrent Web Server with Async Rust
In this chapter, we'll use asynchronous Rust to modify the Rust book's single-threaded web server to serve requests concurrently.
Recap
Here's what the code looked like at the end of the lesson.
src/main.rs:
use std::fs; use std::io::prelude::*; use std::net::TcpListener; use std::net::TcpStream; fn main() { // Listen for incoming TCP connections on localhost port 7878 let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); // Block forever, handling each request that arrives at this IP address for stream in listener.incoming() { let stream = stream.unwrap(); handle_connection(stream); } } fn handle_connection(mut stream: TcpStream) { // Read the first 1024 bytes of data from the stream let mut buffer = [0; 1024]; stream.read(&mut buffer).unwrap(); let get = b"GET / HTTP/1.1\r\n"; // Respond with greetings or a 404, // depending on the data in the request let (status_line, filename) = if buffer.starts_with(get) { ("HTTP/1.1 200 OK\r\n\r\n", "hello.html") } else { ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html") }; let contents = fs::read_to_string(filename).unwrap(); // Write response back to the stream, // and flush the stream to ensure the response is sent back to the client let response = format!("{status_line}{contents}"); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); }
hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
404.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
If you run the server with cargo run and visit 127.0.0.1:7878 in your browser,
you'll be greeted with a friendly message from Ferris!
Running Asynchronous Code
An HTTP server should be able to serve multiple clients concurrently; that is, it should not wait for previous requests to complete before handling the current request. The book solves this problem by creating a thread pool where each connection is handled on its own thread. Here, instead of improving throughput by adding threads, we'll achieve the same effect using asynchronous code.
Let's modify handle_connection to return a future by declaring it an async fn:
async fn handle_connection(mut stream: TcpStream) {
//<-- snip -->
}
Adding async to the function declaration changes its return type
from the unit type () to a type that implements Future<Output=()>.
If we try to compile this, the compiler warns us that it will not work:
$ cargo check
Checking async-rust v0.1.0 (file:///projects/async-rust)
warning: unused implementer of `std::future::Future` that must be used
--> src/main.rs:12:9
|
12 | handle_connection(stream);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: futures do nothing unless you `.await` or poll them
Because we haven't awaited or polled the result of handle_connection,
it'll never run. If you run the server and visit 127.0.0.1:7878 in a browser,
you'll see that the connection is refused; our server is not handling requests.
We can't await or poll futures within synchronous code by itself.
We'll need an asynchronous runtime to handle scheduling and running futures to completion.
Please consult the section on choosing a runtime
for more information on asynchronous runtimes, executors, and reactors.
Any of the runtimes listed will work for this project, but for these examples,
we've chosen to use the async-std crate.
Adding an Async Runtime
The following example will demonstrate refactoring synchronous code to use an async runtime; here, async-std.
The #[async_std::main] attribute from async-std allows us to write an asynchronous main function.
To use it, enable the attributes feature of async-std in Cargo.toml:
[dependencies.async-std]
version = "1.6"
features = ["attributes"]
As a first step, we'll switch to an asynchronous main function,
and await the future returned by the async version of handle_connection.
Then, we'll test how the server responds.
Here's what that would look like:
#[async_std::main] async fn main() { let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); for stream in listener.incoming() { let stream = stream.unwrap(); // Warning: This is not concurrent! handle_connection(stream).await; } }
Now, let's test to see if our server can handle connections concurrently.
Simply making handle_connection asynchronous doesn't mean that the server
can handle multiple connections at the same time, and we'll soon see why.
To illustrate this, let's simulate a slow request.
When a client makes a request to 127.0.0.1:7878/sleep,
our server will sleep for 5 seconds:
use std::time::Duration;
use async_std::task;
async fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).unwrap();
let get = b"GET / HTTP/1.1\r\n";
let sleep = b"GET /sleep HTTP/1.1\r\n";
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
} else if buffer.starts_with(sleep) {
task::sleep(Duration::from_secs(5)).await;
("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let response = format!("{status_line}{contents}");
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
This is very similar to the
simulation of a slow request
from the Book, but with one important difference:
we're using the non-blocking function async_std::task::sleep instead of the blocking function std::thread::sleep.
It's important to remember that even if a piece of code is run within an async fn and awaited, it may still block.
To test whether our server handles connections concurrently, we'll need to ensure that handle_connection is non-blocking.
If you run the server, you'll see that a request to 127.0.0.1:7878/sleep
will block any other incoming requests for 5 seconds!
This is because there are no other concurrent tasks that can make progress
while we are awaiting the result of handle_connection.
In the next section, we'll see how to use async code to handle connections concurrently.
Handling Connections Concurrently
The problem with our code so far is that listener.incoming() is a blocking iterator.
The executor can't run other futures while listener waits on incoming connections,
and we can't handle a new connection until we're done with the previous one.
In order to fix this, we'll transform listener.incoming() from a blocking Iterator
to a non-blocking Stream. Streams are similar to Iterators, but can be consumed asynchronously.
For more information, see the chapter on Streams.
Let's replace our blocking std::net::TcpListener with the non-blocking async_std::net::TcpListener,
and update our connection handler to accept an async_std::net::TcpStream:
use async_std::prelude::*;
async fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).await.unwrap();
//<-- snip -->
stream.write(response.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
}
The asynchronous version of TcpListener implements the Stream trait for listener.incoming(),
a change which provides two benefits.
The first is that listener.incoming() no longer blocks the executor.
The executor can now yield to other pending futures
while there are no incoming TCP connections to be processed.
The second benefit is that elements from the Stream can optionally be processed concurrently,
using a Stream's for_each_concurrent method.
Here, we'll take advantage of this method to handle each incoming request concurrently.
We'll need to import the Stream trait from the futures crate, so our Cargo.toml now looks like this:
+[dependencies]
+futures = "0.3"
[dependencies.async-std]
version = "1.6"
features = ["attributes"]
Now, we can handle each connection concurrently by passing handle_connection in through a closure function.
The closure function takes ownership of each TcpStream, and is run as soon as a new TcpStream becomes available.
As long as handle_connection does not block, a slow request will no longer prevent other requests from completing.
use async_std::net::TcpListener;
use async_std::net::TcpStream;
use futures::stream::StreamExt;
#[async_std::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap();
listener
.incoming()
.for_each_concurrent(/* limit */ None, |tcpstream| async move {
let tcpstream = tcpstream.unwrap();
handle_connection(tcpstream).await;
})
.await;
}
Serving Requests in Parallel
Our example so far has largely presented cooperative multitasking concurrency (using async code)
as an alternative to preemptive multitasking (using threads).
However, async code and threads are not mutually exclusive.
In our example, for_each_concurrent processes each connection concurrently, but on the same thread.
The async-std crate allows us to spawn tasks onto separate threads as well.
Because handle_connection is both Send and non-blocking, it's safe to use with async_std::task::spawn.
Here's what that would look like:
use async_std::task::spawn; #[async_std::main] async fn main() { let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap(); listener .incoming() .for_each_concurrent(/* limit */ None, |stream| async move { let stream = stream.unwrap(); spawn(handle_connection(stream)); }) .await; }
Now we are using both cooperative multitasking concurrency and preemptive multitasking to handle multiple requests at the same time! See the section on multithreaded executors for more information.
Testing the TCP Server
Let's move on to testing our handle_connection function.
First, we need a TcpStream to work with.
In an end-to-end or integration test, we might want to make a real TCP connection
to test our code.
One strategy for doing this is to start a listener on localhost port 0.
Port 0 isn't a valid UNIX port, but it'll work for testing.
The operating system will pick an open TCP port for us.
Instead, in this example we'll write a unit test for the connection handler,
to check that the correct responses are returned for the respective inputs.
To keep our unit test isolated and deterministic, we'll replace the TcpStream with a mock.
First, we'll change the signature of handle_connection to make it easier to test.
handle_connection doesn't actually require an async_std::net::TcpStream;
it requires any struct that implements async_std::io::Read, async_std::io::Write, and marker::Unpin.
Changing the type signature to reflect this allows us to pass a mock for testing.
use async_std::io::{Read, Write};
async fn handle_connection(mut stream: impl Read + Write + Unpin) {
Next, let's build a mock TcpStream that implements these traits.
First, let's implement the Read trait, with one method, poll_read.
Our mock TcpStream will contain some data that is copied into the read buffer,
and we'll return Poll::Ready to signify that the read is complete.
use super::*;
use futures::io::Error;
use futures::task::{Context, Poll};
use std::cmp::min;
use std::pin::Pin;
struct MockTcpStream {
read_data: Vec<u8>,
write_data: Vec<u8>,
}
impl Read for MockTcpStream {
fn poll_read(
self: Pin<&mut Self>,
_: &mut Context,
buf: &mut [u8],
) -> Poll<Result<usize, Error>> {
let size: usize = min(self.read_data.len(), buf.len());
buf[..size].copy_from_slice(&self.read_data[..size]);
Poll::Ready(Ok(size))
}
}
Our implementation of Write is very similar,
although we'll need to write three methods: poll_write, poll_flush, and poll_close.
poll_write will copy any input data into the mock TcpStream, and return Poll::Ready when complete.
No work needs to be done to flush or close the mock TcpStream, so poll_flush and poll_close
can just return Poll::Ready.
impl Write for MockTcpStream {
fn poll_write(
mut self: Pin<&mut Self>,
_: &mut Context,
buf: &[u8],
) -> Poll<Result<usize, Error>> {
self.write_data = Vec::from(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _: &mut Context) -> Poll<Result<(), Error>> {
Poll::Ready(Ok(()))
}
fn poll_close(self: Pin<&mut Self>, _: &mut Context) -> Poll<Result<(), Error>> {
Poll::Ready(Ok(()))
}
}
Lastly, our mock will need to implement Unpin, signifying that its location in memory can safely be moved.
For more information on pinning and the Unpin trait, see the section on pinning.
impl Unpin for MockTcpStream {}
Now we're ready to test the handle_connection function.
After setting up the MockTcpStream containing some initial data,
we can run handle_connection using the attribute #[async_std::test], similarly to how we used #[async_std::main].
To ensure that handle_connection works as intended, we'll check that the correct data
was written to the MockTcpStream based on its initial contents.
use std::fs;
#[async_std::test]
async fn test_handle_connection() {
let input_bytes = b"GET / HTTP/1.1\r\n";
let mut contents = vec![0u8; 1024];
contents[..input_bytes.len()].clone_from_slice(input_bytes);
let mut stream = MockTcpStream {
read_data: contents,
write_data: Vec::new(),
};
handle_connection(&mut stream).await;
let expected_contents = fs::read_to_string("hello.html").unwrap();
let expected_response = format!("HTTP/1.1 200 OK\r\n\r\n{}", expected_contents);
assert!(stream.write_data.starts_with(expected_response.as_bytes()));
}
Appendix : Translations of the Book
For resources in languages other than English.
AWAIT vs SPAWN
1. await - приостановка выполнения задачи
Что делает:
awaitприостанавливает выполнение текущей асинхронной функции до тех пор, пока будущее (Future) не будет завершено. При этом он не блокирует весь поток - пока одна задача ждет, другие могут выполняться.
Простой пример:
use tokio::time::{sleep, Duration}; async fn download_file(filename: &str) -> String { println!("Начинаю загрузку: {}", filename); sleep(Duration::from_secs(2)).await; // ⏸️ ПАУЗА здесь println!("Завершил загрузку: {}", filename); format!("Содержимое {}", filename) } #[tokio::main] async fn main() { println!("Начало программы"); let content = download_file("document.txt").await; println!("Получили: {}", content); println!("Конец программы"); }
Вывод:
Начало программы
Начинаю загрузку: document.txt
(ждем 2 секунды...)
Завершил загрузку: document.txt
Получили: Содержимое document.txt
Конец программы
2. tokio::spawn - запуск параллельной задачи
Что делает:
tokio::spawnсоздает новую асинхронную задачу, которая выполняется параллельно с текущей. ВозвращаетJoinHandle, через который можно дождаться результата.
Простой пример:
use tokio::time::{sleep, Duration}; async fn download_file(filename: &str) -> String { println!("Начинаю загрузку: {}", filename); sleep(Duration::from_secs(2)).await; println!("Завершил загрузку: {}", filename); format!("Содержимое {}", filename) } #[tokio::main] async fn main() { println!("Начало программы"); // Запускаем две загрузки параллельно let task1 = tokio::spawn(async { download_file("document1.txt").await }); let task2 = tokio::spawn(async { download_file("document2.txt").await }); // Ждем завершения обеих задач let result1 = task1.await.unwrap(); let result2 = task2.await.unwrap(); println!("Результаты: {} и {}", result1, result2); println!("Конец программы"); }
Вывод (обратите внимание на параллельность):
Начало программы
Начинаю загрузку: document1.txt
Начинаю загрузку: document2.txt
(ждем 2 секунды, но обе загрузки идут параллельно!)
Завершил загрузку: document1.txt
Завершил загрузку: document2.txt
Результаты: Содержимое document1.txt и Содержимое document2.txt
Конец программы
Ключевые различия
| Аспект | await | tokio::spawn |
|---|---|---|
| Параллелизм | Последовательное выполнение | Параллельное выполнение |
| Блокировка | Не блокирует поток, только текущую задачу | Создает новую независимую задачу |
| Использование | Для ожидания результата Future | Для запуска параллельной работы |
| Результат | Значение Future | JoinHandle для ожидания результата |
Схема работы await (последовательное выполнение):
Схема работы tokio::spawn (параллельное выполнение):
Комбинированный пример
use tokio::time::{sleep, Duration}; async fn expensive_calculation(n: u32) -> u32 { println!("Начинаю вычисление {}", n); sleep(Duration::from_secs(1)).await; println!("Завершил вычисление {}", n); n * n } #[tokio::main] async fn main() { // ПЛОХО: последовательное выполнение (3 секунды) let start = std::time::Instant::now(); let _a = expensive_calculation(1).await; let _b = expensive_calculation(2).await; let _c = expensive_calculation(3).await; println!("Последовательное выполнение: {:?}", start.elapsed()); // ХОРОШО: параллельное выполнение (~1 секунда) let start = std::time::Instant::now(); let task1 = tokio::spawn(expensive_calculation(1)); let task2 = tokio::spawn(expensive_calculation(2)); let task3 = tokio::spawn(expensive_calculation(3)); // await для каждого JoinHandle let _a = task1.await.unwrap(); let _b = task2.await.unwrap(); let _c = task3.await.unwrap(); println!("Параллельное выполнение: {:?}", start.elapsed()); }
Итог
await= "Подожди, пока эта операция завершится, прежде чем продолжать"tokio::spawn= "Запусти эту работу в фоне, я могу заниматься другими делами"
Используйте await для последовательных операций, которые зависят друг от друга, и tokio::spawn для независимых операций, которые можно выполнять параллельно.
Async: Что такое блокировка?
Опубликовано 2020-12-21
Автор: Alice Ryhl
Функция async/await в Rust реализована с использованием механизма, известного как кооперативная многозадачность (cooperative scheduling), и это имеет важные последствия для тех, кто пишет асинхронный код на Rust.
Целевая аудитория этой статьи — новые пользователи асинхронного Rust. В примерах я буду использовать рантайм Tokio, но поднятые вопросы применимы к любому асинхронному рантайму.
Если вы запомните из этой статьи лишь одну вещь, пусть это будет она:
Асинхронный код никогда не должен проводить много времени, не достигая .await.
Блокирующий vs. неблокирующий код
Наивный способ написать приложение, которое работает над многими задачами одновременно, — это порождать новый поток для каждой задачи. Если количество задач невелико, это perfectly fine решение, но когда количество задач становится большим, вы в конечном итоге столкнетесь с проблемами из-за большого количества потоков. В разных языках программирования существуют различные решения этой проблемы, но все они сводятся к одному: очень быстро "переключать" текущую выполняемую задачу в каждом потоке, так чтобы все задачи получили возможность выполниться. В Rust это переключение происходит, когда вы вызываете .await.
При написании асинхронного Rust фраза «блокировать поток» означает «препятствовать рантайму в переключении текущей задачи». Это может стать серьезной проблемой, потому что это означает, что другие задачи в том же рантайме перестанут выполняться, пока поток заблокирован. Чтобы предотвратить это, мы должны писать код, который можно быстро переключать, что достигается за счет того, чтобы никогда не проводить долгое время вдали от .await.
Давайте рассмотрим пример: ▶︎
use std::time::Duration; #[tokio::main] async fn main() { println!("Hello World!"); // Здесь нет .await! std::thread::sleep(Duration::from_secs(5)); println!("Five seconds later..."); }
Вышеприведенный код выглядит правильным, и если вы запустите его, он, казалось бы, сработает. Но в нем есть фатальный изъян: он блокирует поток. В данном случае других задач нет, так что это не проблема, но в реальных программах это будет не так. Чтобы проиллюстрировать это, рассмотрим следующий пример: ▶︎
use std::time::Duration; async fn sleep_then_print(timer: i32) { println!("Start timer {}.", timer); // Здесь нет .await! std::thread::sleep(Duration::from_secs(1)); println!("Timer {} done.", timer); } #[tokio::main] async fn main() { // Макрос join! позволяет запускать несколько задач конкурентно. tokio::join!( sleep_then_print(1), sleep_then_print(2), sleep_then_print(3), ); }
Start timer 1.
Timer 1 done.
Start timer 2.
Timer 2 done.
Start timer 3.
Timer 3 done.
Примеру потребуется три секунды на выполнение, и таймеры будут запускаться один за другим, без какого-либо параллелизма. Причина проста: рантайм Tokio не смог переключить одну задачу на другую, потому что такое переключение может произойти только на .await. Поскольку в sleep_then_print нет .await, никакое переключение не может произойти, пока он выполняется.
Однако, если мы вместо этого используем функцию сна Tokio, которая использует .await для ожидания, функция будет вести себя правильно:
▶︎
use tokio::time::Duration; async fn sleep_then_print(timer: i32) { println!("Start timer {}.", timer); tokio::time::sleep(Duration::from_secs(1)).await; // ^ выполнение может быть приостановлено здесь println!("Timer {} done.", timer); } #[tokio::main] async fn main() { // Макрос join! позволяет запускать несколько задач конкурентно. tokio::join!( sleep_then_print(1), sleep_then_print(2), sleep_then_print(3), ); }
Start timer 1.
Start timer 2.
Start timer 3.
Timer 1 done.
Timer 2 done.
Timer 3 done.
Код выполняется всего за одну секунду и правильно, как и задумано, запускает все три функции одновременно.
Имейте в виду, что это не всегда так очевидно. Используя tokio::join!, все три задачи гарантированно выполняются в одном потоке, но если вы замените его на tokio::spawn и будете использовать многопоточный рантайм, вы сможете запускать несколько блокирующих задач, пока не исчерпаете потоки. Рантайм Tokio по умолчанию создает один поток на ядро CPU, и обычно у вас около 8 ядер CPU. Этого достаточно, чтобы можно было пропустить проблему при локальном тестировании, но достаточно мало, чтобы вы очень быстро исчерпали потоки при реальном запуске кода.
Чтобы дать представление о масштабе, сколько времени — это слишком много, хорошим эмпирическим правилом является не более 10–100 микросекунд между каждым .await. Тем не менее, это зависит от типа приложения, которое вы пишете.
Что, если я хочу блокировать?
Иногда мы просто хотим блокировать поток. Это абсолютно нормально. Есть две распространенные причины для этого:
- Вычислительно сложные (CPU-bound) операции.
- Синхронный ввод-вывод (Synchronous IO).
В обоих случаях мы имеем дело с операцией, которая препятствует достижению задачей .await в течение extended period of time. Чтобы решить эту проблему, мы должны переместить блокирующую операцию в поток вне пула потоков Tokio. Есть три варианта этого:
- Использовать функцию
tokio::task::spawn_blocking. - Использовать крейт
rayon. - Создать выделенный поток с помощью
std::thread::spawn.
Давайте рассмотрим каждое решение, чтобы понять, когда его следует использовать.
Функция spawn_blocking
Рантайм Tokio включает отдельный пул потоков, специально предназначенный для выполнения блокирующих функций, и вы можете порождать задачи в нем с помощью spawn_blocking. Этот пул потоков имеет верхний предел около 500 потоков, так что вы можете породить quite a lot блокирующих операций в этом пуле.
Поскольку в пуле потоков так много потоков, он лучше всего подходит для блокирующего IO, такого как взаимодействие с файловой системой или использование блокирующей библиотеки для работы с базами данных, например diesel.
Пул потоков плохо подходит для ресурсоемких вычислений (CPU-bound computations), поскольку в нем гораздо больше потоков, чем ядер CPU на вашем компьютере. CPU-bound вычисления выполняются наиболее эффективно, если количество потоков равно количеству ядер CPU. Тем не менее, если вам нужно всего несколько CPU-bound вычислений, я не стану винить вас за их запуск через spawn_blocking, так как это довольно просто сделать.
▶︎
#[tokio::main] async fn main() { // Это выполняется в Tokio. Мы не должны блокировать здесь. let blocking_task = tokio::task::spawn_blocking(|| { // Это выполняется в потоке, где блокировка допустима. println!("Inside spawn_blocking"); }); // Мы можем ждать завершения блокирующей задачи вот так: // Если блокирующая задача запаникует, unwrap ниже распространит панику. blocking_task.await.unwrap(); }
Крейт rayon
Крейт rayon — это well known библиотека, которая предоставляет пул потоков, специально предназначенный для ресурсоемких вычислений (CPU-bound computations), и вы можете использовать его для этой цели вместе с Tokio. В отличие от spawn_blocking, пул потоков rayon имеет небольшое максимальное количество потоков, поэтому он подходит для дорогостоящих вычислений.
Мы будем использовать сумму большого списка в качестве примера дорогостоящего вычисления, но обратите внимание, что на практике, если только массив не очень-очень большой, простое вычисление суммы, вероятно, достаточно дешево, чтобы вы могли делать его напрямую в Tokio.
Основная опасность использования rayon заключается в том, что вы должны быть осторожны, чтобы не блокировать поток в ожидании завершения rayon. Чтобы сделать это, объедините rayon::spawn с tokio::sync::oneshot следующим образом:
▶︎
async fn parallel_sum(nums: Vec<i32>) -> i32 { let (send, recv) = tokio::sync::oneshot::channel(); // Запустить задачу в rayon. rayon::spawn(move || { // Выполнить тяжелое вычисление. let mut sum = 0; for num in nums { sum += num; } // Отправить результат обратно в Tokio. let _ = send.send(sum); }); // Ждать завершения задачи в rayon. recv.await.expect("Panic in rayon::spawn") } #[tokio::main] async fn main() { let nums = vec![1; 1024 * 1024]; println!("{}", parallel_sum(nums).await); }
Это использует пул потоков rayon для выполнения дорогой операции. Имейте в виду, что приведенный выше пример использует только один поток в пуле rayon за вызов parallel_sum. Это имеет смысл, если у вас много вызовов parallel_sum в вашем приложении, но также можно использовать параллельные итераторы rayon для вычисления суммы на нескольких потоках:
▶︎
#![allow(unused)] fn main() { use rayon::prelude::*; // Запустить задачу в rayon. rayon::spawn(move || { // Вычислить сумму на нескольких потоках. let sum = nums.par_iter().sum(); // Отправить результат обратно в Tokio. let _ = send.send(sum); }); }
Обратите внимание, что вам все еще нужен вызов rayon::spawn при использовании параллельных итераторов, потому что параллельные итераторы являются блокирующими.
Создание выделенного потока
Если блокирующая операция выполняется вечно (keeps running forever), вам следует запускать ее в выделенном потоке. Например, рассмотрим поток, который управляет подключением к базе данных, используя канал для получения операций с базой данных для выполнения. Поскольку этот поток прослушивает этот канал в цикле, он никогда не завершается.
Запуск такой задачи в любом из двух других пулов потоков является проблемой, потому что это essentially забирает поток из пула навсегда. После того как вы сделаете это несколько раз, в пуле потоков не останется потоков, и все остальные блокирующие задачи не смогут быть выполнены.
Конечно, вы также можете использовать выделенные потоки для кратковременных целей, если вы согласны платить цену за порождение нового потока каждый раз, когда вы запускаете новый.
Резюме
Если вы забыли, вот главное, что вам нужно запомнить:
Асинхронный код никогда не должен проводить много времени, не достигая .await.
Ниже вы найдете шпаргалку по методам, которые вы можете использовать, когда хотите заблокировать:
| Метод | CPU-bound вычисления | Синхронный IO | Выполняется вечно |
|---|---|---|---|
spawn_blocking | Субоптимально | OK | Нет |
rayon | OK | Нет | Нет |
| Выделенный поток | OK | OK | OK |
Наконец, я рекомендую ознакомиться с главой о совместном использовании состояния (shared state) из учебника по Tokio. Эта глава объясняет, как вы можете правильно использовать std::sync::Mutex в асинхронном коде и более подробно рассказывает, почему это допустимо, даже несмотря на то, что блокировка мьютекса является блокирующей операцией. (Спойлер: если вы блокируете ненадолго, это действительно блокировка?)
Я также настоятельно рекомендую статью Reducing tail latencies with automatic cooperative task yielding из блога Tokio.
Спасибо Крису Кричо (Chris Krycho) и Эрике Класен (Erika Clasen) за прочтение черновиков этой статьи и предоставление полезных советов. Все ошибки — мои.
Акторы с Tokio
Опубликовано 2021-02-13 Автор: Alice Ryhl Источник
Эта статья о создании акторов напрямую с помощью Tokio, без использования каких-либо библиотек акторов, таких как Actix. Оказывается, это довольно легко сделать, однако есть некоторые детали, о которых вам следует знать:
- Где разместить вызов
tokio::spawn. - Структура с методом
runvs отдельная функция. - Handle'ы (дескрипторы) для актора.
- Противодавление (Backpressure) и ограниченные каналы.
- Плавное завершение работы (Graceful shutdown).
Методы, описанные в этой статье, должны работать с любым исполнителем (executor), но для простоты мы будем говорить только о Tokio. Есть некоторое пересечение с главами о порождении задач и каналах из учебника по Tokio, и я рекомендую также прочитать эти главы.
Прежде чем мы сможем говорить о том, как писать актор, нам нужно знать, что такое актор. Основная идея актора заключается в порождении самодостаточной задачи, которая выполняет некоторую работу независимо от других частей программы. Обычно эти акторы общаются с остальной частью программы с помощью каналов передачи сообщений. Поскольку каждый актор работает независимо, программы, разработанные с их использованием, естественно параллельны.
Распространенный вариант использования акторов — назначить актору эксклюзивное владение некоторым ресурсом, которым вы хотите делиться, а затем позволить другим задачам получать доступ к этому ресурсу косвенно, общаясь с актором. Например, если вы реализуете сервер чата, вы можете породить задачу для каждого подключения и главную задачу, которая маршрутизирует сообщения чата между другими задачами. Это полезно, потому что главная задача может избежать необходимости работать с сетевым вводом-выводом, а задачи подключения могут сосредоточиться исключительно на работе с сетевым вводом-выводом.
Эта статья также доступна в виде доклада на YouTube.
Рецепт
Актор разделен на две части: задача (task) и handle (дескриптор). Задача — это независимо порожденная задача Tokio, которая фактически выполняет обязанности актора, а handle — это структура, которая позволяет вам общаться с задачей.
Рассмотрим простой актор. Актор внутренне хранит счетчик, который используется для получения какого-то уникального идентификатора. Базовая структура актора будет выглядеть примерно так:
#![allow(unused)] fn main() { use tokio::sync::{oneshot, mpsc}; struct MyActor { receiver: mpsc::Receiver<ActorMessage>, next_id: u32, } enum ActorMessage { GetUniqueId { respond_to: oneshot::Sender<u32>, }, } impl MyActor { fn new(receiver: mpsc::Receiver<ActorMessage>) -> Self { MyActor { receiver, next_id: 0, } } fn handle_message(&mut self, msg: ActorMessage) { match msg { ActorMessage::GetUniqueId { respond_to } => { self.next_id += 1; // `let _ =` игнорирует любые ошибки при отправке. // // Это может произойти, если макрос `select!` // используется для отмены ожидания ответа. let _ = respond_to.send(self.next_id); }, } } } async fn run_my_actor(mut actor: MyActor) { while let Some(msg) = actor.receiver.recv().await { actor.handle_message(msg); } } }
Теперь, когда у нас есть сам актор, нам также нужен handle к актору. Handle — это объект, который другие части кода могут использовать для общения с актором, и он же поддерживает жизнь актора.
Handle будет выглядеть так:
#![allow(unused)] fn main() { #[derive(Clone)] pub struct MyActorHandle { sender: mpsc::Sender<ActorMessage>, } impl MyActorHandle { pub fn new() -> Self { let (sender, receiver) = mpsc::channel(8); let actor = MyActor::new(receiver); tokio::spawn(run_my_actor(actor)); Self { sender } } pub async fn get_unique_id(&self) -> u32 { let (send, recv) = oneshot::channel(); let msg = ActorMessage::GetUniqueId { respond_to: send, }; // Игнорируем ошибки отправки. Если эта отправка не удалась, // то и recv.await ниже тоже не удастся. Нет причины проверять // одну и ту же ошибку дважды. let _ = self.sender.send(msg).await; recv.await.expect("Задача актора была убита") } } }
Давайте внимательнее рассмотрим различные части этого примера.
ActorMessage. Перечисление ActorMessage определяет типы сообщений, которые мы можем отправить актору. Используя перечисление, мы можем иметь много разных типов сообщений, и каждый тип сообщения может иметь свой собственный набор аргументов. Мы возвращаем значение отправителю, используя канал oneshot, который является каналом передачи сообщений, позволяющим отправить ровно одно сообщение.
В примере выше мы сопоставляем шаблон с перечислением внутри метода handle_message для структуры актора, но это не единственный способ структурирования этого. Можно также сопоставлять шаблон с перечислением в функции run_my_actor. Каждая ветвь в этом сопоставлении может затем вызывать различные методы, такие как get_unique_id, для объекта актора.
Ошибки при отправке сообщений. При работе с каналами не все ошибки являются фатальными. Из-за этого в примере иногда используется let _ =, чтобы игнорировать ошибки. Как правило, операция отправки в канал завершается неудачей, если получатель был удален.
Первый случай этого в нашем примере — строка в акторе, где мы отвечаем на отправленное нам сообщение. Это может произойти, если получатель больше не заинтересован в результате операции, например, если задача, отправившая сообщение, могла быть убита.
Завершение работы актора. Мы можем определить, когда актор должен завершить работу, посмотрев на сбои при получении сообщений. В нашем примере это происходит в следующем цикле while:
#![allow(unused)] fn main() { while let Some(msg) = actor.receiver.recv().await { actor.handle_message(msg); } }
Когда все отправители для получателя удалены, мы знаем, что больше никогда не получим сообщений и, следовательно, можем завершить работу актора. Когда это происходит, вызов .recv() возвращает None, и поскольку он не соответствует шаблону Some(msg), цикл while завершается, и функция возвращает управление.
#[derive(Clone)]. Структура MyActorHandle реализует типаж Clone. Она может это делать, потому что mpsc означает, что это канал с несколькими отправителями и одним получателем (multiple-producer, single-consumer). Поскольку канал допускает несколько производителей, мы можем свободно клонировать наш handle к актору, позволяя нам общаться с ним из нескольких мест.
Метод run в структуре
В приведенном выше примере используется функция верхнего уровня, не определенная ни для какой структуры, в качестве того, что мы порождаем как задачу Tokio. Однако многие считают более естественным определить метод run непосредственно для структуры MyActor и порождать его. Это, конечно, тоже работает, но причина, по которой я привожу пример с функцией верхнего уровня, заключается в том, что она более естественно подводит вас к подходу, который не создает много проблем с временами жизни.
Чтобы понять почему, я подготовил пример того, что часто придумывают люди, незнакомые с этим шаблоном.
#![allow(unused)] fn main() { impl MyActor { fn run(&mut self) { tokio::spawn(async move { while let Some(msg) = self.receiver.recv().await { self.handle_message(msg); } }); } pub async fn get_unique_id(&self) -> u32 { let (send, recv) = oneshot::channel(); let msg = ActorMessage::GetUniqueId { respond_to: send, }; // Игнорируем ошибки отправки. Если эта отправка не удалась, // то и recv.await ниже тоже не удастся. Нет причины проверять // одну и ту же ошибку дважды. let _ = self.sender.send(msg).await; recv.await.expect("Задача актора была убита") } } }
... и без отдельного MyActorHandle
Два источника проблем в этом примере:
- Вызов
tokio::spawnнаходится внутриrun. - Актор и handle — это одна и та же структура.
Первая проблема вызывает затруднения, потому что функция tokio::spawn требует, чтобы аргумент был 'static. Это означает, что новая задача должна владеть всем внутри себя, что является проблемой, потому что метод заимствует self, а значит, он не может передать владение self новой задаче.
Вторая проблема вызывает затруднения, потому что Rust обеспечивает принцип единоличного владения. Если вы объедините и актор, и handle в одну структуру, вы (по крайней мере, с точки зрения компилятора) предоставляете каждому handle доступ к полям, принадлежащим задаче актора. Например, целое число next_id должно принадлежать только задаче актора и не должно быть доступно напрямую ни из одного из handle'ов.
Тем не менее, существует рабочая версия. Исправив две вышеуказанные проблемы, вы получите следующее:
#![allow(unused)] fn main() { impl MyActor { async fn run(&mut self) { while let Some(msg) = self.receiver.recv().await { self.handle_message(msg); } } } impl MyActorHandle { pub fn new() -> Self { let (sender, receiver) = mpsc::channel(8); let actor = MyActor::new(receiver); tokio::spawn(async move { actor.run().await }); Self { sender } } } }
Это работает идентично функции верхнего уровня. Обратите внимание, что, строго говоря, можно написать версию, где tokio::spawn находится внутри run, но я не рекомендую этот подход.
Вариации на тему
Актор, который я использовал в качестве примера в этой статье, использует парадигму запрос-ответ для сообщений, но вы не обязаны делать это так. В этом разделе я дам некоторые идеи о том, как вы можете изменить эту концепцию.
Без ответов на сообщения
В примере, который я использовал для введения концепции, включался ответ на сообщения, отправленные через канал oneshot, но вам не всегда нужен ответ вообще. В этих случаях нет ничего плохого в том, чтобы просто не включать канал oneshot в перечисление сообщений. Когда в канале есть место, это даже позволит вам вернуться из отправки до того, как сообщение будет обработано.
Вы все равно должны убедиться, что используете ограниченный канал (bounded channel), чтобы количество сообщений, ожидающих в канале, не росло без ограничений. В некоторых случаях это будет означать, что отправка по-прежнему должна быть асинхронной функцией, чтобы обрабатывать случаи, когда операции отправки необходимо ждать больше места в канале.
Однако есть альтернатива созданию асинхронного метода отправки. Вы можете использовать метод try_send и обрабатывать ошибки отправки, просто убивая актор. Это может быть полезно в случаях, когда актор управляет TcpStream, пересылая любые отправленные вами сообщения в подключение. В этом случае, если запись в TcpStream не успевает, вы можете просто закрыть соединение.
Несколько handle-структур для одного актора
Если актору нужно получать сообщения из разных мест, вы можете использовать несколько handle-структур, чтобы обеспечить отправку определенных сообщений только из определенных мест.
При этом вы все равно можете повторно использовать тот же канал mpsc внутри, с перечислением, которое содержит все возможные типы сообщений. Если вы хотите использовать для этой цели отдельные каналы, актор может использовать tokio::select! для получения из нескольких каналов одновременно.
#![allow(unused)] fn main() { loop { tokio::select! { Some(msg) = chan1.recv() => { // обработать msg }, Some(msg) = chan2.recv() => { // обработать msg }, else => break, } } }
Вам нужно быть осторожным с обработкой случаев, когда каналы закрыты, так как их метод recv в этом случае немедленно возвращает None. К счастью, макрос tokio::select! позволяет вам обработать этот случай, предоставляя шаблон Some(msg). Если закрыт только один канал, эта ветвь отключается, и прием из другого канала продолжается. Когда оба закрыты, выполняется ветвь else, и используется break для выхода из цикла.
Акторы, отправляющие сообщения другим акторам
Нет ничего плохого в том, что акторы отправляют сообщения другим акторам. Для этого вы можете просто дать одному актору handle какого-то другого актора.
Вам нужно быть немного осторожным, если ваши акторы образуют цикл, потому что, удерживая handle-структуры друг друга, последний отправитель никогда не удаляется, предотвращая завершение работы. Чтобы обработать этот случай, вы можете сделать так, чтобы один из акторов имел две handle-структуры с отдельными каналами mpsc, но с tokio::select!, который выглядит так:
#![allow(unused)] fn main() { loop { tokio::select! { opt_msg = chan1.recv() => { let msg = match opt_msg { Some(msg) => msg, None => break, }; // обработать msg }, Some(msg) = chan2.recv() => { // обработать msg }, } } }
Вышеуказанный цикл всегда завершится, если chan1 закрыт, даже если chan2 все еще открыт. Если chan2 — это канал, который является частью цикла акторов, это разрывает цикл и позволяет акторам завершить работу.
Альтернатива — просто вызвать abort для одного из акторов в цикле.
Несколько акторов, использующих один handle
Так же, как вы можете иметь несколько handle'ов на актор, вы также можете иметь несколько акторов на handle. Наиболее распространенный пример этого — при обработке подключения, такого как TcpStream, где вы обычно порождаете две задачи: одну для чтения и одну для записи. При использовании этого шаблона вы делаете задачи чтения и записи как можно более простыми — их единственная задача — выполнять ввод-вывод. Задача чтения будет просто отправлять любые полученные сообщения в какую-то другую задачу, обычно другому актору, а задача записи будет просто пересылать любые полученные сообщения в подключение.
Этот шаблон очень полезен, потому что он изолирует сложность, связанную с выполнением ввода-вывода, а это означает, что остальная часть программы может притворяться, что запись чего-либо в подключение происходит мгновенно, хотя фактическая запись происходит некоторое время спустя, когда актор обрабатывает сообщение.
Остерегайтесь циклов
Я уже немного говорил о циклах в разделе «Акторы, отправляющие сообщения другим акторам», где обсуждал завершение работы акторов, которые образуют цикл. Однако завершение работы — не единственная проблема, которую могут вызвать циклы, потому что цикл также может привести к взаимоблокировке (deadlock), когда каждый актор в цикле ждет, пока следующий актор получит сообщение, но этот следующий актор не получит это сообщение, пока его следующий актор не получит сообщение, и так далее.
Чтобы избежать такой взаимоблокировки, вы должны убедиться, что нет циклов каналов с ограниченной емкостью. Причина этого в том, что метод send для ограниченного канала не возвращается немедленно. Каналы, чей метод send всегда возвращается немедленно, не учитываются в такого рода цикле, поскольку вы не можете заблокироваться на такой отправке.
Обратите внимание, что это означает, что канал oneshot не может быть частью взаимоблокированного цикла, поскольку его метод send всегда возвращается немедленно. Также обратите внимание, что если вы используете try_send вместо send для отправки сообщения, это также не может быть частью взаимоблокированного цикла.
Спасибо matklad за указание на проблемы с циклами и взаимоблокировками.
Использование асинхронного рантайма Tokio в Rust для CPU-интенсивных задач

Jan 14th, 2022 Источник Автор: Andrew Lamb
Несмотря на термин "async" и его ассоциацию с асинхронным сетевым вводом-выводом, эта статья доказывает, что рантайм Tokio, находящийся в центре асинхронной экосистемы Rust, также является хорошим выбором для CPU-интенсивных задач, таких как те, что встречаются в аналитических движках.
Что такое Tokio?
Rust имеет встроенную поддержку асинхронной (async) модели программирования, подобно таким языкам, как JavaScript.
Чтобы в полной мере использовать преимущества многоядерности и асинхронного ввода-вывода, необходимо использовать рантайм, и хотя сообщество Rust предлагает несколько альтернатив, Tokio является де-факто стандартом. Tokio.rs описывает его как: «асинхронный рантайм для языка программирования Rust. Он предоставляет строительные блоки, необходимые для написания сетевых приложений».
Хотя это описание подчеркивает использование Tokio для сетевых коммуникаций, рантайм можно использовать и для других целей, как мы исследуем ниже.
Почему использовать Tokio для CPU-задач?
Оказывается, современным аналитическим движкам неизбежно необходимо обрабатывать клиентские запросы из сети, а также использовать сеть для взаимодействия с системами объектного хранилища, такими как AWS S3, GCP Cloud Storage и Azure Blob Storage.
Таким образом, любая такая система, реализованная на Rust, в конечном итоге будет использовать Tokio для своих сетевых и, по крайней мере, части задач ввода-вывода хранилища (да, я знаю, изначально асинхронный файловый ввод-вывод Tokio не совсем асинхронный, но это скоро исправят).
Аналитические системы также выполняют CPU-интенсивные вычисления, которые я определяю как обработку данных способом, потребляющим большое количество CPU для реорганизации хранилища, предварительного вычисления различных индексов или непосредственного ответа на клиентские запросы. Эти вычисления обычно разбиваются на множество независимых фрагментов, которые я буду называть «задачами», а затем запускаются параллельно, чтобы использовать преимущества множества ядер, доступных в современных процессорах.
Определение того, какие задачи и когда запускать, обычно выполняется так называемым «планировщиком задач» (task scheduler), который сопоставляет задачи с доступными ядрами / потоками операционной системы.
Существуют годы академических и промышленных работ по различным типам планировщиков задач, пулов рабочих, пулов потоков и тому подобного.
Мой опыт работы с несколькими пользовательскими планировщиками задач (и, к своему стыду, их реализации) показывает, что их легко заставить работать изначально (скажем, в 99,9% случаев), но затем требуется много (много!) времени, чтобы разобраться с corner cases (быстрое завершение, отмена задач, осушение и т.д.). Их также печально известно сложно тестировать из-за использования низкоуровневых примитивов потоков, и здесь изобилуют состояния гонки. Я бы не рекомендовал этого делать.
Таким образом, при поиске планировщика задач в экосистеме Rust, как мы делали для InfluxDB IOx и DataFusion, вы естественным образом приходите к Tokio, и он выглядит довольно хорошо:
- У вас уже есть Tokio (без новых зависимостей).
- Tokio реализует сложный планировщик с кражевой работой (work-stealing scheduler).
- Tokio фактически имеет встроенную языковую поддержку продолжений (async / await) и множество относительно зрелых библиотек для потоков, асинхронных блокировок, каналов, отмены и т.д.
- Tokio известен как хорошо протестированный и широко используемый в экосистеме Rust.
- Tokio обычно сохраняет выполняемые задачи и будущие результаты (futures), которые они выполняют, в одном потоке исполнителя, что отлично для локальности кэша.
- Tokio хорошо документирован, активно поддерживается и постоянно улучшается. (Консоль Tokio была анонсирована, пока я писал этот блог).
Таким образом, использовать Tokio в качестве планировщика задач для CPU-интенсивных задач — это же очевидно, верно? НЕЕЕЕЕЕЕЕЕЕЕЕЕЕТ!
Распространенные возражения против использования Tokio
Оказывается, использование Tokio было довольно горячей темой, и я бы сказал, что еще не все на 100% убеждены, отсюда и эта статья. Мы много беспокоились об этом вопросе на ранних стадиях DataFusion и InfluxDB IOx. Вот некоторые распространенные возражения:
В документации Tokio сказано не делать этого:
Более старые версии документации Tokio (например, 1.10.0) включали (на мой взгляд) известное предостережение:
«Если ваш код является CPU-интенсивным и вы хотите ограничить количество потоков, используемых для его запуска, вы должны запускать его в другом пуле потоков, таком как Rayon».
Я считаю, что эта формулировка вызвала значительную путаницу как в нашей команде, так и в широком сообществе Rust. Многие люди поняли это так, что рантайм Tokio никогда не следует использовать для CPU-интенсивных задач. Ключевой момент на самом деле заключается в том, что один и тот же экземпляр Runtime (один и тот же пул потоков) не должен использоваться как для I/O, так и для CPU, и впоследствии мы уточнили intent документации (подробности в PR).
Кстати, в документации Tokio предлагается использовать Rayon для CPU-интенсивных задач. Rayon — отличный выбор для многих приложений, но он не поддерживает async, поэтому, если ваш код должен выполнять любой ввод-вывод, вам придется переступать через болезненную границу sync/async. Мне также было сложно сопоставить pull-based модель выполнения, где задача должна ждать, пока все входные данные будут готовы, прежде чем она сможет запуститься, с Rayon.
Хвостовые задержки будут преследовать вас
Мудрые люди говорят: «Использование Tokio для CPU-интенсивной работы увеличит хвостовые задержки ваших запросов, что неприемлемо». Но подождите! Вы можете сказать: «Хвостовые задержки? 🙄 Я пишу базу данных, и это звучит как академическая проблема для веб-серверов, находящихся под высокой нагрузкой…»
Не совсем: Рассмотрим проверку работоспособности (liveness check), де-факто обязательную в наши дни для систем, развернутых с помощью оркестровки контейнеров (кхм Kubernetes). Проверка того, что ваш процесс ведет себя хорошо, часто представляет собой HTTP-запрос к чему-то вроде /health. Если этот запрос sits в очереди задач где-то, потому что Tokio полностью использует ваш CPU для эффективного пережевывания массы задач обработки данных, Kubernetes не получает требуемого ответа «Все в порядке» и убивает ваш процесс.
Эта цепочка рассуждений приводит к классическому выводу, что, поскольку хвостовые задержки критичны, вы не можете использовать Tokio для CPU-интенсивных задач.
Однако, как советует документация Tokio, чтобы избежать уничтожения Kubernetes и друзьями при полной загрузке CPU, важно использовать отдельный пул потоков — один для задач, где «задержка важна», таких как ответ на /health, и один для CPU-интенсивных задач. Оптимальное количество потоков для этих пулов потоков зависит от ваших потребностей и является хорошей темой для отдельной статьи.
Возможно, если думать о Tokio Runtime как о сложном пуле потоков, идея использования разных экземпляров Runtime может показаться более приемлемой, и мы покажем, как это сделать, с помощью выделенного исполнителя ниже.
Высокие накладные расходы на задачу
Но «подождите!» — слышу я вас (или все слышат вас на Hacker News), — у Tokio высокие накладные расходы на задачу. Я совсем не удивлен, что люди могут создавать пулы потоков, которые прогоняют крошечные задачи быстрее, чем Tokio.
Однако я еще не видел такой системы, которой я бы доверял для своих рабочих нагрузок в production, ни одной, которая имела бы такую же надежную поддержку экосистемы.
К счастью, для многих рабочих нагрузок накладные расходы на задачу могут быть амортизированы с помощью «векторизованной обработки». Это модный способ сказать, что каждая задача обрабатывает тысячи строк за раз, а не одну. Конечно, нельзя сходить с ума; вам нужно разбивать работу на фрагменты разумного размера, и нельзя амортизировать все рабочие нагрузки. Однако для всех случаев, которые важны для моих приложений, накладные расходы на задачу Tokio теряются в шуме.
Как использовать Tokio для CPU-интенсивных задач?
Итак, давайте представим, что я убедил вас, что использовать Tokio для CPU-интенсивной работы — это нормально. Как это сделать?
Во-первых, что критически важно, ваш код должен следовать поговорке: «асинхронный код никогда не должен проводить много времени, не достигая .await», как объяснено в посте Alice Ryhl. Это нужно, чтобы дать планировщику шанс запланировать что-то еще, украсть работу и т.д.
Конечно, «много времени» зависит от вашего приложения; Ryhl рекомендует 10–100 микросекунд при оптимизации для хвостовых задержек ответа. Я думаю, что 10–100 миллисекунд также нормально для задач при оптимизации для CPU. Однако, поскольку мои расчетные накладные расходы Tokio на задачу составляют около ~10 наносекунд, почти невозможно даже измерить накладные расходы Tokio Runtime для задач в 10 миллисекунд.
Во-вторых, запускайте свои задачи в отдельном экземпляре Runtime. Как это сделать? Рад, что вы спросили.
Выделенный исполнитель (Dedicated Executor)
Вот упрощенная версия того, как мы запускаем задачи в отдельном Tokio Runtime в InfluxDB IOx. (Полную версию можно найти в нашем репозитории, и в ней есть дополнительная логика для чистого завершения и соединения.)
#![allow(unused)] fn main() { pub struct DedicatedExecutor { state: Arc<Mutex<State>>, } /// Запускает future (и любые `tasks`, которые `tokio::task::spawned` ими) /// на отдельном исполнителе Tokio struct State { /// Канал для запросов -- выделенный исполнитель принимает запросы /// отсюда и запускает их. requests: Option<std::sync::mpsc::Sender<Task>>, /// Поток, в котором установлен другой Tokio runtime /// и порождает задачи там thread: Option<std::thread::JoinHandle<()>>, } impl DedicatedExecutor { /// Создает новый `DedicatedExecutor` с выделенным Tokio /// исполнителем, который отделен от пула потоков, созданного через /// `[tokio::main]`. pub fn new(thread_name: &str, num_threads: usize) -> Self { let thread_name = thread_name.to_string(); let (tx, rx) = std::sync::mpsc::channel::<Task>(); let thread = std::thread::spawn(move || { // Создать новый Runtime для запуска задач let runtime = Tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_name(&thread_name) .worker_threads(num_threads) // Понизить приоритет рабочих потоков ОС для приоритизации основного рантайма .on_thread_start(move || set_current_thread_priority_low()) .build() .expect("Creating Tokio runtime"); // Брать запросы задач из канала и отправлять их исполнителю runtime.block_on(async move { while let Ok(task) = rx.recv() { Tokio::task::spawn(async move { task.run().await; }); } }); }); let state = State { requests: Some(tx), thread: Some(thread), }; Self { state: Arc::new(Mutex::new(state)), } } // ... } }
Этот код создает новый std::thread, который создает отдельный многопоточный Tokio Runtime для запуска задач, а затем читает задачи из канала и порождает их в новом Runtime.
Примечание: Новый поток — это ключ. Если вы попытаетесь создать новый Runtime в основном потоке или в одном из потоков, созданных Tokio, вы получите ошибку, так как Runtime уже установлен.
Вот соответствующий код для отправки задачи во второй Runtime.
#![allow(unused)] fn main() { impl DedicatedExecutor { /// Запускает указанный Future (и любые задачи, которые он порождает) на /// `DedicatedExecutor`. pub fn spawn<T>(&self, task: T) -> Job<T::Output> where T: Future + Send + 'static, T::Output: Send + 'static, { let (tx, rx) = tokio::sync::oneshot::channel(); let fut = Box::pin(async move { let task_output = task.await; tx.send(task_output).ok() }); let mut state = self.state.lock(); let task = Task { fut, }; if let Some(requests) = &mut state.requests { // завершится ошибкой, если кто-то начал завершение работы requests.send(task).ok(); } else { warn!("tried to schedule task on an executor that was shutdown"); } Job { rx } } } }
Job
Код выше использует обертку вокруг Future под названием Job, которая обрабатывает передачу результатов из выделенного исполнителя обратно в основной исполнитель, что выглядит так:
#![allow(unused)] fn main() { #[pin_project(PinnedDrop)] pub struct Job<T> { #[pin] rx: Receiver<T>, } impl<T> Future for Job<T> { type Output = Result<T, Error>; fn poll( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Self::Output> { let this = self.project(); this.rx.poll(cx) } } }
И это все! Вы можете найти весь код в этом Github gist.
Разрушение мифов об IPv6: почему внедрение отстает и что это изменит

Jul 29th, 2025 11:00am by Alessandro Improta and Denton Chikura Источник
IPv6 был разработан в конце 1990-х годов как преемник IPv4 для решения проблемы быстрого роста интернета и предотвращения истощения адресов IPv4. Первоначальный замысел заключался в том, что после периода совместной работы (dual-stack) IPv4 будет постепенно выведен из эксплуатации. Спустя более 25 лет полное истощение адресов IPv4 уже на горизонте, однако внедрение IPv6 остается медленным — в настоящее время всего около 30% в мировом масштабе, причем такая же доля среди топ-1000 веб-сайтов Alexa доступна через IPv6. Сроки полного перехода остаются неопределенными.
Понимание IP-адресов: почтовая система интернета
Прежде чем погружаться в сложности внедрения IPv6, важно понять, что на самом деле делают эти протоколы. Представьте, что IP-адреса — это эквивалент почтовых адресов в интернете — они указывают пакетам данных, куда направляться в обширной сети взаимосвязанных компьютеров, составляющих интернет.

Сравнение масштаба адресного пространства IPv4 и IPv6
Разница в масштабе ошеломляющая. Если бы адреса IPv4 были grains of sand (песчинками), то адреса IPv6 были бы like having entire beaches for every grain of sand on Earth (как если бы у вас были целые пляжи на каждую песчинку на Земле). Это massive expansion (огромное расширение) гарантирует, что у нас никогда не закончатся интернет-адреса, независимо от того, сколько устройств мы подключаем.

Ключевые различия между протоколами IPv4 и IPv6
Ложная безопасность преобразования сетевых адресов (NAT)
Широкое распространение технологии преобразования сетевых адресов (NAT) задержало развертывание IPv6. NAT — это метод, используемый в сетях IPv4, позволяющий нескольким устройствам в локальной сети использовать один общедоступный IP-адрес при доступе в интернет. Он изменяет информацию об IP-адресе в заголовках пакетов при их прохождении через маршрутизатор или межсетевой экран, позволяя частным IP-адресам (например, 192.168.x.x) взаимодействовать с внешними сетями. Хотя NAT помог отсрочить истощение IPv4, он вносит сложность и нарушает модель сквозной связи (end-to-end connectivity) в Интернете.
NAT также создает у организаций ложное чувство безопасности, порождая убеждение, что IPv4 можно расширять бесконечно. Многие сетевые специалисты остаются comfortable and confident (уверенными и чувствующими себя комфортно) с IPv4, задаваясь вопросом о необходимости нового протокола, когда их текущие системы кажутся достаточными. Создание частных NAT с адресами IPv4 часто provides a temporary fix (предоставляет временное решение), решая проблемы сейчас, но упуская из виду более широкие отраслевые и технические сдвиги.
Проблема в том, что технологические специалисты часто настолько сосредотачиваются на своей непосредственной пользовательской базе, что упускают общую картину. Если вы можете создать частный NAT, используя адреса IPv4, ваши непосредственные проблемы кажутся решенными. Однако этот подход игнорирует фундаментальные сдвиги, происходящие в интернет-инфраструктуре.
Что происходит за кулисами
Крупные поставщики контента, включая Google и Facebook, удаляют IPv4 из своих центров обработки данных и отодвигая его на границы сети, вынуждая выполнять преобразования для трафика, не использующего IPv6. Когда операторы направляют трафик через IPv4 и carrier-grade NAT (трансляцию адресов на уровне оператора), система пропускает все через единую точку преобразования, вводя несколько уровней трансляции и неэффективности.
Это создает особенно неэффективный сценарий: если вы интернет-провайдер, направляющий весь свой трафик через IPv4 посредством carrier-grade NAT, вы, по сути, подключаетесь через этот маленький translation machine (механизм преобразования) на границе сети для доступа к контенту. Этот подход требует как минимум двойного преобразования и значительно увеличивает затраты.
Принятие IPv6 позволяет осуществлять прямые, «зеленые» (green path) соединения от пользователей к контенту, минуя дорогостоящие уровни преобразования. В то время как некоторые dismiss IPv6 as experimental (отмахиваются от IPv6 как от экспериментального), на самом деле ведущие компании уже развернули его в больших масштабах, и внедрение ускоряется в силу необходимости.
Проблема развертывания
Организации сталкиваются с разными сроками развертывания в зависимости от сложности сети. Небольшие, несложные сети могут перейти на IPv6 в течение нескольких месяцев. Крупные телекоммуникационные среды могут потребовать год или два, даже в лучших обстоятельствах. Проблема становится все более актуальной, поскольку адресное пространство IPv4 в региональных реестрах, таких как ARIN (Американский реестр интернет-номеров) и RIPE (Европейский реестр IP-сетей), истощается, оказывая растущее давление на операторов.
Ключевые проблемы развертывания
- Совместимость с устаревшими системами: Многие старые системы и устройства не поддерживают IPv6.
- Требования к обучению персонала: Сетевым командам необходимо образование по управлению IPv6.
- Сложность dual-stack: Управление обоими протоколами одновременно увеличивает операционные накладные расходы.
- Пробелы в поддержке поставщиков: Не все производители оборудования полностью поддерживают IPv6.
- Вопросы стоимости: Обновление инфраструктуры может быть дорогостоящим.
Дилема Dual Stack
Решение переходить на dual stack зависит от типа сети и нормативной среды. Для фиксированных сетей dual stack часто практичен, хотя местные законы — особенно касающиеся legal interception (законного перехвата) — могут влиять на решение. В мобильных сетях dual stack offers little benefit (предлагает мало преимуществ), поскольку такие решения, как 464XLAT, уже хорошо работают.
Понимание ограничений Dual Stack
Запуск dual stack не решает проблему ограниченного пространства IPv4. Это лишь делает истощение IPv4 более управляемым. Развертывание dual stack означает, что примерно половина сетевого трафика может перейти на IPv6, уменьшая перегрузку через carrier-grade NAT, но организациям по-прежнему необходимо эксплуатировать и поддерживать инфраструктуру NAT.
Однако dual stack действительно provides an exit strategy (предоставляет стратегию выхода). Со временем, по мере миграции большего числа поставщиков контента на IPv6, трафик через устройства преобразования будет сокращаться, в конечном итоге уменьшая или устраняя необходимость в крупных развертываниях NAT.
Разрушение распространенных мифов об IPv6
Несколько устойчивых мифов и заблуждений о IPv6 продолжают циркулировать:
Проблемы структуры адреса
Некоторые критики утверждают, что разделение на сетевую и host part (хост-часть) адреса «тратит впустую» 64 бита. Хотя это можно было бы структурировать иначе, IPv6 по-прежнему представляет собой vast improvement (огромное улучшение) по сравнению с 32-битной адресацией IPv4. Дизайн был создан почти три десятилетия назад, и 64 бита для идентификации хоста far superior to IPv4's limitations (намного превосходят ограничения IPv4).
Ошибочные представления о безопасности
IPv6 иногда неверно представляют как более или менее безопасный, чем IPv4. Функции безопасности fundamentally similar (фундаментально схожи). Хотя IPv6 поддерживает встроенный IPsec в транспортном режиме, его развертывание встречается редко из-за отсутствия удобных инструментов управления ключами. Реальная проблема заключается не в самой технологии, а в отсутствии простых инструментов для распределения и управления ключами.
Сложность мультихоминга (Multihoming)
Практический мультихоминг с IPv6 остается концептуально идентичным IPv4: требуется автономная система (AS) и протокол BGP. Минимальный рекламируемый сетевой блок — /48, обычно назначаемый на сайт. Организации могут получать более крупные выделения, такие как /29, в качестве локальных интернет-реестров, в зависимости от их регионального реестра.
Лучшие практики для адресации IPv6
Вам не стоит беспокоиться о подсчете адресов или подсетях в IPv6. Вместо этого длина префикса сигнализирует об использовании: /64 для отдельных сетей, /48 для целых сайтов или щедрых домашних выделений и /56 для небольших развертываний. Назначения ниже /56 не рекомендуются.
Когда сетевые инженеры спрашивают, сколько устройств может поместиться в подсети /64, ответ прост: все. Это просто возможность адресации для одного интерфейса Уровня 3 в типичном развертывании.
Как сдвинуть с мертвой точки внедрение IPv6
IPv6 — это зрелая технология с почти четвертьвековым опытом реального развертывания. Если смотреть в будущее, дальнейшие изменения протокола маловероятны в нашей жизни. Пора принять IPv6 как новый фундамент и начать развертывание.
В то время как некоторые выступают за государственное вмешательство для ускорения внедрения IPv6, подход различается по регионам. Нормативные рамки, рыночные силы и местные приоритеты формируют темпы и методы внедрения по всему миру. Универсального решения не существует.
Рекомендации для различных заинтересованных сторон
Для сетевых операторов:
- Начните с обучения персонала по IPv6
- Проведите аудит существующей инфраструктуры на совместимость с IPv6
- Разработайте поэтапный план внедрения
- Рассмотрите финансовые преимущества снижения сложности NAT
Для бизнес-лидеров:
- Поймите, что внедрение IPv6 неизбежно, а не опционально
- Заложите в бюджет модернизацию инфраструктуры и обучение сотрудников
- Оценивайте поддержку IPv6 поставщиками при принятии решений о закупках
- Рассмотрите конкурентные преимущества раннего внедрения
Для государственных органов:
- Разработайте подходящие для региона политики, поощряющие внедрение
- Инвестируйте в программы обучения и повышения осведомленности об IPv6
- Подавайте пример в развертывании государственных сетей
- Поддерживайте усилия по международной координации
Путь вперед
Вы больше не можете избегать IPv6. Снижающаяся полезность NAT, истощение адресов IPv4 и миграция крупных поставщиков контента — это сходящиеся факторы. Задержка перехода на IPv6 будет только увеличивать сложность и стоимость, поскольку потребуется все больше обходных путей преобразования.
Технологии и инструменты для IPv6 готовы, и бизнес-обоснование яснее, чем когда-либо. Вопрос не в том, стоит ли развертывать IPv6, а в том, насколько быстро и эффективно ваша организация сможет это сделать. Те, кто действует сейчас, получат выгоду от прямых, эффективных подключений к все более нативному IPv6 интернету, в то время как те, кто медлит, окажутся управляющими все более сложными и дорогими системами преобразования.
Будущее интернета — за IPv6. Единственный оставшийся вопрос — будете ли вы частью решения или будете бороться, чтобы догнать отставших.
Услышьте экспертное мнение по этой теме в подробном интервью.
Древовидная структурированная конкурентность
2023-07-01 Yoshua Wuyts Источник
- Что такое структурированная конкурентность?
- Неструктурированная конкурентность: пример
- Структурированная конкурентность: пример
- Что может случиться в худшем случае?
- Применение структурированной конкурентности в ваших программах
- Паттерн: управляемые фоновые задачи
- Гарантирование структуры
- Заключение
Довольно долго я пытался найти хороший способ объяснить, что такое структурированная конкурентность и как она применяется в Rust. Я придумывал эффектные фразы вроде: «Структурированная конкурентность — это структурированное программирование, примененное к примитивам управления параллельным выполнением». Но это требует от меня начать объяснять, что такое структурное программирование, и внезапно я оказываюсь погруженным в концепцию на 2000 слов, которая кажется естественной большинству людей, пишущих программы сегодня1.
Вместо этого я хочу попробовать нечто иное. В этом посте я хочу дать вам практическое введение в структурированную конкурентность. Я сделаю все возможное, чтобы объяснить, что это такое, почему это важно и как вы можете начать применять это в своих проектах на Rust уже сегодня. Структурированная конкурентность — это призма, которую я использую почти во всех своих рассуждениях об асинхронном Rust, и я думаю, что она может помочь и другим. Так что давайте погрузимся.
Этот пост предполагает некоторое знакомство с асинхронным Rust и отменой асинхронных операций. Если вы еще не знакомы, возможно, будет полезно бегло просмотреть предыдущие посты на эту тему.
Что такое структурированная конкурентность?
Структурированная конкурентность — это свойство вашей программы. Это не просто любая структура; структура программы гарантированно представляет собой дерево, независимо от того, сколько конкурентности происходит внутри2. Хороший способ думать об этом: если бы вы могли построить живой граф вызовов вашей программы в виде ряда отношений, он бы аккуратно образовывал дерево. Без циклов3. Без висячих узлов. Просто одно дерево.
Рис. 1. Стрелки указывают от родительских узлов к дочерним. Нет циклов. Родитель может иметь несколько детей. Но у ребенка всегда один родитель — кроме корневого узла.
И эта структура, по крайней мере в асинхронном Rust, обеспечивает три ключевых свойства:
- Распространение отмены: Когда вы отбрасываете (drop) future для его отмены, гарантируется, что все future под ним также отменяются.
- Распространение ошибок: Когда ошибка возникает где-то внизу графа вызовов, ее всегда можно передать вверх вызывающим сторонам, пока не найдется вызывающий, готовый обработать ее.
- Порядок операций: Когда функция возвращает результат, вы знаете, что она закончила работу. Никаких сюрпризов, что что-то все еще происходит спустя долгое время после того, как вы подумали, что функция завершилась.
Эти свойства вместе приводят к так называемой «модели выполнения черного ящика»: при структурированной модели вычислений вам не нужно знать ничего о внутреннем устройстве функций, которые вы вызываете, потому что их поведение гарантировано. Функция вернет результат, когда закончит работу, отменит всю работу, когда вы попросите, и вы всегда получите ошибку, если есть что-то, что нужно обработать. И как следствие, код в этой модели является композируемым.
Рис. 2. При структурированной конкурентности у каждого future есть родитель, отмена распространяется вниз, а ошибки — вверх. Когда future возвращает результат, вы можете быть уверены, что он закончил работу.
Если ваша модель конкурентности неструктурированна, то у вас нет этих гарантий. Поэтому, чтобы гарантировать, скажем, правильное распространение отмены, вам нужно будет проверять внутреннее устройство каждой функции, которую вы вызываете. Код в этой модели не является композируемым и требует ручных проверок и специальных решений. Это трудоемко и подвержено ошибкам.
Неструктурированная конкурентность: пример
Давайте начнем с реализации классического паттерна конкурентности: «гонка» (race). Но вместо использования структурированных примитивов мы можем использовать основы неструктурированного программирования: почтенные task::spawn и канал (channel). «Гонка» работает так: она принимает два future, и мы пытаемся получить вывод того, который завершится первым. Мы могли бы написать это примерно так:
#![allow(unused)] fn main() { use async_std::{channel, task}; let (sender0, receiver) = channel::bounded(1); let sender1 = sender0.clone(); task::spawn(async move { // 👈 Задача "C" task::sleep(Duration::from_millis(100)); sender1.send("first").await; }); task::spawn(async move { // 👈 Задача "B" task::sleep(Duration::from_millis(100)); sender0.send("second").await; }); let msg = receiver.recv().await; // 👈 Future "A" println!("{msg}"); }
Хотя это правильно реализует семантику «гонки», это не обрабатывает отмену. Если одна из ветвей завершится, мы бы хотели отменить другую. И если содержащая функция отменена, обе вычисления должны быть отменены. Из-за того, как мы структурировали программу, ни одна задача не привязана к родительскому future, поэтому мы не можем отменить ни одно вычисление напрямую. Вместо этого решением было бы придумать какой-то дизайн, используя больше каналов, привязать handle'ы — или мы могли бы переписать это, используя структурированные примитивы.
Рис. 3. Вы можете создать операцию «гонки», комбинируя задачи и каналы. Данные могут выходить из задач к вызывающей стороне. Но поскольку задачи не привязаны к родительской задаче, отмена не распространяется.
Структурированная конкурентность: пример
Мы можем переписать пример выше, используя структурированные примитивы. Вместо того чтобы самостоятельно реализовывать «гонку» с помощью задач и каналов, мы должны использовать примитив «гонки», который реализует эту семантику за нас — и правильно обрабатывает отмену. Используя библиотеку futures-concurrency, мы можем сделать это следующим образом:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; use async_std::task; let c = async { // 👈 Future "C" task::sleep(Duration::from_millis(100)); "first" }; let b = async { // 👈 Future "B" task::sleep(Duration::from_millis(100)); "second" }; let msg = (c, b).race().await; // 👈 Future "A" println!("{msg}"); }
Когда один future завершается здесь, другой future отменяется. И если future Race будет отброшен, то оба future отменяются. Оба future имеют родительский future при выполнении. Отмена распространяется вниз. И хотя в этом примере нет ошибок, если бы мы работали с операциями, которые могут завершиться ошибкой, то ранние возвраты привели бы к раннему завершению future — и ошибки обрабатывались бы как ожидалось.
Рис. 4. Используя структурированный примитив «гонки», все дочерние future привязаны к родительскому future. Что позволяет распространять как отмену, так и ошибки. И операция не вернется, пока все дочерние future не будут отброшены.
До сих пор мы рассматривали только операцию «гонки», которая кодирует: «Ждать завершения первого future, затем отменить другой». Но существуют и другие асинхронные операции конкурентности, такие как:
- join: ждать завершения всех future.
- race_ok: ждать завершения первого future, который вернет
Ok. - try_join: ждать завершения всех future или вернуться раньше, если есть ошибка.
- merge: ждать завершения всех future и выдавать элементы из потока (stream), как только они будут готовы.
Есть еще несколько, таких как «zip», «unzip» и «chain», а также динамические примитивы конкурентности, такие как «группа задач» (task group), «группа задач с ошибками» (fallible task group) и другие. Суть в том, что набор примитивов конкурентности ограничен. Но их можно перекомбинировать таким образом, чтобы выразить любую форму конкурентности, какую вы захотите. Не unlike тому, как если язык программирования поддерживает ветвление, циклы и вызовы функций, вы можете закодировать почти любую логику управления потоком, — без необходимости использовать «goto».
Что может случиться в худшем случае?
Люди иногда спрашивают: Что самое худшее может случиться, когда у вас нет структурированной конкурентности? Возможен ряд плохих исходов, включая, но не ограничиваясь: потерю данных, повреждение данных и утечки памяти.
В то время как Rust защищает от состояний гонки данных (data races), которые подпадают под категорию «безопасность памяти» (memory safety), Rust не может защитить вас от логических ошибок (logic bugs). Например: если вы выполняете операцию записи внутри задачи, handle которой не присоединен (joined), то вам нужно найти какой-то альтернативный механизм, чтобы гарантировать порядок этой операции по отношению к остальной части программы. Если вы ошиблись, вы можете случайно записать в закрытый ресурс и потерять данные. Или выполнить запись не в том порядке и случайно повредить ресурс4. Эти виды ошибок не относятся к тому же классу, что и ошибки безопасности памяти. Но, тем не менее, они серьезны, и их можно смягчить с помощью принципиального проектирования API.
Применение структурированной конкурентности в ваших программах
task::spawn
При использовании или создании асинхронных API в Rust вы должны задать себе следующие вопросы, чтобы обеспечить структурированную конкурентность:
- Распространение отмены: Если этот future или функция будут отброшены, будет ли отмена распространена на все дочерние future?
- Распространение ошибок: Если ошибка произойдет где-либо в этом future, можем ли мы либо обработать ее напрямую, либо передать ее вызывающей стороне?
- Порядок операций: Когда эта функция вернет результат, не будет ли больше работы, продолжающейся в фоне?
Если все эти свойства истинны, то после выхода из функция завершила выполнение, и все в порядке. Однако это подводит нас к серьезной проблеме в современной асинхронной экосистеме: ни async-std, ни tokio не предоставляют функцию spawn, которая была бы структурированной. Если вы отбросите handle задачи, задача не отменяется, а вместо этого отсоединяется (detached) и продолжает работать в фоне. Это означает, что отмена не распространяется автоматически через границы задач, что делает ее неструктурированной.
Библиотека smol приближается к этому. В ней есть реализация задачи, которая приближает нас к семантике «отмена при отбрасывании» (cancel on drop) из коробки. Хотя она еще не доводит нас до конца, потому что не гарантирует порядок операций. Когда задача smol Task отбрасывается, не гарантируется, что задача была отменена; гарантируется только, что задача будет отменена в какой-то момент в будущем.
async drop
Что подводит нас к самому большому недостающему элементу в истории структурированной конкурентности асинхронного Rust: отсутствию async Drop в языке. Задачи smol имеют асинхронный метод cancel, который завершается только тогда, когда задача была успешно отменена. В идеале мы могли бы вызывать этот метод в деструкторе и ждать его. Но чтобы сделать это сегодня, нам нужно было бы блокировать поток, а это может привести к проблемам с пропускной способностью. Нет, на практике для хорошей работы нам действительно нужны асинхронные деструкторы5.
Что можно сделать сегодня?
Но хотя мы еще не можем тривиально выполнить все требования для асинхронной структурированной конкурентности для асинхронных задач, не все потеряно. Без async Drop мы уже можем достичь 2/3 требований для порождения задач сегодня. И если вы используете среду выполнения (runtime), отличную от smol, адаптировать функцию spawn для работы как в smol — не слишком сложная работа. Но большей части конкурентности не нужны задачи, потому что она не является динамической. Для этого вы можете взглянуть на библиотеку futures-concurrency, которая реализует композируемые примитивы для структурированной конкурентности.
Если вы хотите внедрить структурированную конкурентность в своей кодовой базе сегодня, вы можете начать с ее принятия для не основанной на задачах конкурентности. А для конкурентности на основе задач вы можете принять модель порождения задач smol, чтобы получить большую часть преимуществ структурированной конкурентности уже сегодня. И в конечном итоге, мы надеемся, мы сможем добавить какую-либо форму async Drop в язык, чтобы закрыть оставшиеся пробелы.
Паттерн: управляемые фоновые задачи
Люди часто спрашивают, как они могут реализовать «фоновые задачи» при структурированной конкурентности. Это используется в сценариях, таких как обработчик HTTP-запросов, который также хочет отправить часть телеметрии. Вместо того чтобы блокировать отправку ответа на телеметрии, он порождает «фоновую задачу» для отправки телеметрии в фоне и немедленно возвращает результат из запроса. Это может выглядеть примерно так:
#![allow(unused)] fn main() { let mut app = tide::new(); app.at("/").post(|_| async move { task::spawn(async { // 👈 Порождение фоновой задачи… let _res = send_telemetry(data, more_data).await; // … что, если `res` — это `Err`? Как нам следует обрабатывать ошибки здесь? }); Ok("hello world") // 👈 …и немедленный возврат после. }); app.listen("127.0.0.1:8080").await?; }
Фраза «фоновая задача» кажется вежливой и ненавязчивой. Но с структурированной точки зрения она представляет собой вычисление без родителя — это висячая задача (dangling task). Суть паттерна, с которым мы имеем дело, заключается в том, что мы хотим создать вычисление, которое переживает время жизни обработчика запроса. Мы можем решить это, вместо создания висячей задачи, отправив ее в очередь задач или группу задач, которая переживает обработчик запроса. В отличие от висячей задачи, очередь задач или группа задач сохраняет структурированную конкурентность. Там, где висячая задача не имеет родительского future и становится недостижимой, используя очередь задач, мы передаем владение future другому объекту, который переживает текущую, более кратковременную область видимости.
Я слышал, как люди ранее утверждали, что task::spawn совершенно структурирован, если думать о нем как о порождении в какой-то недостижимый, глобальный пул задач. Но вопрос должен быть не в том, порождаются ли задачи в пуле задач, а в том, каково отношение этих задач к остальной части программы. Потому что мы не можем отменить и воссоздать недостижимый пул задач. Мы также не можем получать ошибки из этого пула или ждать завершения всех задач в нем. Это не обеспечивает свойства, которые мы хотим от структурированной конкурентности, — поэтому мы не должны считать это структурированным.
Я не чувствую, что в экосистеме есть какие-то великолепные решения для этого yet — отчасти ограниченные тем, что нам нужны «задачи с областью видимости» (scoped tasks), которые basically требуют линейных деструкторов для функционирования. Но существуют другие эксперименты, поэтому мы можем использовать их плюс каналы, чтобы собрать что-то, что даст нам то, что мы хотим:
⚠️ Примечание: Этот код не считается «хорошим» автором и используется лишь как пример, чтобы показать, что это возможно написать сегодня. Необходима дополнительная работа по проектированию, чтобы сделать это эргономичным. ⚠️
#![allow(unused)] fn main() { // Создаем канал для отправки и получения future. let (sender, receiver) = async_channel::unbounded(); // Создаем структурированную группу задач на верхнем уровне, рядом с HTTP-сервером // // Если любая из порожденных задач вернет ошибку, все активные задачи отменяются, // и ошибка возвращается через handle. let telemetry_handle = async_task_group::group(|group| async move { while let Some(telemetry_future) = receiver.next().await { group.spawn(async move { telemetry_future.await?; // 👈 Передаем ошибки вверх Ok(()) }); } Ok(group) }); // Создаем состояние приложения для нашего HTTP-сервера #[derive(Clone)] struct State { sender: async_channel::Sender<impl Future<Result<_>>>, } // Создаем HTTP-сервер let mut app = tide::new(); app.at("/").post(|req: Request<State>| async move { state.sender.send(async { // 👈 Отправляет future в цикл обработчика… send_telemetry(data, more_data).await?; Ok(()) }).await; Ok("hello world") // 👈 …и немедленно возвращается после. }); // Одновременно выполняем и HTTP-сервер, и обработчик телеметрии, // и если один из них перестает работать, другой тоже останавливается. (app.listen("127.0.0.1:8080"), telemetry_handle).race().await?; }
Как я сказал: нам нужно сделать гораздо больше работы над API, чтобы сравниться с удобством простого запуска висячей задачи. Но то, чего нам не хватает в удобстве API, мы компенсируем семантикой. В отличие от нашего предыдущего примера, это будет правильно распространять отмену и ошибки, и каждым выполняющимся future владеет родительский future. Мы могли бы даже пойти дальше и реализовать такие вещи, как обработчики повторов (retry-handlers) с квотами ошибок поверх этого, чтобы создать более устойчивую систему. Но, надеюсь, этого уже достаточно, чтобы передать идею того, что мы могли бы делать с этим.
Гарантирование структуры
Я уже некоторое время задаю себе вопрос: «Возможно ли для Rust обеспечить соблюдение структурированной конкурентности на уровне языка и библиотек?» Я не верю, что это то, что мы можем гарантировать со стороны языка. Но это то, что мы можем гарантировать для библиотечного кода Rust и сделать так, чтобы большая часть асинхронного кода по умолчанию была структурированной.
Причина, по которой я не верю, что принципиально возможно гарантировать структуру на уровне языка, заключается в том, что в Rust можно выразить любую программу, включая неструктурированные программы. Future, каналы и задачи, существующие сегодня, — это все просто обычные библиотечные типы. Если бы мы захотели обеспечить структуру со стороны языка, нам нужно было бы найти способ запретить создание этих библиотек — и это кажется невозможным для языка общего назначения6.
Вместо этого мне кажется более практичным принять древовидную структурированную конкурентность как модель, которой мы следуем для асинхронного Rust. Не как гарантию безопасности памяти, а как дисциплину проектирования, которую мы применяем ко всему асинхронному Rust. API, которые неструктурированны, не должны добавляться в stdlib. И наши инструменты должны знать, что неструктурированный код может существовать, чтобы они могли помечать его, когда встречают.
Заключение
В этом посте я показал, что такое (древовидная) структурированная конкурентность, почему она важна для корректности и как вы можете применять ее в своих программах. Я надеюсь, что, определив структурированную конкурентность в терминах гарантий о распространении ошибок и отмены, мы сможем создать практическую модель для людей, чтобы рассуждать об асинхронном Rust.
Как недавно сообщила Google, асинхронный Rust — один из самых сложных аспектов Rust для изучения. Вероятно, отсутствие структуры в асинхронном коде Rust сегодня не помогло. В асинхронном коде сегодня ни отмена, ни ошибки не гарантированно распространяются. Это означает, что если вы хотите надежно комбинировать код, вам нужно знать о внутреннем устройстве кода, который вы используете. Приняв (древовидную) структурированную модель конкурентности, эти свойства могут быть гарантированы с самого начала, что, в свою очередь, сделает асинхронный Rust более легким для понимания и обучения. Потому что «Если это компилируется, это работает» должно относиться и к асинхронному Rust.
Спасибо Ирине Шестак за иллюстрации и вычитку этого поста.
Примечания
-
Если вам интересно структурированное программирование, я могу рекомендовать писания Дейкстры на эту тему. Большинство языков программирования, которые мы используем сегодня, структурированы, поэтому читать о времени, когда это было не так, действительно интересно. ↩
-
Когда я исследовал эту тему в прошлом году, я нашел статью, кажется, из 80-х, в которой использовалась фраза «tree-structured concurrency». Я не смог найти ее снова к моменту написания этого поста, но я помню, что твитил о ней, потому что раньше не видел этот термин и думал, что он очень полезен! ↩
-
Да, да, рекурсия может заставлять функции вызывать самих себя — или даже вызывать самих себя через прокси. Под «живым графом вызовов» я подразумеваю его так, как flame charts визуализируют вызовы функций. Рекурсия визуализируется путем наложения вызовов функций друг на друга. Та же идея применима здесь путем добавления новых асинхронных узлов как детей существующих узлов. Акцент здесь сделан именно на живом графе вызовов, а не на логическом. ↩
-
На предыдущей работе мы столкнулись именно с этим в клиенте базы данных: у нас были проблемы с правильным распространением отмены, что означало, что протокол соединения мог быть поврежден, потому что мы не сбрасывали (flush) сообщения, когда должны были. ↩
-
Асинхронная отмена — едва ли единственная мотивация для
async Drop. Это также мешает нам кодировать базовые вещи, такие как: «сбросить эту операцию при отбрасывании» (flush on drop) — что-то, что мы можем кодировать в неасинхронном Rust сегодня. ↩ -
Пример этого из структурированного программирования: Rust — структурированный язык. Ассемблер — не структурированный язык. Вы можете реализовать интерпретатор ассемблера полностью на безопасном Rust — meaning вы можете выразить неструктурированный код на структурированном языке. Я мог бы показать примеры этого, но, эх, я надеюсь, общая линия рассуждений здесь имеет смысл. ↩
Древовидная структурированная конкурентность II: Замена фоновых задач акторами
2025-07-02 Yoshua Wuyts Источник
- Структурированная конкурентность
- Проблема фоновых задач
- Паттерн актора
- Чем акторы отличаются от глобальных переменных?
- Заключение
- Приложение А: Шаблон паттерна актора
Структурированная конкурентность
(Древовидная) структурированная конкурентность — это здорово, потому что она значительно упрощает параллельные программы. Она значительно уменьшает, если не откровенно устраняет возможность логических состояний гонки из-за проблем конкурентности. И концептуально это не так сложно, поскольку мы можем закодировать структурированную конкурентность всего двумя правилами:
- Каждое дочернее вычисление (кроме корневого) должно иметь ровно одного логического родителя в любой момент времени во время выполнения.
- Каждое родительское вычисление может иметь несколько дочерних вычислений, выполняющихся конкурентно в любой момент.
Каждый ребенок всегда должен иметь ровно одного родителя. Но каждый родитель может иметь несколько детей. Эти два правила позволяют нам вывести третье правило, которое находится в основе системы:
- Дочерние вычисления никогда не переживут своих родителей.
Если мы применим эти правила, мы заметим, что граф вызовов программы естественным образом образует дерево. В этом нет ничего особенного, поскольку неконкурентные программы уже работают таким образом. Если это не интуитивно: поймите, что, например, flame graphs и flame charts — не что иное, как визуализация данных в форме дерева.
Проблема фоновых задач
Один из самых распространенных вопросов, которые задают люди, изучая структурированную конкурентность:
«Как мне запустить работу после возврата из функции?»
Это справедливый вопрос! Распространенный пример — при реализации HTTP-сервера: вы можете захотеть логировать метрики для каждого запроса, но вы не хотите задерживать отправку ответа до завершения логирования. В этот момент люди часто прибегают к неструктурированной конкурентности в виде фоновых задач, часто используя какую-то форму task::spawn:
#![allow(unused)] fn main() { /// Некий HTTP-обработчик async fn handler(req: Request, _context: ()) -> Result<Response> { // Получить тело из запроса let body = req.body().try_to_string()?; // Создать отсоединенную фоновую задачу, // которая выполняет некоторую работу в фоне task::spawn(async { background_work(body).await.unwrap(); }); // Сконструировать ответ и вернуть // его из обработчика Ok(Response::Ok()) } }
В этом примере task::spawn создает фоновую задачу, которой не владеет ни один родитель. Это нарушает второе правило структурированной конкурентности: каждое вычисление всегда должно иметь одного логического родителя. Мы можем видеть, что это проблема, если background_work завершится неудачно. Сейчас, если она ошибочна, у нас нет способа распространить ошибку или повторить операцию, так что все, что мы можем реалистично сделать, — это позволить ей упасть (crash).
В этом примере у нас также нет способа отменить работу, выполняемую в задаче, поскольку она не привязана ни к какому логическому родителю. Но несмотря на эти проблемы, сам вариант использования также очень разумен: нам не следует ждать завершения фоновой работы перед отправкой ответа. Итак: что нам делать?
Паттерн актора
Мы хотим, чтобы работа происходила в фоне, одновременно будучи привязанной к некоторому родительскому вычислению. Я считаю, что правильный способ решить это — использовать акторы: типы, которым мы можем отправлять данные и которые будут планировать работу для нас.
В таких фреймворках, как actix и choo, это делается с помощью сообщений, которые отправляются с использованием каналов или эмиттеров событий. Но в языках, таких как Swift, поддержка акторов встроена непосредственно в язык и relies on методы вместо сообщений.
Но для того, что мы пытаемся сделать здесь, нам на самом деле не нужны какие-либо навороченные библиотеки или фреймворки: мы можем легко построить своих собственных акторов, используя структуры, блоки impl и каналы. Вот базовый шаблон, который вы можете использовать для реализации своих собственных акторов. Без комментариев это всего около 15 строк:
#![allow(unused)] fn main() { // Используем библиотеки `async-channel` и `futures-concurrency` use async_channel::{self as channel, Sender, Receiver}; use futures_concurrency::prelude::*; /// Тип данных, которые мы будем отправлять нашему актору. /// Это просто простой псевдоним для типа `Body` /// нашего HTTP-фреймворка. type Message = Body; /// Наш пользовательский тип актора struct Actor(Receiver<Message>); /// Handle (дескриптор) к актору, который можно /// использовать для планирования сообщений. type Handle = Sender<Message>; impl Actor { /// Создать новый экземпляр `Actor` fn new(capacity: usize) -> (Self, Handle) { let (sender, receiver) = channel::bounded(capacity); (Self(receiver), sender) } /// Начать прослушивание входящих сообщений /// и обрабатывать их конкурентно async fn run(&mut self) -> Result<()> { // Используем `ConcurrentStream` из `futures-concurrency` // чтобы брать работу из канала и выполнять ее // конкурентно. Чтобы ограничить количество конкурентности, // вы можете использовать предоставленный комбинатор `.limit`. self.0.co().try_for_each(|msg| { // Работа планируется здесь background_work(body).await?; Ok(()) }).await?; Ok(()) } } }
Теперь, чтобы использовать это, нам нужно создать экземпляр, запустить его конкурентно с нашим HTTP-сервером и убедиться, что обработчики запросов на сервере имеют к нему доступ. Я больше всего знаком с HTTP-фреймворком Tide, поэтому я буду использовать его здесь, но ваш любимый фреймворк, вероятно, позволяет сделать что-то подобное. Вот способ связать все это вместе:
use futures_concurrency::prelude::*; #[async_main] async fn main() -> server::Result<()> { // Создать экземпляр нашего актора let (mut actor, handle) = Actor::new(8); // емкость 8 // Создать экземпляр нашего сервера // и передать ему handle актора let mut app = Server::with_context(handle); app.at("/submit").post(handler); // Запустить и актор, и сервер // и выполнить их конкурентно let a = app.listen("127.0.0.1:8080"); let b = actor.run(); (a, b).try_join().await?; Ok(()) }
И теперь, когда наш сервер имеет handler, мы можем изменить нашу функцию-обработчик запроса, чтобы планировать работу на акторе, а не в висячей задаче:
#![allow(unused)] fn main() { async fn handler(req: Request, handle: Handle) -> Result<Response> { let body = req.body().try_to_string()?; handle.send(body).await?; // ← Отправить работу актору Ok(Response::Ok()) } }
Хотя это все еще полагается на выполнение асинхронной операции, которая может потенциально завершиться неудачей, это все же значимо. Вместо того чтобы ждать завершения работы, теперь мы ждем только постановки работы в очередь. И если в системе не происходит чего-то ужасно неправильного, это должно происходить практически мгновенно. А если по какой-то причине это не так: именно поэтому мы всегда добавляем таймауты к нашим вычислениям, верно? …верно?
Чем акторы отличаются от глобальных переменных?
Этот раздел был добавлен 2025-06-02, после публикации поста.
Мне несколько раз задавали вопрос, в чем разница между паттерном актора, показанным в этом посте, и глобальным пулом задач, который управляет порожденными задачами. В конце концов: если взять программу как корень, оба варианта в конечном итоге выглядят как дерево.
Разница между ними заключается в том, что при структурированной конкурентности мы рассматриваем как корень не программу, а функцию main. Паттерн актора в этом посте превращает недостижимый глобальный пул задач в достижимый пул задач с локальностью, которой можно управлять как угодно. Сай Бранд (Sy Brand) особенно хорошо это выразил; слегка перефразируя:
[…] это «о, это структурировано, пока вы смотрите на это таким образом» пахнет как «ну, моя программа не течет память, так как она вся будет возвращена, когда она завершится».
Смысл структурированной конкурентности в том, что всем всегда можно управлять и все достижимо за счет того, что все вычисления существуют в одном дереве. Это делает возможным всегда обрабатывать ошибки, перезапускать экземпляры и изящно завершать работу.
Заключение
В этом посте мы ответили на один из самых распространенных вопросов, которые возникают у людей при первом знакомстве со структурированной конкурентностью: «Как мне планировать фоновую работу без доверия на висячие задачи?»
Самый простой ответ на этот вопрос — акторы: типы, которые предоставляют некоторый (разделяемый) handle, который можно использовать для получения работы. Мы показали, как определить актора примерно в 15 строках кода, как интегрировать его с HTTP-сервером и, наконец, использовать его для замены существующей фоновой задачи.
Я думаю о структурированной конкурентности так же, как о безопасности памяти: чем больше мы делаем ее по умолчанию, тем меньше проблем с конкурентностью у людей в целом. И это кажется важным, потому что параллельные, асинхронные и конкурентные вычисления склоняются чтобы быть одними из самых сложных для осмысления и отладки.
Приложение А: Шаблон паттерна актора
#![allow(unused)] fn main() { use async_channel::{self as channel, Sender, Receiver}; use futures_concurrency::prelude::*; type Message = (); type Handle = Sender<Message>; struct Actor(Receiver<Message>); impl Actor { fn new(capacity: usize) -> (Self, Handle) { let (sender, receiver) = channel::bounded(capacity); (Self(receiver), sender) } async fn run(&mut self) -> Result<()> { self.0.co().try_for_each(|msg| { todo!("запланировать работу здесь") }).await } } }
Зачем нужен асинхронный Rust
2022-09-26 Yoshua Wuyts Источник
- Иерархия языков
- Асинхронность под капотом
- Возможности асинхронного Rust
- Ad-hoc отмена
- Ad-hoc конкурентность
- Комбинирование отмены и конкурентности
- Производительность: рабочие нагрузки
- Производительность: оптимизации
- Экосистема
- Заключение
Многие проектные решения в системном дизайне связаны с осмыслением природы предметных областей, с которыми мы сталкиваемся. И только потом, после того как мы их поймем, кодирование этого понимания таким образом, чтобы машины могли его проверить.
Я часто замечаю, что асинхронный Rust неправильно понимают. Разговоры о «зачем нужна асинхронность» часто сосредотачиваются на производительности1 — теме, которая сильно зависит от рабочих нагрузок и приводит к тому, что люди полностью говорят мимо друг друга. Хотя производительность — не плохая причина для выбора асинхронного Rust, часто мы замечаем производительность только тогда, когда испытываем ее недостаток. Поэтому я хочу вместо этого сосредоточиться на том, какие возможности предоставляет асинхронный Rust, которых нет в неасинхронном Rust. Хотя мы тоже немного поговорим о производительности в конце этого поста.
Иерархия языков
Нередко можно услышать, как Rust и другие языки описывают как «N языков в одном пальто». В Rust у нас есть управляющие конструкции Rust, у нас есть метаязык decl-макросов, у нас есть система трейтов (которая тьюринг-полна), у нас есть язык аннотаций cfg — и список продолжается. Но если мы рассматриваем Rust таким, каким он предоставлен нам из коробки, как «базовый Rust», то есть некоторые очевидные модификаторы для него:
unsafeRust: для использования сырых указателей и FFIconstRust: для вычисления значений во время компиляцииasyncRust: для включения неблокирующих вычислений
Все эти «модифицирующие ключевые слова» языка Rust предоставляют новые возможности, которых нет в «базовом Rust». Но они также иногда могут забирать возможности. То, как я начал думать и говорить о функциях языка, — в терминах «подмножество языка» или «надмножество языка». С этой классификацией мы можем снова посмотреть на модифицирующие ключевые слова и сделать следующую категоризацию:
unsafeRust: надмножествоconstRust: подмножествоasyncRust: надмножество
unsafe Rust только добавляет возможность использовать сырые указатели. async только добавляет возможность .await значений. Но const добавляет возможность вычислять значения во время компиляции, но убирает возможность использовать статики и получать доступ к таким вещам, как сеть или файловая система.
Если функции языка только добавляют к базовому Rust, то они считаются надмножествами. Но если функции, которые они добавляют, требуют, чтобы они также ограничивали другие функции, то они считаются подмножествами. В случае с const все const-функции могут быть выполнены во время выполнения. Но не весь код, который может быть выполнен во время выполнения, может быть помечен как const.
Дизайн функций языка как под-/надмножеств «базового» Rust важен: он гарантирует, что язык продолжает ощущаться целостным. И больше, чем размер или объем, именно единообразие приводит к ощущению простоты.
Асинхронность под капотом
В своей основе async/.await в Rust предоставляет стандартизированный способ возвращать типы с методом на них, который возвращает другой тип. Вместо того чтобы возвращать тип напрямую, асинхронные функции сначала возвращают промежуточный тип2.
#![allow(unused)] fn main() { /// Эта функция возвращает строку fn read_to_string(path: Path) -> String { .. } /// Эта функция возвращает тип, который в конечном итоге возвращает строку async fn read_to_string(path: Path) -> String { .. } /// Вместо использования `async fn` мы также можем написать это так. /// `impl Future` здесь — это тип-стирающая структура fn read_to_string(path: Path) -> impl Future<Output = String> { .. } }
Future — это просто тип с методом на нем (fn poll). Если мы вызываем метод в правильное время и правильным образом, то в конечном итоге он даст нам эквивалент Option<T>, где T — значение, которое мы хотели.
У «Future — это просто тип с методом, который мы можем вызывать» есть несколько следствий. Во-первых, так же, как мы можем выбрать вызов метода, мы можем выбрать и не вызывать метод. Если мы не будем вызывать метод вообще, то future не выполнит никакой работы3. Или мы можем выбрать вызов его, а затем прекратить вызывать его на некоторое время. Может быть, мы просто вызовем метод снова позже. Мы даже можем выбрать отбрасывание структуры в любой момент, и тогда не будет больше метода для вызова и больше не будет значения, которое можно получить.
Когда мы говорим о «представлении вычисления в типе», мы на самом деле говорим о компиляции async fn и всех ее точек .await в машину состояний, которая знает, как приостанавливаться и возобновляться из различных точек .await. Эти машины состояний — это просто структуры с некоторыми полями в них, и у них есть автоматически сгенерированная реализация Future::poll, которая знает, как правильно переходить между различными состояниями. Чтобы узнать больше о том, как работают эти машины состояний, я рекомендую посмотреть «Life of an async fn» от tmandry.
Синтаксис .await предоставляет способ гарантировать, что ни одна из деталей лежащего в основе poll не проявляется в пользовательском синтаксисе. Большая часть использования async/.await выглядит так же, как неасинхронный Rust, но с добавленными сверху аннотациями async/.await.
Возможности асинхронного Rust
Основная функция, которую предоставляет async/.await в Rust, — это контроль над выполнением. Вместо того чтобы контракт был:
«вызов функции» -> «вывод»
Мы вместо этого получаем доступ к промежуточному шагу:
«вызов функции» -> «вычисление» -> «вывод»
Вычисление — это уже не просто что-то, что скрыто от нас. С async/.await мы получаем возможность управлять самим вычислением. Это приводит к нескольким ключевым возможностям:
- Возможность приостанавливать/отменять/ставить на паузу/возобновлять вычисление (ad-hoc отмена)
- Возможность выполнять вычисления конкурентно (ad-hoc конкурентность)
- Возможность комбинировать контроль над выполнением, отменой и конкурентностью
Ad-hoc отмена
Возможность приостанавливать/отменять/ставить на паузу/возобновлять любое вычисление невероятно полезна. Из трех способность отменять выполнение, вероятно, наиболее полезна. Как в синхронном, так и в асинхронном коде желательно останавливать выполнение до его завершения. Но что уникально для асинхронного Rust, так это то, что любое вычисление может быть остановлено единообразным способом. Каждый future может быть отменен, и все future должны это учитывать4.
Ad-hoc конкурентность
Возможность выполнять вычисления конкурентно — еще одна характерная возможность асинхронного Rust. Любое количество async fn может быть запущено конкурентно5 и ожидаться (.await) вместе. В неасинхронном Rust конкурентность обычно привязана к параллелизму: многие вычисления могут быть запланированы конкурентно с использованием thread::spawn и разделения таким образом. Но асинхронный Rust отделяет конкурентность от параллелизма, предоставляя больше контроля6. В неасинхронном Rust конкурентность и параллелизм взаимосвязаны, что, среди прочего, имеет последствия для производительности. Мы поговорим больше о различиях позже в этом посте.
Комбинирование отмены и конкурентности
Теперь, наконец: что происходит, когда вы комбинируете отмену и конкурентность? Это позволяет нам делать некоторые интересные вещи! В моем посте «Async Time III: Cancellation and Signals» я подробно рассказываю о некоторых вещах, которые можно делать с этим. Но канонический пример здесь: таймауты. Таймаут — это конкурентное выполнение какого-то future и future-таймера, отображенное в Result:
- Если future завершается до таймера, мы отменяем таймер и возвращаем
Ok - Если таймер завершается до future, мы отменяем future и возвращаем
Err
Это отмена + конкурентность, объединенные для предоставления нового третьего типа операции. Чтобы понять, почему возможность устанавливать таймаут для любого вычисления является полезным свойством, я очень рекомендую прочитать «Crash-Only Software» от Candea и Fox7. Но на таймаутах все не останавливается: если мы комбинируем любые из возможностей приостановки/отмены/паузы/возобновления с конкурентностью, мы открываем myriad новых возможных операций.
Это возможности, которые включает асинхронный Rust. В неасинхронном Rust конкурентность, отмена и приостановки часто требуют вызова нижележащей операционной системы — и это не всегда поддерживается. Например: в Rust нет встроенного способа отменять потоки. Способ сделать это обычно — передать канал в поток и периодически проверять его, чтобы увидеть, не было ли передано какое-то сообщение «отмена».
В отличие от этого, в асинхронном Rust любое вычисление может быть приостановлено, отменено или запущено конкурентно. Это не означает, что все вычисления должны запускаться конкурентно или на все должен быть таймаут. Но эти решения могут приниматься на основе того, что мы реализуем, а не ограничиваться внешними факторами, такими как доступность системного вызова.
Производительность: рабочие нагрузки
Когда что-то заявлено как работающее лучше чего-то другого, всегда стоит спрашивать: «при каких обстоятельствах?» Производительность всегда зависит от рабочей нагрузки. В тестах графических карт вы часто видите различия между видеокартами в зависимости от того, какие игры запускаются. В тестах процессоров сильно важно, является ли рабочая нагрузка в основном однопоточной или многопоточной. И когда мы говорим о функциях программного обеспечения, «производительность» — это тоже не бинарная величина, а сильно зависящая от рабочей нагрузки. Говоря о параллельной обработке, мы можем различать две общие категории рабочих нагрузок:
- Ориентированные на пропускную способность (throughput-oriented)
- Ориентированные на задержку (latency-oriented)
Рабочие нагрузки, ориентированные на пропускную способность, обычно заботятся об обработке максимального количества вещей за кратчайшее время. В то время как ориентированные на задержку заботятся об обработке каждой вещи как можно быстрее. Звучит запутанно? Давайте проясним.
Пример программного обеспечения, разработанного с учетом пропускной способности, — это hadoop. Он создан для «оффлайн» пакетной обработки рабочих нагрузок; где самая важная цель проектирования — минимизировать общее время ЦП, затраченное на обработку данных. Когда данные помещаются в систему, их обработка может часто занимать минуты или даже часы. И это нормально. Нам не важно, когда мы получим результаты (в разумных пределах, конечно), мы в первую очередь заботимся об использовании как можно меньшего количества ресурсов для получения результатов.
Сравните это с общедоступным HTTP-сервером. Сети обычно ориентированы на задержку. Нас часто меньше волнует, сколько запросов мы можем обработать, чем то, как быстро мы можем на них ответить. Когда приходит запрос, мы не хотим тратить минуты или часы на генерацию ответа. Мы хотим, чтобы циклы запрос-ответ измерялись в миллисекундах максимум. И такие вещи, как p99 хвостовые задержки (tail-latencies), часто используются как ключевые показатели производительности.
Асинхронный Rust обычно считается более ориентированным на задержку, чем на пропускную способность. Среды выполнения, такие как async-std и tokio, в первую очередь заботятся о том, чтобы общая задержка оставалась низкой, и предотвращают внезапные скачки задержки.
Понимание того, какой тип рабочей нагрузки обсуждается, часто является первым шагом в обсуждении производительности. Ключевое преимущество асинхронного Rust заключается в том, что большинство систем, которые его используют, были сильно настроены для обеспечения хорошей производительности для рабочих нагрузок, ориентированных на задержку, — примером которых являются сети. Если вы хотите обрабатывать более ориентированные на пропускную способность рабочие нагрузки, неасинхронные крейты, такие как rayon, часто лучше подходят.
Производительность: оптимизации
Асинхронный Rust отделяет конкурентность и параллелизм друг от друга. Иногда эти два понятия путают друг с другом, но на самом деле они разные:
- Параллелизм — это ресурс, конкурентность — это способ планирования вычислений
Лучше всего думать о «параллелизме» как о максимуме. Например, если ваш компьютер имеет два ядра, максимальное количество параллелизма, которое у вас может быть, может быть два8. Но параллелизм отличается от конкурентности: вычисления могут быть перемежаемы на одном ядре, поэтому, пока мы ждем, пока сеть выполнит работу, мы можем запускать некоторые другие вычисления, пока не получим ответ. Даже на однопоточных машинах вычисления могут быть перемежаемыми и конкурентными. И наоборот: только потому, что мы запланировали вещи параллельно, не означает, что вычисления перемежаются. Независимо от того, на скольких ядрах мы работаем, если потоки по очереди ждут на одной общей блокировке, то логическое выполнение может на самом деле все еще происходить последовательно.
Давайте взглянем на пример конкурентной рабочей нагрузки в асинхронном и неасинхронном Rust. В неасинхронном Rust наиболее распространено использование потоков для достижения конкурентного выполнения9. Но поскольку потоки также являются абстракцией для достижения параллельного выполнения, это означает, что в неасинхронном Rust конкурентность и параллелизм часто тесно взаимосвязаны.
В асинхронном Rust мы можем отделить конкурентность от параллелизма. Если рабочая нагрузка конкурентна, это не подразумевает, что она также может быть распараллелена. Это обеспечивает более тонкий контроль над выполнением, что является ключевой силой асинхронного Rust. Давайте сравним конкурентность в неасинхронном и асинхронном Rust:
#![allow(unused)] fn main() { // конкурентное вычисление на основе потоков let x = thread::spawn(|| 1 + 1); let y = thread::spawn(|| 2 + 2); let (x, y) = (x.join(), y.join()); // ждем, пока оба потока вернут результат // конкурентное вычисление на основе async let x = async { 1 + 1 }; let y = async { 2 + 2 }; let (x, y) = (x, y).await; // разрешаем оба future конкурентно }
Это может показаться довольно глупым примером: вычисление синхронное, поэтому оба делают одно и то же, но неасинхронный вариант имеет накладные расходы на необходимость порождать реальные потоки. И на этом все не останавливается: потому что второму примеру не нужны потоки, инлайнер компилятора может вступить в действие и может быть способен оптимизировать его до следующего10:
#![allow(unused)] fn main() { // оптимизированное компилятором конкурентное вычисление на основе async let (x, y) = (2, 4); }
В отличие от этого, лучшее, что компилятор, вероятно, может сделать для варианта на основе потоков, это:
#![allow(unused)] fn main() { // конкурентное вычисление на основе потоков let x = thread::spawn(|| 2); let y = thread::spawn(|| 4); let (x, y) = (x.join(), y.join()); // ждем, пока оба потока вернут результат }
Отделение конкурентности от параллелизма позволяет проводить больше оптимизаций вычислений. async в Rust — это basically модный способ создания машины состояний, и вложенные вызовы async/.await позволяют типам компилироваться в единичные машины состояний. Иногда мы можем захотеть разделить машины состояний, но это тот вид контроля, который предоставляет нам асинхронный Rust, которого труднее достичь, используя неасинхронный Rust.
Экосистема
Прежде чем мы закончим, мы должны указать еще одну последнюю причину, по которой люди могут выбирать асинхронный Rust: размер экосистемы. Без keyword generics может быть много работы для авторов библиотек публиковать и поддерживать библиотеки, которые работают как в асинхронном, так и в неасинхронном Rust. Часто проще всего просто опубликовать либо асинхронную, либо неасинхронную библиотеку и не учитывать другой вариант использования. Многие сетевые библиотеки на crates.io используют асинхронный Rust, что означает, что библиотеки, строящиеся поверх этого, также будут использовать асинхронный Rust. И, в свою очередь, люди, желающие создавать веб-сайты без переписывания всего с нуля, часто будут иметь большую экосистему для выбора при использовании асинхронного Rust.
Сетевые эффекты реальны, и их нужно признавать в этом контексте. Не все, кто хочет построить веб-сайт, будут думать в терминах функций языка, но вместо этого могут просто смотреть на варианты, которые у них есть с точки зрения экосистемы. И это тоже совершенно веская причина использовать асинхронный Rust.
Заключение
Иногда возникают разговоры об асинхронном Rust с предложениями вроде: «Что, если бы мы полностью запретили отмену?»11 Видеть это всегда сбивает меня с толку, потому что кажется, что это несет фундальное непонимание того, что предоставляет асинхронный Rust и почему его следует использовать. Если фокус исключительно на производительности, такие функции, как отмена или аннотации .await, могут казаться простой неприятностью.
Но если фокус больше на возможностях, которые включает асинхронный Rust, такие вещи, как отмена и таймауты, быстро поднимаются от неприятности до ключевых причин для принятия асинхронного Rust. Асинхронный Rust предоставляет нам возможность контролировать выполнение таким образом, который просто невозможен в неасинхронном Rust. И, откровенно говоря, даже невозможен во многих других языках программирования, имеющих async/.await. Тот факт, что async fn компилируется в ленивую машину состояний вместо eager управляемой задачи, — это crucial различие. И это означает, что мы можем создавать примитивы конкурентности полностью в библиотечном коде, вместо того чтобы нужно было встраивать их в компилятор или среду выполнения.
В асинхронном Rust функции, которые он включает, строятся друг на друге. Вот краткое описание того, как они связаны:
Иерархия возможностей
асинхронного Rust от Yosh
┌───────────────────────────────┐
3. │ Таймауты, Таймеры, Сигналы │ …которые затем могут быть скомпонованы в…
├───────────────┬───────────────┤
2. │ Отмена │ Конкурентность│ …которые, в свою очередь, включают…
├───────────────┴───────────────┤
1. │ Контроль над выполнением │ Основные future включают…
└───────────────────────────────┘
Мы также кратко рассмотрели аспект производительности асинхронного Rust. Вообще говоря, он может быть более производительным, чем неасинхронный Rust, когда вы делаете асинхронный ввод-вывод. Но это в основном будет тогда, когда нижележащие системные API предназначены для этого, что обычно включает сетевые API и, в последнее время, также начало включать дисковый ввод-вывод.
Спасибо Ирине Шестак за вычитку этого поста и предоставление полезных отзывов по пути.
Примечания
-
Введение в книгу по асинхронному Rust обобщает преимущества асинхронного Rust следующим образом: «В итоге асинхронное программирование позволяет реализовать высокопроизводительные реализации, которые подходят для низкоуровневых языков, таких как Rust, обеспечивая при этом большую часть эргономических преимуществ потоков и сопрограмм.» ↩
-
Конечно; «монада» 🙃 ↩
-
Да, это только случай для
async fn/async {}-future. Если вы-> impl Future, вы можете выполнять работу до конструирования future и возврата его. Но это не считается хорошим паттерном, и на практике это довольно редко. ↩ -
Да, я полностью осведомлен о концепции «безопасности отмены» (cancellation-safety), и у меня скоро выйдет пост, обсуждающий ее более подробно. Кратко: концепция «безопасности отмены» недостаточно определена, но важно: «безопасность отмены» актуальна только при использовании
select! {}, который является чем-то, что не следует использовать. Правильная обработка отмены — это то, что вручную созданные future все еще должны делать, и это может быть сложно сделать без «async Drop». Но это отличается от «безопасности отмены» или идеи, что future вообще не должны учитывать возможность быть отмененными. ↩ -
За исключением любых сбоев среды выполнения, таких как взаимоблокировки (deadlocks), которые могут возникнуть, если два вычисления выполняются конкурентно, но разделяют ресурс. ↩
-
Не все библиотеки используют разделение между «конкурентностью» и «параллелизмом» though. Мы все еще очень much разбираемся в том, что такое асинхронный Rust вообще, но многие из библиотек в общем использовании сегодня не обязательно проявляют это понимание. Я знаю, что многие мои собственные старые библиотеки точно нет. ↩
-
Крикнуть Eric Holk за то, что познакомил меня с этой статьей! ↩
-
Это упрощенный пример; для более длинного объяснения см. документацию
std::thread::available_parallelism. ↩ -
Если только вы не начнете вручную писать циклы
epoll(7)и вручную создавать машины состояний. В какой-то момент вы можете начать думать о создании абстракций, чтобы эти машины состояний лучше сочетались друг с другом, в какой момент вы basically снова пришли к future. Нативно, чтобы достичь: «Я хочу, чтобы этот код выполнялся конкурентно», потоки — самая простая, наиболее удобная абстракция, доступная в неасинхронном Rust. ↩ -
Обратите внимание, что этот точный пример еще не работает в оптимизаторе, но я не верю, что есть какая-то веская причина, почему он не мог бы. Это все локальные рассуждения, без необходимости межпоточной синхронизации. Ближайший пример, который у меня есть для оптимизаций такого рода, — этот пример версии
block_on, написанной вручную, которая компилируется абсолютно в ничто. Это немного читерство, удаляющее не использованиеArcна основе атомиков, так что я не уверен, насколько это реалистично. Но это определенно то, к чему стоит стремиться, и я оптимистичен, что по мере того, как асинхронный Rust будет видеть больше использования, мы увидим больше асинхронно-специфичных оптимизаций. ↩ -
Цель этого часто — иметь линейные future или «future, которые гарантированно завершаются». Способ, которым отмена была бы запрещена, — только вид «остановить опрос» (stop polling). Вы все равно должны быть able передавать каналы для отмены вещей, по крайней мере, такова теория. Хотя я верю, что могут быть последствия и для конкурентности, что сделало бы это действительно трудным. ↩
Асинхронная отмена 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 могут быть отменены в одной из двух точек. Для простоты большинство наших примеров в этом посте будут использовать операции сна и печати; тогда как в реальном мире мы бы, вероятно, говорили об операциях с файлами/сетью и обработке данных.
- Отменить 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.
- Отменить 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, а для tokio — JoinHandle::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 трейт унаследовал бы от этого трейта и добавил бы дополнительную гарантию, что он на самом деле может быть отменен.
Я видел две основные мотивации для движения в этом направлении:
- Совместимость FFI с асинхронной системой C++, где невыполнение «C++ Future» до завершения является неопределенным поведением.
- Это упростило бы написание асинхронного 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.
Примечания
-
Возможно, «Stream» / «AsyncIterator» является третьим асинхронным примитивом, но все, что мы говорим о типе Future, применимо и к Stream, поэтому мы считаем их одинаковыми в этом посте. ↩
-
«управляемый» используется на протяжении всего этого поста как: «планируется в среде выполнения». Это обычно сопровождается требованием, чтобы future были
'static(future не содержит никаких заимствований), и при планировании в многопоточной среде выполнения часто, но не всегда, требует, чтобы future былиSend(future потокобезопасен). ↩ -
Вы можете думать о Задачах как о чем-то вроде облегченных потоков, управляемых вашей программой, а не вашей операционной системой. ↩
-
Аналогом задач Rust в JavaScript является
Promise. Он начинает выполняться в момент его создания, а не в момент его ожидания. Насколько я понимаю,Task<T>в C# работает much таким же образом. ↩ -
Вероятно, потому что я был соавтором проекта. ↩
-
Способ, которым поток может быть отменен извне, — это передать канал в поток и отменить, когда сообщение получено. В отличие от асинхронного Rust, потоки не имеют точек
.await, которые действуют как естественные точки остановки. Поэтому вместо этого потоки должны подключаться к отмене, вручную прослушивая сигналы. ↩ -
Подробнее о том, можем ли мы actually обеспечить это later в этом посте. ↩
-
Эта терминология заимствована из Swift, но похожа-ish на то, как работает
crossbeam-scope(n потоков, управляемых центральной точкой). Однако, в отличие отcrossbeam-scope, название меньше касается того, как протекают времена жизни, и больше того, как вы можете рассуждать о том, как это должно использоваться. ↩ -
«Очищать ресурсы независимо от того, что вызвало останов функции» — это то, что я называю «устойчивостью к останову» (halt-safety). Я не в восторге от термина, но мне нужно было найти способ дать имя этой группе механизмов. Я надеюсь, термин достаточно ясен. ↩
-
Например, если мне когда-либо понадобится создать что-то с использованием
MaybeUninit, я хочу, чтобы компилятор напоминал мне создать охранник удаления при сохранении его живым после чего-то, что может panic. Возможно, someday (: ↩ -
Это, вероятно, заслуживает отдельного поста в блоге. Но у меня уже так много постов в работе, что я решил добавить это сюда. ↩
-
Люди говорили мне это из использования других языков. Я никогда actually не использовал
defer, поэтому не могу комментировать, на что это похоже. Но я много занимался нелинейным управлением потоком, написанием JavaScript с большим количеством колбэков, и это действительно требует некоторого привыкания. ↩
Размещающие функции (Placing Functions)
— 2025-07-08 Yoshua Wuyts Источник
- Что такое размещающие функции?
- Базовый десугаринг
- Мышление в терминах размещающих функций
- Предшествующие наработки в Rust
- Вопросы и ответы
- А как насчет размещающих аргументов?
- А как насчет заимствований / расширения локальных времен жизни?
- А как насчет закрепления (pinning)?
- Обязательны ли аннотации?
- А как насчет самоссылающихся типов?
- Почему бы не использовать напрямую
Init<T>или&out? - Могут ли размещающие функции быть вложенными?
- Заключение
Что такое размещающие функции?
Около года назад я заметил, что построение на месте (in-place construction) оказывается на удивление простым. Разделив создание места в памяти и запись значения в это место, нетрудно понять, как можно превратить это в языковую возможность. И вот около шести месяцев назад я взялся за это и создал крейт placing: прототип на основе proc-макросов для "размещающих функций".
Размещающие функции — это функции, тип возвращаемого значения которых конструируется в стековом фрейме вызывающей стороны, а не в стековом фрейме функции. Это означает, что адрес с момента начала конструирования стабилен. Это не только может привести к повышению производительности, но и служит основой для ряда полезных функций, таких как поддержка dyn AFIT (Асинхронных Функций в Traits).
В этом посте я объясню, как работает десугаринг размещающих функций, почему они являются правильным решением для размещения, и как они интегрируются в язык. Не так подробно, как в настоящем RFC, но скорее как общее введение в идею размещающих функций. И чтобы сразу перейти к делу, вот базовый пример того, как это будет работать:
struct Cat { age: u8, } impl Cat { #[placing] // ← Помечает функцию как "размещающую". fn new(age: u8) -> Self { Self { age } // ← Конструирует `Self` во фрейме вызывающей стороны. } fn age(&self) -> &u8 { &self.age } } fn main() { let cat = Cat::new(12); // ← `Cat` конструируется на месте. assert_eq!(cat.age(), &12); }
Базовый десугаринг
Цель крейта placing — доказать, что размещающие функции не должны быть сложными в реализации. Мне удалось реализовать рабочий прототип за несколько часов во время зимних каникул. В общей сложности я потратил на реализацию около четырех дней. Я, впрочем, не инженер по компиляторам, и я ожидаю, что замечательные ребята из T-Compiler, вероятно, смогут воссоздать это за доли времени, которое потратил я.
Поскольку я не знаком с фронтендом rustc, я реализовал крейт placing полностью с помощью proc-макросов. Плюс был в том, что я мог быстрее получить работающий прототип. Минус в том, что proc-макросы не имеют доступа к информации о типах, поэтому мне пришлось обходить это ограничение. Что привело к API, требующему множества атрибутов proc-макросов. Но для доказательства концепции это приемлемо.
Давайте пройдемся по базовому примеру, который я показал ранее, но на этот раз с использованием proc-макросов. Начнем с установки крейта placing:
$ cargo add placing
Затем мы можем импортировать placing и определить нашу структуру Cat. Нам нужно аннотировать её атрибутом #[placing], потому что нам нужно немного изменить внутреннее представление. Вот как это выглядит:
#![allow(unused)] fn main() { //! Исходный код use placing::placing; #[placing] pub struct Cat { age: u8, } }
Давайте посмотрим, во что раскрывается аннотация #[placing]. Как я сказал во введении: размещающие функции разделяют создание места в памяти и инициализацию значений в этом месте. Для нашего типа Cat местом должен быть тип MaybeUninit<Cat>. Но поскольку мы хотим сохранить тот же тип, даже если мы меняем внутреннее устройство, мы фактически хотим оставить Cat как внешнее имя типа и переместить поля во внутренний MaybeUninit:
#![allow(unused)] fn main() { //! Десугаринг use std::mem::MaybeUninit; /// Это сохраняет тот же внешний тип, /// но меняет внутреннее устройство, чтобы хранить поля /// в `MaybeUninit`. #[repr(transparent)] pub struct Cat(MaybeUninit<InnerCat>); /// Это поля исходного типа `Cat`. /// Выделены в отдельную структуру, чтобы их можно было /// обернуть в `MaybeUninit` внутри. struct InnerCat { age: u8, } }
Нашему десугарингу нужно добавить последний штрих к определению типа, чтобы обеспечить корректную работу. Поскольку мы теперь храним MaybeUninit, мы должны убедиться, что он вызывает свои деструкторы при дропе. Это означает, что наш десугаринг должен сгенерировать реализацию Drop, которая передает вызов через MaybeUninit.
#![allow(unused)] fn main() { //! Десугаринг impl Drop for Cat { fn drop(&mut self) { // Конструкторы гарантируют, что это никогда // не будет дропнуто до инициализации. unsafe { self.0.assume_init_drop() } } } }
Теперь, когда у нас есть определение типа, давайте покажем, как реализовать конструктор new. Поскольку у нас нет доступа к информации о типах, крейт placing требует аннотации как на блоке impl, так и на методе:
#![allow(unused)] fn main() { //! Исходный код #[placing] impl Cat { #[placing] fn new(age: u8) -> Self { Self { age } } } }
Это самая сложная часть десугаринга, потому что ей нужно разделить конструктор на две части. Одну — для создания места, другую — для инициализации значений на месте. Концептуально это означает переписывание возвращаемого типа последней строки в запись в изменяемый аргумент.
#![allow(unused)] fn main() { //! Десугаринг use std::mem::MaybeUninit; impl Cat { /// Вызов этой функции конструирует место для /// инициализации типа. Это часть двухэтапного /// конструктора, и за ней всегда должен /// следовать вызов `new_init`. unsafe fn new_uninit() -> Self { Self(MaybeUninit::uninit()) } /// Эта функция инициализирует значения типа на месте. /// `new_init` нельзя вызывать более одного раза, и /// он всегда должен следовать после вызова `new_uninit`. unsafe fn new_init(&mut self, age: u8) { let this = self.0.as_mut_ptr(); unsafe { (&raw mut (*this).age).write(age) }; } } }
Далее у нас также есть геттер. Все, что нам нужно с ним сделать, — это научить его "пробираться" через внешнюю структуру к внутренним полям. Вот определение:
#![allow(unused)] fn main() { //! Исходный код #[placing] impl Cat { fn age(&self) -> u8 { &self.age } } }
И вот во что он раскрывается:
#![allow(unused)] fn main() { //! Десугаринг impl Cat { fn age(&self) -> u8 { let this = unsafe { self.0.assume_init_ref() }; this.age } } }
Теперь мы готовы создать экземпляр Cat на месте и вызвать наш геттер. Это единственная часть, которую нельзя абстрагировать, поскольку правила видимости макросов Rust очень строги по сравнению с прямой реализацией в компиляторе. То, что нам действительно хотелось бы сделать, это:
#![allow(unused)] fn main() { let cat = placing!(Cat, new, 12); }
Но пока нам приходится вызывать это вручную, и вызов выглядит так:
//! Исходный код fn main() { let mut cat = unsafe { Cat::new_uninit() }; unsafe { cat.new_init(12) }; assert_eq!(cat.age(), &12); }
Кстати: в Rust теперь есть экспериментальная функция super_let, которая позволяет конструировать типы в охватывающей области видимости, но ссылаться на них потом. Это почти подходит для нашего случая использования, но может возвращать только ссылки, а не владеющие типы. Это означает, что лучшее, что мы можем сделать с этой функцией, это следующее (песочница):
#![allow(unused)] #![feature(super_let)] fn main() { macro_rules! new_cat { ($value:expr $(,)?) => { { super let mut cat = unsafe { Cat::new_uninit()) }; unsafe { Cat::new_init(&mut cat, $value) }; &mut cat // ← ❌ Возвращает по ссылке, а не по владению. } } } }
Если мы попытаемся вернуть владеемое значение, оно фактически скопируется — чего мы как раз и пытаемся избежать. Мы могли бы изменить это в реализации компилятора. И чтобы подвести итог: вот версия нашего исходного примера от крейта placing:
//! Исходный код use placing::placing; #[placing] struct Cat { age: u8, } #[placing] impl Cat { #[placing] fn new(age: u8) -> Self { Self { age } } fn age(&self) -> u8 { &self.age } } fn main() { // `Cat` конструируется на месте. let mut cat = unsafe { Cat::new_uninit() }; unsafe { cat.new_init() }; assert_eq!(cat.age(), &12); }
А вот тот же код с раскрытыми макросами:
//! Десугаринг use std::mem::MaybeUninit; #[repr(transparent)] struct Cat(MaybeUninit<InnerCat>); struct InnerCat { age: u8, } impl Cat { /// Создает неинициализированное место. unsafe fn new_uninit() -> Self { Self(MaybeUninit::uninit()) } /// Инициализирует поля на месте. fn new_init(&mut self, age: u8) { let this = self.0.as_mut_ptr(); unsafe { (&raw mut (*this).age).write(age) }; } fn age(&self) -> u8 { let this = unsafe { self.0.assume_init_ref() }; this.age } } impl Drop for Cat { fn drop(&mut self) { unsafe { self.0.assume_init_drop() } } } fn main() { let mut cat = unsafe { Cat::new_uninit() }; unsafe { cat.new_init() }; assert_eq!(cat.age(), &12); }
Крейт placing также поддерживает десугаринг типов, возвращающих Result, Box и Arc. А также включает поддержку вложенных конструкторов, где вы в конечном итоге вызываете #[placing] fn из другой #[placing] fn. Поэтому я вполне уверен, что это должно сработать.
Главное ограничение крейта в том, что он пока не поддерживает черты (traits). Я начал добавлять поддержку, но у меня закончилось время. Вот почему, если вы посмотрите на кодогенерацию в крейте, вы увидите, что везде вставлен константный дженерик PLACING: bool. Пока он не особо полезен, но теперь вы знаете, зачем он там.
Мышление в терминах размещающих функций
Размещающие функции черпают вдохновение из двух других языковых возможностей:
- Super Let (Rust Nightly): это экспериментальная функция, которая позволяет временно расширять время жизни. Это позволяет переменным создаваться в охватывающей области видимости.
- Гарантированное устранение копий (C++ 17): иногда называемое "отложенной материализацией временных объектов", гарантирует, что структуры всегда конструируются в области видимости вызывающей стороны.
Если super let работает с областями видимости блоков, то размещающие функции можно считать работающими через границы функций. И если гарантированное устранение копий — это автоматическая гарантия, применяемая ко всем функциям, то размещающие функции применяются только к функциям, которые явно включили эту возможность.
Дизайн размещающих функций пытается сбалансировать три основных ограничения:
- Контроль: в некоторых случаях размещение уже происходит через оптимизации (например, инлайнинг). Языковая функция размещения должна гарантировать размещение, иначе компиляция должна завершаться ошибкой.
- Интеграция: гарантированное устранение копий в C++ показывает, насколько широко применимо размещение. Это означает, что первоначальная верхняя граница для этой языковой возможности — это каждая функция с возвращаемым типом. Это невероятно широко, и нам нужно обеспечить ее тесную и простую интеграцию с остальным языком.
- Совместимость: стандартная библиотека Rust дает строгие гарантии обратной совместимости. Мы хотим, чтобы она могла использовать размещение без нарушения обратной совместимости. Это означает, что мы не можем создавать новые черты только для поддержки размещения или добавлять новые API specifically для размещения.
Было бы ошибкой считать размещение функцией с узкой применимостью; C++ доказывает, что размещение актуально почти для каждого конструктора. Правильный способ думать о размещении — предположить, что у него максимально широкая верхняя граница. Но затем начать проектировать и реализовывать минимальное подмножество. Хотя мы можем в конечном итоге найти ограничения или случаи, когда размещение невозможно; это будет что-то, что мы докажем через реализацию.
Вот почему я считаю, что нам следует моделировать "размещение" скорее как эффект, а не как другой вид языковой возможности. Я думаю о размещающих функциях как о чем-то среднем между const функциями и async функциями. Они меняют кодогенерацию функции, не совсем unlike преобразование генератора, которое мы используем для async и gen. Но оно никогда не понижается до другого типа, который мы можем наблюдать в системе типов, что делает его очень похожим на const.
Предшествующие наработки в Rust
Когда я уже почти готовился опубликовать этот пост, я немного поговорил с Sy Brand о размещающих функциях, C++ и ABI. Оказалось: Rust уже гарантирует размещение в ряде случаев. Рассмотрим, например, следующий код:
#![allow(unused)] fn main() { pub struct A { a: i64, b: i64, c: i64, d: i64, e: i64, } impl A { pub fn new() -> A { A { a: 42, b: 69, c: 4269, d: 6942, e: 696942, } } } }
Когда мы компилируем это для ABI SYSV на x86, выводится следующая ассемблерная вставка (godbolt):
example::A::new::hd00831bc57a4b613:
mov rax, rdi
mov qword ptr [rdi], 42
mov qword ptr [rdi + 8], 69
mov qword ptr [rdi + 16], 4269
mov qword ptr [rdi + 24], 6942
mov qword ptr [rdi + 32], 696942
ret
Эта ассемблерная вставка записывает данные напрямую по смещениям указателя, переданного в функцию. Другими словами: эта функция размещает. И она фактически гарантированно это делает, как определено в спецификации ABI x86 SYSV, раздел 3.2.3:
[!quote] Если тип имеет класс MEMORY, то вызывающая сторона предоставляет место для возвращаемого значения и передает адрес этого хранилища в
%rdi, как если бы это был первый аргумент функции. Фактически, этот адрес становится "скрытым" первым аргументом. Это хранилище не должно перекрывать любые данные, видимые вызываемой стороне через другие имена, кроме этого аргумента. При возврате%raxбудет содержать адрес, переданный вызывающей стороной в%rdi.
Скрытый первый аргумент, передаваемый функциям? Это очень похоже на то, как должен работать десугаринг для размещающих функций. Фактически, гарантированное устранение копий в C++ использует эту же самую возможность. В разделе 3.2.3 stated следующее:
Если объект C++ имеет нетривиальный конструктор копирования или нетривиальный деструктор, он передается по невидимой ссылке (объект заменяется в списке параметров указателем, который имеет класс INTEGER).
Это все невероятно похоже на то, что я предлагаю, но происходит автоматически на уровне ABI, а не прозрачно на уровне языка. Это также raises the question: насколько легко было бы изменить код понижения ABI в rustc для x64, чтобы просто сказать "если тип объявлен с атрибутом placing, он всегда классифицируется как MEMORY"?
Вопросы и ответы
А как насчет размещающих аргументов?
До сих пор этот пост обсуждал размещение только в отношении возвращаемых типов. Для нашей цели сохранения полной совместимости с существующей stdlib, размещения возвращаемых типов недостаточно. Еще в марте Eric Holk и Tyler Mandry argued что нам также понадобится какая-то форма размещающих аргументов (или, по крайней мере, возможность, которая позволяет нам это делать). Давайте используем Box::new в качестве примера, чтобы показать, почему. Без размещающих аргументов лучшее, что мы могли бы сделать, это определить какую-то форму Box::new_with, которая принимает размещающее замыкание:
#![allow(unused)] fn main() { impl<T> Box<T> { // Существующий конструктор по умолчанию. fn new(x: T) -> Self { ... } // Вновь введенный `placing` конструктор. fn new_with<F>(f: F) -> Self where F: #[placing] FnOnce() -> T, { ... } } }
Конструктор new_with всегда предпочтительнее конструктора new, потому что он гарантирует отсутствие промежуточных копий в стеке. Это приведет к эффективному устареванию Box::new. Если не полному, то, вероятно, сначала путем "лучших практик".
Способ решить это — позволить Box::new быть получателем значений, которые нужно разместить. Это будет сделано путем требования аннотаций не на уровне функции, а на уровне аргумента/возвращаемого типа. Продолжая использовать нашу заполнительную нотацию #[placing], мы можем представить, что это выглядит примерно так:
#![allow(unused)] fn main() { //! Исходный код impl<T> Box<T> { // `Box::new` здесь принимает тип `T` и // конструирует его на месте в куче. fn new(x: #[placing] T) -> Self { ... } } }
Я ожидаю, что десугаринг этого, вероятно, будет выглядеть somewhat similar to RFC инициализации на месте Alice Ryhl, десугарируясь в некоторую форму impl Emplace trait. Но ключевой момент: это будет наблюдаемо только внутри реализации, а не для любой из вызывающих сторон.
#![allow(unused)] fn main() { //! Десугаринг /// Запись значения в место. trait Emplace<T> { fn emplace(self, slot: *mut T); } impl<T> Box<T> { fn new(x: impl Emplace<T>) -> Self { let mut this = Box::<T>::new_uninit(); // 1. Создать место. x.emplace(this.as_mut_ptr()); // 2. Инициализировать значение. Ok(this.assume_init()) // 3. Все готово. } } }
Причина, по которой я поместил это в раздел "Вопросы и ответы", в том, что я еще не разобрался с более тонкими языковыми правилами, поскольку еще не реализовал это. Как основное ограничение дизайна: вызов функции, принимающей размещающие аргументы, не должен отличаться от обычной функции. Это необходимо для сохранения обратной совместимости API. Особенность должна заключаться в том, что функции с размещающими аргументами и размещающими возвращаемыми типами должны работать вместе для размещения.
А как насчет заимствований / расширения локальных времен жизни?
В своем посте о super let Mara приводит наглядный пример, когда полезно расширение времени жизни временных объектов. Здесь Writer::new принимает &'a File, и нам нужна такая возможность, как super let, чтобы создать экземпляр File, который переживет область видимости блока:
#![allow(unused)] fn main() { let writer = { println!("opening file..."); let filename = "hello.txt"; super let file = File::create(filename).unwrap(); Writer::new(&file) }; }
Возвращаемый тип Writer здесь должен иметь время жизни, чтобы иметь возможность ссылаться на super let file. Но это не может быть обычное время жизни, поскольку оно не подчиняется обычным правилам. Без указания конкретных правил это время жизни было названо 'super. С точки зрения блока оно ведет себя почти как 'static — хотя关键о оно не то же самое, что 'static.
Теперь вопрос: как мы можем представить этот блок как функцию? Потому что логично, что мы в конечном итоге захотим выносить функциональность из блоков в функции. Мы, вероятно, захотели бы сделать это с помощью времени жизни 'super, примерно так:
#![allow(unused)] fn main() { //! Исходный код fn create_writer(filename: &str) -> Writer<'super> { println!("opening file..."); super let file = File::create(filename).unwrap(); Writer::new(&file) } }
Обратите внимание, что сам Writer не требует аннотации #[placing]: нормально, что мы копируем его из функции. Единственная важная часть — это то, что file переживает текущую область видимости. Десугаринг для этого довольно забавен, даже если мы еще не можем представить его в системе типов. Что нам нужно сделать здесь, так это убедиться, что file сконструирован на месте в области видимости вызывающей стороны. И после инициализации мы можем ссылаться на него, используя пустое/небезопасное время жизни в нашем возвращаемом типе. Я не проверял этого, но я считаю, что это валидный десугаринг:
#![allow(unused)] fn main() { //! Десугаринг fn create_writer<'a>(filename: &str, file: &'a mut MaybeUninit<File>) -> Writer<'a> { println!("opening file..."); let file = unsafe { file.write(File::create(filename).unwrap()) }; Writer::new(file) } }
Однако это должно быть paired with прелюдией функции при вызове для создания места для file. Таким образом, мы можем представить, что вызов этой функции выглядит примерно так:
#![allow(unused)] fn main() { // С синтаксическим сахаром. let writer = create_writer("hello.text"); // Десугаринг. let mut file = MaybeUninit::uninit(); let writer = create_writer("hello.text", &mut file); }
А как насчет закрепления (pinning)?
Как только у нас появится размещение в языке, большинство причин, по которым Pin устроен именно так, отпадут. Но есть разрыв между наличием размещения и наличием !Move, так что нам нужно обеспечить некоторую форму совместимости с Pin. К счастью, тип Pin — это просто частный случай предыдущего примера с расширением времени жизни.
Что замечательно в размещающих функциях, так это то, что они позволят нам заменить макрос std::pin::pin! на свободную функцию pin, используя время жизни 'super:
#![allow(unused)] fn main() { //! Исходный код pub fn pin<T>(t: T) -> Pin<&'super mut T> { super let mut t = t; unsafe { Pin::new_uninit(&mut t) } } }
Все, что нужно сделать десугарингу, чтобы это работало, — это изменить функцию так, чтобы она принимала дополнительный слот MaybeUninit<T>, в который мы можем записать наше значение. Это позволяет нам расширить время жизни, после чего мы можем ссылаться на него в нашем возвращаемом типе, как показано ниже:
#![allow(unused)] fn main() { //! Десугаринг pub fn pin<T, 'a>(t: T, slot: &'a mut MaybeUninit<T>) -> Pin<&'a mut T> { let mut t = unsafe { slot.write(t) }; unsafe { Pin::new_uninit(&mut t) } } }
Обязательны ли аннотации?
RFC 2884: Placement by Return предложил ввести правила Гарантированного Устранения Копий C++ в Rust практически как есть. Я ценю этот RFC, потому что в нем есть правильное представление о масштабе изменений и делается это solely путем изменения смысла return. Но где он сталкивается с проблемами, так это в том, что он меняет только смысл return. И поэтому вместо добавления размещения, например, в Box::new, ему нужно добавить новый метод Box::new_with.
Фундаментально есть три вида размещения, которые нас интересуют:
- Размещение возвращаемых типов: когда мы хотим избежать копирования типа, возвращаемого из функции. Например, если нам нужен ссылочно-стабильный конструктор.
- Размещение аргументов функции: когда мы хотим избежать копирования аргумента, передаваемого в функцию. Например: при конструировании типа в куче.
- Расширение времени жизни: когда мы хотим ссылаться на локальную переменную из локального типа, который переживет текущую область видимости (расширение времени жизни). Например: при закреплении (pinning).
Даже если Гарантированное Устранение Копий C++ должно быть нашей конечной целью; аннотации позволяют нам достичь этого постепенно. Размещение возвращаемых типов довольно просто. Размещение аргументов функции немного сложнее. А расширение времени жизни будет еще сложнее. Возможность явно включать это с помощью аннотаций означает, что мы можем начать с малого и постепенно наращивать.
Для чего-то такого широкого, как "функции с аргументами или возвращаемыми типами", это кажется правильным way to start. И если по какой-то причине эта возможность окажется настолько успешной, что мы захотим аннотировать почти каждую функцию ей, изменение значений по умолчанию, похоже, можно будет сделать через редакцию (edition), если бы мы захотели.
А как насчет самоссылающихся типов?
Я уже много писал о самоссылающихся типах ранее. Как только у нас появятся размещающие функции, у нас будут три компонента для обобщенных самоссылающихся типов:
- Размещающие функции: чтобы мы могли конструировать тип в стабильном месте в памяти.
- Самовремена жизни (Self-lifetimes): чтобы вы могли объявить, что некоторое поле заимствует из некоторого другого поля.
- Частичные конструкторы: чтобы вы могли начать с инициализации владеемых данных first, а затем инициализировать ссылки на эти данные second.
Мы уже видели размещающие функции. Самовремена жизни позволят полям ссылаться на данные, содержащиеся в других полях, что, вероятно, будет выглядеть примерно так:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self.data str, // ← ссылается на `self.data` } }
А частичные конструкторы позволят вам конструировать типы в несколько этапов. Например, здесь мы сначала инициализируем поле data в Cat. А затем мы берем строку внутри него и выполняем crude parsing, чтобы интерпретировать все до первого пробела как имя кота:
#![allow(unused)] fn main() { fn new_cat(data: String) -> Cat { let mut cat = Cat { data, .. }; cat.name = cat.data.split(' ').next().unwrap(); cat } }
Как только у нас это появится, мы, конечно, можем combine это с примерами расширения времени жизни, которые мы показали ранее, чтобы гарантировать, что тип остается в стабильном месте памяти. Но ключевой момент: это также forward-совместимо с альтернативными механизмами ссылочной стабильности, такими как автотрейт Move.
Как примечание: частичные конструкторы — это basically та же возможность, что и типы представлений (view types) и типы шаблонов (pattern types). Это все та же общая возможность уточнения (refinement), но теперь с добавленным правилом, что мы можем перейти от уточненного типа обратно к исходному типу, заполнив его поля. Присваивание здесь занимает место обратного совпадения (inverse match), если хотите.
Что хорошо в этом дизайне, так это то, что эти возможности все ортогональны, но дополняют друг друга. Размещение полезно даже без частичной инициализации. И частичная инициализация (уточнение) полезна даже без самоссылающихся времен жизни. То, как возможности дополняют друг друга таким образом, для меня является признаком хорошего дизайна языка. Это означает, что он обобщается beyond просто нишевого случая использования. Но одновременно становится еще более полезным в сочетании с другими функциями.
Почему бы не использовать напрямую Init<T> или &out?
Как тип Init, так и функция параметров &out несовместимы с обратной совместимостью при добавлении к существующим типам и интерфейсам. Это проблема, потому что размещение широко применимо: мы знаем из C++ 17, что практически каждый конструктор хочет быть размещающим. И мы не можем разумно переписать каждую функцию, возвращающую -> T, чтобы вместо этого возвращать -> Init<T> или принимать &out T.
#![allow(unused)] fn main() { // 1. Исходная сигнатура. fn new_cat() -> Cat { ... } // 2. Использование `Init`, меняет сигнатуру. fn new_cat() -> Init<Cat> { ... } // 3. Использование `&out`, меняет сигнатуру. fn new_cat(cat: &out Cat) { ... } // 4. Использование `#[placing]`, сохраняет сигнатуру. #[placing] fn new_cat() -> Cat { ... } }
Это не означает, что эти проекты inherently сломаны или некорректны; совсем наоборот, actually. Но поскольку они, seem to assume другой объем дизайна, это естественно означает, что эти проекты работают с другим набором ограничений дизайна — что, в свою очередь, приводит к другим проектам.
Я считаю, что RFC 2884: Placement by Return от PoignardAzur была правильной идеей. Чтобы Rust был конкурентоспособен с C++, нам нужно гарантировать, что большинство конструкторов могут размещаться. И чтобы сделать это, мы не можем требовать от людей переписывать их код.
Однако в RFC Init есть некоторые great ideas о том, как размещать аргументы функции. На что у RFC 2884 не было хорошего ответа. Я считаю, что сила Rust заключается в его способности distilling различные идеи и синтезировать их в нечто новое. Я считаю, что мы можем прийти к чему-то truly великому, если объединим super let, placement-by-return, Init и обеспечим обратную совместимость.
Могут ли размещающие функции быть вложенными?
Да — размещающие функции, вызываемые в возвращаемой позиции, должны иметь возможность композироваться. Для языковой возможности это должно быть relatively straight-forward при вызове одной размещающей функции внутри другой в возвращаемой позиции:
#![allow(unused)] fn main() { struct Foo {} #[placing] fn inner() -> Foo { Foo {} // ← 1. Конструируется в области видимости вызывающей стороны. } #[placing] fn outer() -> Foo { inner() // ← 2. Передает размещение своей вызывающей стороне. } }
Это само по себе напоминает гарантии гарантированного устранения копий C++, которые композируются через функции. Это потому, что в C++ временные объекты материализуются в реальные объекты только в конце цепочки вызовов, что позволяет произвольно глубокой композиции. Для Rust важно, чтобы мы сохраняли это же свойство, включая случаи обертывания и композиции типов:
#![allow(unused)] fn main() { struct Foo {} #[placing] fn inner() -> Foo { Foo {} // ← 1. Конструируется в области видимости вызывающей стороны. } struct Bar(Foo) #[placing] fn outer() -> Bar { Bar(inner()) // ← 2. Размещает Bar в вызывающей стороне, и Foo внутри Bar. } }
В этом примере Bar конструируется в области видимости вызывающей стороны, и как часть инициализации он вызывает и размещает Foo внутри себя. Я прекратил работу над
Размещение аргументов
— 2025-08-13 Yoshua Wuyts источник
- введение
- размещающие функции
- замыкания
- разрешение путей, зависящее от редакции
Введение
В моём предыдущем посте я представил размещающие функции (placing functions) — функции, которые могут «возвращать» значения без их копирования. Это полезно не только из-за эффективности (меньше копий), но и потому, что это гарантирует стабильность адресов — что необходимо для типов с внутренними заимствованиями (самоссылающимися типами). Напоминание, как выглядят размещающие функции:
#![allow(unused)] fn main() { #[placing] // ← 1. Помечает функцию как "размещающую". fn new_cat(age: u8) -> Cat { // ← 2. Имеет логический возвращаемый тип `Cat`. Cat { age } // ← 3. Конструирует `Cat` во фрейме вызывающего кода. }
Как видите, это обычный код на Rust, с единственным отличием — добавленной аннотацией #[placing]. Основная идея размещающих функций — обратная совместимость. Мы должны иметь возможность постепенно добавлять аннотации #[placing] во всю стандартную библиотеку, подобно тому, как мы делаем это с const. Это важно, потому что мы не хотим добавлять ещё одну ось в язык, которая бы блокировала использование типов, чувствительных к адресу, с существующими трейтами и функциями. Мы уже страдаем от раскола, который ввёл Pin¹, и я не думаю, что нам следует повторять это для самоссылающихся типов.
Размещающие функции
Круто иметь идеалы и видение того, как всё должно быть, но нам всегда нужно сверять их с реальностью. И хотя кажется, что размещающие функции будут работать без проблем (пост о размещающих функциях, которые могут завершиться ошибкой, будет позже), именно размещение аргументов выглядит проблематичным. Напомню, что такое размещающие аргументы: идея в том, что мы могли бы написать следующее определение, и всё бы просто работало без каких-либо копий:
#![allow(unused)] fn main() { impl<T> Box<T> { // `Box::new` здесь принимает тип `T` и // конструирует его на месте в куче fn new(x: #[placing] T) -> Self { ... } } }
В том посте я сделал следующее утверждение (выделенное жирным, потому что оно важно):
Как ключевое ограничение дизайна: вызов функции, принимающей размещающие аргументы, не должен отличаться от вызова обычной функции. Это необходимо для сохранения обратной совместимости API.
Отличная идея, но есть одна маленькая загвоздка: мы не можем этого сделать. И всё потому, что размещающие аргументы по своей сути кодируют семантику обратного вызова. Оливье Фор (Olivier Faure) привёл этот пример, чтобы доказать почему:
#![allow(unused)] fn main() { let x = Box::new({ return 0; 12 }); }
Чтобы сохранить совместимость, мы должны сохранить порядок выполнения. В Rust на сегодня, если вы вызываете это, выражение внутри Box::new будет вычислено до вызова Box::new. В этом случае это означает, что функция вернётся до того, как у Box::new появится шанс выполнить аллокацию.
Если мы применим семантику размещения, это фундаментально изменит порядок. Прежде чем разместить что-либо, нужно создать место. В данном случае это место — в куче, что означает взаимодействие с аллокатором, которое в Rust может вызвать панику. Это значит, что код, который, казалось бы, должен вернуть управление до вызова функции, на самом деле запаникует. И это плохо!
Чтобы проиллюстрировать это ещё лучше, Тейлор Крамер (Taylor Cramer) недавно привёл пример, показывающий, как порядок выполнения также создаёт проблемы и для borrow checker. В этом примере нам нужно удерживать &vec как часть выражения для vec.len, одновременно удерживая &mut vec для vec.push. И это не может работать; даже с view types и абстрактными полями²:
#![allow(unused)] fn main() { vec.push(make_big_thing(vec.len())); // ^^^^^^^^ ^^^^^^^ // | &mut vec | &vec }
Замыкания
Единственный способ, как я вижу, разобраться с этим — это фактически требовать от пользователей использовать здесь замыкания. Это сделает порядок намного понятнее и предотвратит любые сюрпризы с порядком выполнения или заимствованиями. Ральф Юнг (Ralf Jung) заметил, что FnOnce для подобных сценариев идеально кодирует нужную нам семантику, и я согласен. Рассмотрим этот пример:
#![allow(unused)] fn main() { vec.push(|| make_big_thing(vec.len())); }
Это даст следующую ошибку:
#![allow(unused)] fn main() { error[E0502]: cannot borrow `vec` as mutable because it is also borrowed as immutable --> src/main.rs:50:9 | 47 | vec.push(|| make_big_thing(vec.len())); | --- immutable borrow occurs here ... 47 | vec.push(|| make_big_thing(vec.len())); | ^^^ mutable borrow occurs here }
В этой ошибке нет ничего специфичного для размещающих функций, равно как и в её решении:
#![allow(unused)] fn main() { let len = vec.len(); vec.push(|| make_big_thing(len)); }
Но мы не можем просто изменить сигнатуру Vec::push, чтобы она принимала замыкания. Вместо этого нам нужно будет ввести новый метод, например, Vec::push_with, который может принимать замыкание и размещать его результат:
#![allow(unused)] fn main() { impl<T> Vec<T> { pub fn push_with<F>(&mut self, f: F) where F: #[placing] FnOnce() -> T; } }
И это указывает на довольно серьёзную проблему с такими API, потому что, по сути, это прокладывает путь к устареванию Vec::push. Метод Vec::push_with эффективнее, чем Vec::push, и, кроме причин совместимости, не будет причин продолжать использовать Vec::push. Так что люди естественным образом начнут считать Vec::push устаревшим, даже если мы явно не пометим его таковым.
Разрешение путей, зависящее от редакции
Я твёрдо верю, что очевидный выбор должен быть правильным³. Ощущается неправильным ругать людей за использование Box::new, говоря им, что им следует использовать Box::new_with. Или что вместо вызова Hashmap::insert они должны вызывать Hashmap::insert_with. И так далее.
Мой предпочтительный способ решить это — иметь возможность разрешения путей, зависящего от редакции: импорты и символы, которые разрешаются по-разному в зависимости от используемой редакции. Например, на редакции 2024 и ниже люди будут видеть и Vec::push, и Vec::push_with:
#![allow(unused)] fn main() { impl<T> Vec<T> { pub fn push(&mut self, item: T); pub fn push_with(&mut self, f: impl #[placing] FnOnce() -> T); } }
Но на редакции 2024 + 1 мы сможем устарешить Vec::push, переименовать его во что-то другое (например, push_without), а на его место поставить Vec::push_with:
#![allow(unused)] fn main() { impl<T> Vec<T> { // Называется `push_with` на редакциях 2024 и ниже pub fn push(&mut self, f: impl #[placing] FnOnce() -> T); // Называется `push` на редакциях 2024 и ниже #[deprecated(note = "please use `push` instead")] pub fn push_without(&mut self, item: T); } }
Под капотом эти функции всё равно будут разрешаться в одни и те же символы после учёта редакций. Концептуально для этого нужен стабильный, не зависящий от редакции идентификатор, на который библиотеки могут ссылаться в редакционно-специфичной манере:
| Стабильный идентификатор | Редакция 2024 | Редакция 2024 + 1 |
|---|---|---|
::std::vec::Vec::push | Vec::push | Vec::push_without † |
::std::vec::Vec::push_with | Vec::push_with † | Vec::push |
†: Эти имена приведены только для иллюстрации; это не конкретные предложения.
Мы могли бы пойти ещё дальше: в гипотетической редакции 2024 + 2 мы могли бы полностью удалить старый API, так что вы сможете использовать только новый:
#![allow(unused)] fn main() { impl<T> Vec<T> { // Называется `push_with` на редакциях 2024 и ниже; // `push_without` больше недоступен. pub fn push(&mut self, f: impl #[placing] FnOnce() -> T); } }
Основная причина, по которой у нас до сих пор нет такой системы, — это большой объём работы и гарантированное наличие крайних случаев, которые делают это сложнее, чем кажется. Базовый дизайн, который я описываю, уже приходил в голову людям раньше, и не в этом причина, почему его ещё не реализовали.
Однако, насколько полезным ни было бы разрешение путей, зависящее от редакции, в долгосрочной перспективе, оно не нужно с самого начала. Мы можем начать с добавления размещающих вариантов существующих методов в стандартную библиотеку. Этого, вероятно, достаточно для продолжения работы, с пониманием, что разрешение путей, зависящее от редакции, может стать возможным в будущем.
Спасибо Алисе Рихл (Alice Ryhl) за ревью этого поста.
Примечания
-
И нет, к сожалению, эксперимент с «эргономикой Pin» ничего не сделает для решения этой проблемы. Мне жаль, что мы не относимся к этой проблеме серьёзнее, потому что такая несовместимость влияет на язык способами, выходящими далеко за рамки синтаксиса и удобства. ←
-
И
Vec::len, иVec::pushнуждаются в доступе к «длине» вектора. Виртуализация полей / частичные заимствования (partial borrows) этого не изменят. ← -
Хотя и не совсем то же самое, эту же идею «способ по умолчанию должен быть правильным» можно увидеть в практике безопасности «poka-yoke». Или в различии Холнагеля (Hollnagel) между Safety 1 и Safety 2. ←
Асинхронные трейты могут напрямую использовать ручные реализации Future
— 2025-05-26 источник Yoshua Wuyts
- введение
- наивная поддержка AFIT ручными реализациями Future
- прямая поддержка AFIT ручными реализациями Future
- простые встроенные пол-стэйт-машины
- заключение
- благодарности
Введение
Есть интересный факт, о котором, кажется, большинство людей не знает, когда пишет асинхронные функции в трейтах (AFIT): они позволяют напрямую возвращать фьючерсы из методов. Звучит запутанно? Позвольте проиллюстрировать, что я имею в виду, на простом примере. Предположим, у нас есть трейт AsyncIterator с асинхронным методом async fn next, определённым следующим образом:
#![allow(unused)] fn main() { trait AsyncIterator { type Item; async fn next(&mut self) -> Option<Self::Item>; } }
Если бы мы захотели определить итератор, который выдаёт элемент ровно один раз, мы бы, вероятно, написали его так. Он хранит значение T в Option, и при вызове next мы извлекаем его и возвращаем Some(T), если значение есть, и None — если нет:
#![allow(unused)] fn main() { /// Выдаёт элемент ровно один раз pub struct Once<T>(Option<T>); impl<T> AsyncIterator for Once<T> { type Item = T; async fn next(&mut self) -> Option<T> { self.0.take() } } }
Просто, верно? Теперь попробуем написать этот фьючерс вручную. Rust — это язык системного программирования, и важно, чтобы мы могли опускаться на более низкие уровни абстракции для получения дополнительного контроля, когда это нужно. Я считаю пробелом в языке ситуации, когда мы не можем пробиться через абстракцию к её составным частям. Давайте посмотрим, как это будет выглядеть.
Наивная поддержка AFIT ручными реализациями Future
Хорошо, что такое составные части async fn? В основном, это трейт Future. Что мы хотим сделать — это определить свой собственный фьючерс и использовать его в качестве основы нашей реализации. Всё, что мы делаем, — это разыменовываем Option и забираем его внутреннее значение, что делает задачу довольно простой. Наивно мы бы, вероятно, написали что-то вроде этого:
#![allow(unused)] fn main() { /// Внутренняя реализация `Future` для нашего итератора `Once` struct OnceFuture<'a, T>(&'a mut Once<T>); impl<'a, T> Future for Once<'a, T> { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context<'a>) -> Poll<Self::Output> { // SAFETY: мы проецируемся в непроколотое поле let this = unsafe { Pin::into_inner_unchecked(self) }; Poll::Ready((&mut this.0.0).take()) } } /// Выдаёт элемент ровно один раз pub struct Once<T>(Option<T>); impl<T> AsyncIterator for Once<T> { type Item = T; async fn next(&mut self) -> Option<T> { // Делегируем реализации `OnceFuture` OnceFuture(self).await } } }
Внезапно стало много всего нового, правда? Это низкоуровневая версия того, что мы написали ранее. Или нет? Если присмотреться, можно заметить, что здесь мы имеем дело с одним дополнительным уровнем косвенности. В теле нашего итератора мы написали: OnceFuture(self).await. В простом бенчмарке компилятор с радостью это оптимизирует. Но в более сложных программах — может и нет. И это проблема, потому что это означает, что переход на низкоуровневую абстракцию может дать худшую производительность.
Если бы это было лучшее, что мы можем сделать, это, вероятно, означало бы смерть AFIT в стандартной библиотеке. Это бы означало, что AFIT полезны для высокоуровневых API, которые мы можем реализовать, используя async fn, и быть довольными этим. Но не для серьёзных реализаций, которые нужно оптимизировать до конца, таких как в стандартной библиотеке. И это указывало бы нам на вещи вроде трейтов на основе poll_*, которые мы можем реализовывать вручную более непосредственно.
Прямая поддержка AFIT ручными реализациями Future
К счастью, мы можем легко убрать промежуточный вызов .await из нашего предыдущего примера, используя малоизвестную особенность AFIT: возможность напрямую возвращать фьючерсы. Вместо того чтобы писать async fn next, мы можем написать ту же функцию как fn next() -> impl Future. Это почти одно и то же, с небольшими различиями:
#![allow(unused)] fn main() { /// Внутренняя реализация `Future` для нашего итератора `Once` struct OnceFuture<'a, T>(&'a mut Once<T>); impl<'a, T> Future for Once<'a, T> { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context<'a>) -> Poll<Self::Output> { // SAFETY: мы проецируемся в непроколотое поле let this = unsafe { Pin::into_inner_unchecked(self) }; Poll::Ready((&mut this.0.0).take()) } } /// Выдаёт элемент ровно один раз pub struct Once<T>(Option<T>); impl<T> AsyncIterator for Once<T> { type Item = T; // Напрямую возвращает реализацию `OnceFuture` fn next(&mut self) -> impl Future<Output = Option<T>> { OnceFuture(self) // ← больше нет `.await` } } }
Видите реализацию fn next? Теперь она напрямую возвращает OnceFuture. И мы можем это делать, даже несмотря на то, что определение трейта явно определяло метод трейта как async fn next. Это значит, что для того, чтобы основать наш трейт на основе AFIT на ручной реализации фьючерса, не требуется промежуточного вызова .await. А это значит, что мы больше не зависим от компилятора в оптимизации этого для достижения эквивалентной производительности, соответствуя нашим целям возможности вручную разобрать высокоуровневую нотацию на составные части, которыми мы можем управлять вручную.
Простые встроенные пол-стэйт-машины
Стандартная библиотека Rust предоставляет удобную функцию future::poll_fn, которая позволяет создавать stateless фьючерсы (т.е. не хранящие собственное состояние между вызовами poll). Но поскольку она принимает замыкание FnMut, оно может ссылаться на внешнее состояние, доступное ему через upvars. Это позволяет нам переписать громоздкую ручную реализацию фьючерса из наших последних двух примеров в виде изящного встроенного вызова poll_fn:
#![allow(unused)] fn main() { /// Выдаёт элемент ровно один раз pub struct Once<T>(Option<T>); impl<T> AsyncIterator for Once<T> { type Item = T; fn next(&mut self) -> impl Future<Output = Option<T>> { future::poll_fn(|_cx| /* -> Poll<Option<T>> */ { Poll::Ready((&mut self.value).take()) }) } } }
Это невероятно удобно, потому что позволяет быстро писать оптимизированный встроенный код стейт-машин на основе poll внутри реализаций трейтов. Или, в общем-то, в любом асинхронном контексте. Мне нравится думать о poll_fn как о способе быстро перейти в «низкоуровневый асинхронный» режим, вроде того как unsafe {} позволяет перейти в «режим работы с сырой памятью».
Это также чётко делает трейт Future фундаментальным строительным блоком всего асинхронного кода. Это более простая и надёжная модель, чем альтернативная модель fn poll_*; в рамках которой асинхронные операции могут быть реализованы либо через Future::poll, AsyncIterator::poll_next, AsyncRead::poll_read и так далее.
Заключение
Я написал этот пост потому, что большинство людей, кажется, не знают, что AFIT можно использовать таким образом. Хотя, возможно, это одна из их самых важных возможностей. И это важно, потому что, как я сказал ранее в этом посте: было бы очень плохо, если бы пространство асинхронных трейтов разделилось на:
- Трейты на основе AFIT: которые удобно реализовывать, но имеют худшую производительность из-за отсутствия контроля.
- Трейты на основе
poll: которые неудобно реализовывать, но имеют лучшую производительность из-за предоставляемого контроля.
Этот пост показывает, что трейты на основе AFIT одновременно удобны в реализации и, при необходимости, предоставляют контроль, требуемый для гарантии производительности через ручные реализации пол-стейт-машин. Это унифицирует дизайн-пространство для асинхронных трейтов, убирая выбор между «удобным API» и «быстрым API». С AFIT удобный API — это быстрый API.
Хотя в этом посте в качестве примера мы использовали трейт AsyncIterator, ничто изложенное здесь не является специфичным для AsyncIterator. Возможность контролировать стейт-машины фьючерсов для AsyncRead, AsyncWrite и так далее не менее важна. Но если мы рассмотрим AsyncIterator на основе AFIT в сочетании с функцией async gen {}, мы увидим, что он предоставляет в общей сложности три уровня контроля, тогда как трейт AsyncIterator на основе poll предоставляет только два1:
| На основе AFIT | На основе poll_* | |
|---|---|---|
async gen {} | ✅ | ✅ |
async fn | ✅ | ❌ |
fn пол-стейт-машина | ✅ | ✅ |
И все три уровня абстракции важны для удобной реализации. Было бы неправильно выбирать только некоторые. Возможность реализовывать асинхронные трейты на основе async fn важна в случаях, когда высокоуровневая конструкция вроде gen {} недоступна, как с AsyncRead и AsyncWrite. Но также важно, чтобы для AsyncIterator можно было реализовывать подтрейты вроде ExactSizeIterator и можно было реализовывать предоставленные методы вроде fn size_hint. async gen {} не предоставляет возможности делать ни то, ни другое2, и пользователей не следует заставлять писать пол-стейт-машины только ради этого.
Благодарности
Я хотел бы поблагодарить Оли Шерера (Oli Scherer) за то, что он в конце прошлого года нашёл время поработать со мной над серией примеров AFIT, использующих ручные реализации фьючерсов, и объяснить, как они разрешаются и оптимизируются компилятором. Этот разговор дал мне уверенность делать утверждения в этом посте со степенью определённости, которой у меня иначе не было. Однако Оли не рецензировал этот пост заранее, так что я определённо не говорю от его имени, и любые ошибки в этом посте будут моими собственными.
Примечания
-
Уверен, что с достаточным количеством языковой магии мы могли бы предоставить какой-то способ, позволяющий реализовывать трейты на основе
fn poll_*черезasync fn. Но для этого потребовалась бы совершенно новая языковая функция, и всё только для того, чтобы в итоге получить менее согласованную и более сложную модель, чем если бы мы напрямую основали все асинхронные трейты (кромеFuture) наasync fn. ↩ -
Споры о возможностях (
capability-based) в стороне, я также считаю, что реализация трейтов должна быть лёгкой. То есть, что значит, реализация неблокирующего итератора вручную требует докторской степени по Rustology? Rust призван делать системное программирование доступным и понятным. И это должно применяться на каждом уровне абстракции. ↩
Проблема с аллокацией Waker
— 2025-05-23 Yoshua Wuyts источник
- введение
- эффективное конкурентное выполнение требует промежуточных Waker'ов
- промежуточные Waker'ы требуют аллокаций
- заключение
Введение
Весь смысл фьючерсов и системы async/.await в Rust — это введение двух новых возможностей: произвольного конкурентного выполнения вычислений и произвольной отмены вычислений. Разница между блокирующими и неблокирующими вычислениями не важна, если мы затем не используем её для планирования работы параллельно.
Но есть проблема — чтобы планировать работу параллельно, фьючерсы имеют два плохих варианта организации своей внутренней работы:
- Без аллокаций у фьючерсов поведение пробуждения дочерних фьючерсов имеет сложность O(N²) (квадратичную). Это означает, что если мы запланируем 1_000 фьючерсов, которые все пробуждаются независимо и последовательно, мы получим приблизительно 1_000_000 общих пробуждений1.
- С аллокациями фьючерсы могут иметь поведение пробуждения дочерних фьючерсов со сложностью O(N). Это означает, что если мы запланируем 1_000 фьючерсов, которые все завершаются независимо и последовательно, у нас будет 1_000 общих пробуждений.
В Rust мы хорошо знаем, что скрытые аллокации — это плохо. Но мы также знаем, что квадратичный рост при потенциально неограниченном количестве элементов — это тоже плохо. И плохо то, что мы оказались в ситуации, где должны выбирать между двумя плохими вариантами.
Эффективное конкурентное выполнение требует промежуточных Waker'ов
Допустим, мы пишем вручную функцию future::join, которая принимает массив impl Future и возвращает массив Future::Output. Мы можем смоделировать внутреннее состояние, используя массив фьючерсов и массив их выходных данных:
#![allow(unused)] fn main() { /// Фьючерс, который конкурентно ожидает список фьючерсов struct Join<Fut: Future, const N: usize> { /// Фьючерсы, которые мы ожидаем. futures: [Fut; N], /// Выходные данные фьючерсов. outputs: [Option<Fut::Output>; N], } }
Чтобы конкурентно ожидать все фьючерсы, мы должны реализовать трейт Future для Join. Его выходной тип — [Future::Output; N], и каждый вызов poll должен перебирать каждый ожидающий внутренний фьючерс. Вот наивный пример реализации, в основном просто для контекста:
#![allow(unused)] fn main() { impl<Fut: Future, const N: usize> Future for Join<Fut, N> { type Output = [Fut::Output; N]; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { // Настройка начального состояния let this = unsafe { self.get_unchecked_mut() }; let mut all_done = true; // Перебираем каждый фьючерс, пока не получим все // значения или пока у нас остаются незавершённые фьючерсы. for i in 0..this.futures.len() { // Либо фьючерс уже завершён... if this.outputs[i].is_some() { continue; } // ...либо нам всё ещё нужно его опросить. let fut = unsafe { Pin::new_unchecked(&mut this.futures[i]) }; match fut.poll(cx) { Poll::Ready(output) => this.outputs[i] = Some(output), Poll::Pending => all_done = false, } } // Цикл завершён, пора посмотреть на наши // результаты. Либо у нас всё ещё есть незавершённые фьючерсы... if !all_done { return Poll::Pending; } // ...либо всё завершено, и мы готовы вернуть результат. let outputs = std::mem::replace(&mut this.outputs, [None; N]) .map(Option::unwrap); Poll::Ready(outputs) } } }
Если посмотреть на суть этого цикла, можно заметить, что мы перебираем каждый фьючерс на каждой итерации — независимо от того, вызывал ли он свой соответствующий waker или нет. Единственные фьючерсы, которые мы не вызываем, — те, чей вывод мы уже получили. Практически это означает, что мы приближаемся к выполнению O(N²): мы пробуждаем каждый фьючерс при каждом вызове poll. И мы делаем это, потому что не знаем, какие фьючерсы готовы, а какие нет.
Решение здесь — создать «промежуточный» или «встроенный» waker. Вместо того чтобы передавать waker вызывающего кода во все дочерние фьючерсы, фьючерс Join должен создать свой собственный waker(ы), который может отслеживать, какие дочерние фьючерсы готовы, — и опрашивать только их. Самый простой способ сделать это — хранить массив waker'ов и массив «готовых» индексов. Всякий раз, когда дочерний фьючерс вызывает wake, он добавляет свой индекс в список готовых индексов, а затем вызывает родительский waker. Затем, когда опрашивается фьючерс Join, всё, что ему нужно сделать, — это перебрать все готовые индексы и опросить соответствующие фьючерсы. Для краткости я не буду показывать полную реализацию, только само определение:
#![allow(unused)] fn main() { /// Фьючерс, который конкурентно ожидает список фьючерсов struct Join<Fut: Future, const N: usize> { /// Фьючерсы, которые мы ожидаем. futures: [Fut; N], /// Выходные данные фьючерсов. outputs: [Option<Fut::Output>; N], /// Хранит по одному waker'у для каждого дочернего фьючерса wakers: [ChildWaker; N], /// Отслеживает, какие дочерние фьючерсы должны быть пробуждены indexes: Arc<[AtomicBool; N]>, } /// Waker, связанный с конкретным дочерним фьючерсом struct ChildWaker { /// Индекс дочернего фьючерса в списке индексов index: usize, /// Ссылка на родительский waker, переданный в структуру parent_waker: Waker, /// Отслеживает, какие дочерние фьючерсы должны быть пробуждены. /// Предназначено для индексирования с помощью `index` внутри `Wake`. indexes: Arc<[AtomicBool; N]>, } impl Wake for InlineWaker { ... } }
Существуют более эффективные схемы, чем эта, хотя она одна из простейших. И что критически важно: это позволяет нам пробуждать только те фьючерсы, которые нуждаются в пробуждении. Это важно, поскольку пробуждение фьючерсов потенциально может быть дорогой операцией. И хотя мы можем поощрять создание фьючерсов, которые дёшево пробуждать спонтанно, — нам действительно не стоит вызывать лишние пробуждения с самого начала. Потому что лучше, если это бремя ляжет на экспертов по async, пишущих примитивы, чем на каждого отдельного человека, реализующего трейт Future2.
Промежуточные Waker'ы требуют аллокаций
Теперь, когда мы рассмотрели, почему фьючерсы хотят использовать промежуточные waker'ы, давайте внимательнее посмотрим на их внутреннее устройство. Трейт Future опирается на тип Waker, который можно рассматривать как указатель Arc<Fn>. Где Arc можно рассматривать как улучшенный тип Box<T>, который можно свободно клонировать и который предоставляет общий (неизменяемый) доступ к своим внутренностям. Вот как можно определить новый Waker, который будет разблокировать текущий поток при вызове:
#![allow(unused)] fn main() { use std::sync::Arc; use std::task::{Context, Wake}; use std::thread::{self, Thread}; use std::pin::pin; /// Waker, который пробуждает текущий поток при вызове. struct ThreadWaker(Thread); impl Wake for ThreadWaker { fn wake(self: Arc<Self>) { self.0.unpark(); } } }
А вот как создать экземпляр этого же waker'а. Это опирается на реализацию From<Arc<Wake + Send + Sync + 'static>> для Waker:
#![allow(unused)] fn main() { /// Выполняет фьючерс до завершения в текущем потоке. fn block_on<T>(fut: impl Future<Output = T>) -> T { let mut fut = pin!(fut); // Создаём экземпляр `ThreadWaker` let t = thread::current(); let waker = Arc::new(ThreadWaker(t)).into(); // ← Это аллокация // ... }
Вот именно, создание экземпляра с помощью Arc::new означает, что мы выделяем память. В теории нам не обязательно использовать Arc и для этого. Тип Waker требует только, чтобы он оборачивал RawWakerVTable способом, который является Send + Sync + Clone. Это значит, что теоретически мы могли бы где-то иметь статическую переменную с внутренней изменяемостью, и, возможно, некоторые (встроенные) системы полагаются на это для своих глобальных рантаймов. Но на практике для встроенных конкурентных операций, если мы собираемся создавать Waker, он должен будет быть основан на Arc.
Заключение
Тот факт, что это практически требует аллокаций, — не единственная проблема: в однопоточных системах Waker всё равно требует ограничений + Send + Sync. Даже если бы мы в итоге добавили тип LocalWaker, исходный тип Waker всё равно должен присутствовать. Это означает, что мы не можем практически писать переносимые асинхронные библиотеки !Send, потому что это ограничение никогда не появится в системе типов3.
Причина, по которой у нас есть Arc<Fn> с самого начала, в том, что дизайн фьючерсов предполагает, что мы можем захотеть обернуть произвольные вычисления в waker'ы. Но я не уверен, насколько это верно. Например, в библиотеке futures-concurrency — наши waker'ы выполняют только операцию, эквивалентную переключению атомарного булевого значения (src). Если бы мы вручную писали свою собственную реализацию напрямую, например, против API epoll, нам не пришлось бы платить такую цену. То, что мы рассмотрели в этом посте, — чисто артефакт дизайна трейта Future.
Необходимость выбирать между алгоритмической сложностью и аллокациями — также причина, по которой я ещё не предложил перенести API futures-concurrency в стандартную библиотеку. В стандартной библиотеке Rust есть принцип дизайна, запрещающий скрытые аллокации. Но на работе мы также знаем, что нам нужно защищаться от неправильного использования API. Это означает, что мы, скорее всего, столкнёмся со случаями, когда использование API stdlib на самом деле не рекомендуется. И сложно оправдать перенос в основную ветку чего-то, от использования чего мы, возможно, сразу захотим предостеречь.
Это также не единственная проблема с дизайном трейта Future. Я подробно говорил о проблемах, связанных с API Pin. И в этом посте я также затронул проблему !Send Waker'а. На мой взгляд, у трейта Future есть семь таких проблем, которые я постараюсь описать в будущих постах.
Примечания
-
Это не просто гипотетическая ситуация — это проблема, с которой мы столкнулись в Microsoft. Мы не можем разумно ожидать, что каждый человек, реализующий трейт
Future, будет экспертом по async. И поэтому нам нужны примитивы, которые не просто перекладывают ответственность за производительность на отдельных пользователей. ↩ -
Если быть точным — вероятно, около половины, поскольку фьючерсы, которые уже завершились, больше не опрашиваются. ↩
-
Надеюсь, мне не нужно объяснять, что наличие
Send, выраженного в системе типов, — это хорошая вещь. ↩
Открытые и закрытые системы эффектов
— 2025-05-22Yoshua Wuyts Источник
Обсуждая системы эффектов в контексте производственных языков программирования, я считаю, что самое важное различие — это определены ли эффекты исключительно языком или пользователи также могут их определять. Когда Магнус Мадсен (Magnus Madsen) из языка программирования Flix присоединился к звонку Rust T-Lang на две сессии в прошлом декабре, он использовал термины «открытые» и «закрытые», чтобы описать это различие:
- Закрытые системы эффектов: это системы, в которых все эффекты определены языком как встроенные. Количество эффектов может со временем расти, но эффекты определяются только самим языком. Это делает количество возможных эффектов ограниченным.
- Открытые системы эффектов: позволяют пользователям языка также определять свои собственные эффекты управления потоком, которые существуют наряду со встроенными эффектами языка. Это делает количество возможных эффектов неограниченным.
Самые интересные эффекты в языках программирования должны быть встроенными. Сюда входят такие вещи, как контроль того, гарантирует ли функция своё завершение («расходимость»), или разрешено ли функции выполнять побочные эффекты («чистота»).
Это отличается от пользовательских эффектов. Обычно они определяются с помощью «обработчиков алгебраических эффектов», но я также встречал использование термина «типизированные продолжения»1. Я думаю о них более или менее как о поддержке на уровне языка для тщательно типизированных итераторов/генераторов/сопрограмм. Не имеет особого смысла выражать «расходимость» с помощью итераторов. Вот почему, хотя все обработчики эффектов являются эффектами, не все эффекты выражаются с помощью обработчиков эффектов.
В контексте Rust всё, что мы обсуждаем сейчас, — это введение закрытой системы эффектов. И это не потому, что мы считаем, что открытая система не была бы полезной или хорошей. А потому, что формализация и обобщение системы эффектов Rust обратно-совместимым образом сама по себе представляет собой невероятный объём работы. И чтобы выпустить её, мы должны где-то провести черту2.
Примечания
-
Я считаю, что между двумя терминами есть разница, но не совсем помню её. Однако я нахожу термин «типизированные продолжения» гораздо более выразительным. Выходец из Node.js 0.8, я думаю об этом примерно как о «что, если бы все обратные вызовы были полностью типизированы и у нас был компилятор, гарантирующий их обработку». Возможно, синтаксически не то же самое, но я думаю, что семантически это довольно близко. ← ↩
-
Хотя это не означает, что мы не можем оставить синтаксическое пространство открытым для рассмотрения этого позже. Тот факт, что мы говорим «не сейчас», не означает, что мы говорим «никогда». ← ↩
Автоматическое чередование высокоуровневых конкурентных операций
— 2025-05-05 Yoshua Wuyts Источник
- введение
- проблема
- решение
- дальнейшее чтение
Введение
При работе с низкоуровневым параллелизмом (атомарными операциями) языки программирования обычно очень стремятся позволить компиляторам переупорядочивать операции, если это приводит к лучшей производительности. Информация о том, допустимо ли переупорядочивать операции, кодируется с помощью Моделей памяти (Atomic Orderings), Барьеров памяти (Fences) и Атомарных операций (Operations). Странно, что большинство языков программирования, поддерживающих семантически осознанное переупорядочивание низкоуровневых конкурентных операций, не включают аналогичную поддержку для переупорядочивания выполнения высокоуровневых конкурентных операций.
Насколько мне известно, это верно для большинства языков, за заметным исключением Swift и его конструкции async let. Эта функция сохраняет линейно выглядящую природу асинхронного кода, но позволяет компилятору анализировать граф потока управления и планировать операции конкурентно, где это возможно. Это означает, что, как и с атомарными операциями, операция, определённая позже в куске кода, может завершить выполнение раньше операции, которая появляется раньше. Вот пример программы на Swift, где всё, что может быть конкурентным, на самом деле является конкурентным:
func makeDinner() async throws -> Meal {
async let veggies = chopVegetables() // 1. конкурентно с: 2, 3
async let tofu = marinateTofu() // 2. конкурентно с: 1, 3
async let oven = preheatOven(temperature: 350) // 3. конкурентно с: 1, 2, 4
let dish = Dish(ingredients: await [try veggies, tofu]) // 4. зависит от: 1, 2, конкурентно с: 3
return await oven.cook(dish, duration: .hours(3)) // 5. зависит от: 3, 4, не конкурентно
}
Проблема
Для меня это представляет вершину поддержки асинхронного/конкурентного программирования на уровне языка. Это делает тривиальным изменение любого кода, который может быть выполнен конкурентно, чтобы он действительно выполнялся конкурентно. Это позволяет компилятору позаботиться о том, что в противном случае было бы утомительным и/или нечитаемым. Возьмём, к примеру, этот код, написанный последовательным образом с использованием async/.await:
#![allow(unused)] fn main() { async fn make_dinner() -> SomeResult<Meal> { let veggies = chop_vegetables().await?; let tofu = marinate_tofu().await?; let oven = preheat_oven(350).await; let dish = Dish(&[veggies, tofu]).await; oven.cook(dish, Duration::from_mins(3 * 60)).await } }
Используя операции Future::join, мы можем переписать его для конкурентного выполнения независимых операций. Но это имеет тот недостаток, что код теперь значительно менее читаем. Вот тот же код, написанный с использованием Future::try_join:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; async fn make_dinner() -> SomeResult<Meal> { let dish_fut = { let veggies_fut = chop_vegetables(); let tofu_fut = marinate_tofu(); let (veggies, tofu) = (veggies_fut, tofu_fut).try_join().await?; Dish::new(&[veggies, tofu]).await }; let oven_fut = preheat_oven(350); let (dish, oven) = (dish_fut, oven_fut).try_join().await?; oven.cook(dish, Duration::from_mins(3 * 60)).await } }
Чтобы воспользоваться одним из ключевых преимуществ async/.await (ad-hoc конкурентное планирование), нам пришлось пожертвовать одним из его главных достоинств (читаемостью). Это плохо, когда две основные части одной и той же функции противоречат друг другу таким образом. И мы не можем просто взмахнуть волшебной палочкой и приказать компилятору автоматически выполнять эти фьючерсы конкурентно. Фьючерсы, как правило, выражают операции, которые так или иначе изменяют состояние программы. Другими словами: большинство фьючерсов кодируют побочные эффекты. И компилятор не может автоматически вывести, какие побочные эффекты можно выполнять последовательно, а какие — конкурентно. Это потому, что он не знает семантику программы.
Решение
Решение состоит в том, чтобы позволить программистам явно соглашаться на переупорядочивания в своём коде, как это делает Swift с помощью async let. Мы могли бы использовать краткую нотацию, например, .co.await (это пример, выберите свою любимую нотацию). Мы хотим, чтобы нотация была в постфиксной позиции, потому что, в отличие от Swift, мы не хотим начинать выполнение при определении операций, а только влиять на способ планирования операций при использовании .await. И таким образом нам никогда не придётся представлять это в системе типов1. Это выглядело бы примерно так:
#![allow(unused)] fn main() { async fn make_dinner() -> SomeResult<Meal> { let veggies = chop_vegetables().co.await?; let tofu = marinate_tofu().co.await?; let oven = preheat_oven(350).co.await; let dish = Dish(&[veggies, tofu]).co.await; oven.cook(dish, Duration::from_mins(3 * 60)).await } }
Этот код напрямую преобразовывался бы в эквивалент схемы на основе Future::join. Но с преимуществом гораздо меньшей церемонии для кодирования той же семантики. Другое преимущество этой схемы в том, что у нас всегда остаётся возможность планировать эти операции последовательно, если мы выберем это. Это делает схему совместимой с асинхронно-полиморфными функциями, в отличие от ручных вызовов Future::join.
Функциональность в этом духе важна, потому что для полного использования возможностей конкурентного планирования в асинхронном Rust любые операции, которые могут быть выполнены конкурентно, должны выполняться конкурентно. Но без поддержки на уровне языка это происходит за счёт серьёзного ухудшения читаемости и, как следствие, сопровождаемости. Единственный выход из этой дилеммы — сделать то, что сделал Swift, и напрямую включить поддержку этого на уровне языка.
Дальнейшее чтение
- Задачи — это неправильная абстракция — представляет трейт
ParallelFuture, который можно комбинировать со схемой, описанной в этом посте, для автоматического планирования фьючерсов на нескольких ядрах. - Древовидная структурированная конкурентность — обсуждает, что такое структурированная конкурентность, как её осмыслять и чего не хватает Rust для её полной поддержки.
- Расширение системы эффектов Rust — обсуждает, среди прочего, асинхронно-полиморфные функции.
Примечания
-
Написание
.coбез последующего.awaitдолжно быть ошибкой компиляции..coслужило бы модификатором для.await. Хотя, возможно, что-то вроде нотации C++co_awaitпроще. Какой бы ни был синтаксис, я не думаю, что он должен когда-либо появляться в системе типов. ← ↩
Синтаксические размышления о выражениях match
— 2025-04-29 Yoshua Wuyts Источник
- введение
- логическое И
- логическое ИЛИ
- порядок вычисления в if let
- заключительные слова
Введение
Одна из вещей, которая меня поражает, — это насколько семантически похожи match и if..else, при этом довольно сильно расходясь синтаксически. Причины этого кажутся в основном случайными, и у меня есть смутное подозрение, что мы могли бы немного облегчить жизнь, сделав обе конструкции более похожими.
В этом посте я буду показывать примеры управления потоком на основе очереди, которая может быть либо онлайн, либо офлайн. Это определяется перечислением QueueState, которое хранит длину очереди как u32:
#![allow(unused)] fn main() { enum QueueState { Online(u32), Offline(u32), } }
В зависимости от того, активно ли добавляются новые данные в очередь (online), мы захотим по-разному рассуждать о пределах очереди (u32). Мы также хотим изменить поведение в зависимости от того, полна очередь или нет, и для этого нам нужно будет вызвать функцию:
#![allow(unused)] fn main() { fn is_full(n: u32) -> bool { .. } }
Логическое И
Возьмём скромный if-guard (охранник). Мы можем использовать его в ветках match для объединения условий, что полезно, когда мы хотим что-то сделать со значением, содержащимся внутри структуры. Здесь метод is_full возвращает bool. Но чтобы вызвать его, нам сначала нужно получить доступ к числу, содержащемуся в варианте Online:
#![allow(unused)] fn main() { match state { QueueState::Online(count) if !is_full(count) => { // ^^^^^^^^^^^^^^^^^^ // if-guard println!("queue is online and not full"); } _ => println!("queue is in an unknown state"), } }
if-guards — это по сути просто логическое И. В выражениях match мы находимся в процессе добавления цепочек if let в Rust посредством RFC 2497 (стабильно в Rust 1.88). С этим мы сможем записать это же условие match следующим образом:
#![allow(unused)] fn main() { if let QueueState::Online(count) = state && !is_full(count) { // ^^^^^^^^^^^^^^^^^^ // && chaining println!("queue is online and not full"); } else { println!("queue is in an unknown state"); } }
Это, по сути, одна и та же возможность, представленная двумя разными способами. Вместо этого мне бы очень хотелось иметь возможность использовать && в обоих случаях, сблизив их:
#![allow(unused)] fn main() { match state { QueueState::Online(count) && !is_full(count) => { // ^^^^^^^^^^^^^^^^^^ // && chaining println!("queue is online and not full"); } _ => println!("queue is in an unknown state"), } }
Я могу гарантировать, что почти все, кто сталкивался с языком, имеющим C-подобный синтаксис (а это почти все программисты), без труда поймут, как это работает. if-guards, для сравнения, обычно труднее интуитивно понять.
Логическое ИЛИ
RFC 3637 представляет функцию «guard patterns»: расширение нотации паттернов Rust, которое выносит if-guards из выражений match во все паттерны. Функционально это даёт нам способ объединять булевы выражения И с логическими выражениями ИЛИ во всех паттернах. Скажем, мы взяли наш предыдущий пример и хотим вывести сообщение, если is_full вернул false, независимо от того, онлайн очередь или офлайн. Используя цепочки if..let, мы бы написали это так:
#![allow(unused)] fn main() { if let QueueState::Online(count) = state && !is_full(count) { println!("queue is online and not full"); } else if let QueueState::Online(count) = state && is_full(count) { println!("queue is full"); // 1. Это утверждение... } else if let QueueState::Offline(count) = state && is_full(count) { println!("queue is full"); // 2. ...дублируется здесь. } else { println!("queue is in an unknown state"); } }
Это немного раздражает, потому что в идеале мы бы хотели, чтобы второе и третье условия были одним большим условием, разделённым логическим ИЛИ. Guard patterns — это как раз функция, которая позволит нам сделать именно это, добавив if-guards и цепочки паттернов на основе ИЛИ в выражения. С ней мы перепишем это так:
#![allow(unused)] fn main() { if let QueueState::Online(count) if !is_full(count) = state { // ^^^^^^^^^^^^^^^^^^ // теперь используем if-guard, для разнообразия println!("queue is online and not full"); } else if let ((QueueState::Online(count) if is_full(count) = state) | // логическое ИЛИ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // Либо совпадает с паттерном 1... (QueueState::Offline(count) if is_full(count) = state) // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // ...либо с паттерном 2. { println!("queue is full"); // Больше никакого дублирования! } else { println!("queue is in an unknown state"); } }
Здесь много всего происходит. Во-первых, это делает if-guards допустимыми в выражениях if..let. А также позволяет использовать несколько паттернов в одном условии с семантикой ИЛИ, если они соответствующим образом заключены в скобки. Продолжим и используем это с выражением match:
#![allow(unused)] fn main() { match state { QueueState::Online(count) if !is_full(count) => { println!("queue is online and not full"); } ((QueueState::Online(count) if is_full(count)) | (QueueState::Offline(count) if is_full(count))) => { println!("queue is full"); // Больше никакого дублирования! } _ => println!("queue is in an unknown state"), } }
Это намного лучше, поскольку поток понятнее. Не повторяя явное = state в каждой строке, мы теперь можем читать все утверждения слева направо. Это хорошо. Нам это нравится. Перенос из версии с if..let в версию с match также был очень простым. Нам вообще не пришлось менять условия if..let, за исключением обрезки их концов. Это хорошо!
Однако что менее хорошо, так это то, что мы добились этого, усложнив выражения if..else. Взаимодействие цепочек if..let из RFC 2497 и guard patterns из RFC 3637 кажется особенно способным застать людей врасплох. Рассмотрим, например, эту строку:
#![allow(unused)] fn main() { if let Foo::Bar(x) if cond(x) = bar() && let Bin::Baz = baz() { .. } }
Порядок, в котором вычисляются выражения в этой строке, здесь 2-3-1-5-4. Это означает, что, вероятно, нецелесообразно смешивать обе функции. Причина, по которой эти взаимодействия таковы, заключается в том, что оба RFC исходят из принципиально противоположных предпосылок:
- RFC 2497 считает, что мы должны позволить булевым операторам, таким как
&&, работать в большем количестве мест. - RFC 3637 считает, что мы должны позволить операторам, специфичным для
match, таким как if-guards, работать в большем количестве мест.
В предыдущем разделе мы уже рассмотрели замену if-guards оператором &&, вдохновлённую цепочками if..let. Недалеко ушла идея использовать оператор || в цепочках if..let. Это выглядело бы примерно так:
#![allow(unused)] fn main() { if let QueueState::Online(count) = state && !is_full(count) { // ^^^^^^^^^^^^^^^^^^ // if-let цепочка println!("queue is online and not full"); } else if let QueueState::Online(count) = state && is_full(count) || // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // Либо совпадает с паттерном 1... let QueueState::Offline(count) = state && is_full(count) // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // ...либо с паттерном 2. { println!("queue is full"); // Больше никакого дублирования! } else { println!("queue is in an unknown state"); } }
Это та же функциональность, что и раньше, но предоставленная как естественное расширение цепочек if..let на основе &&. Теперь снова попробуем переписать это как выражение match:
#![allow(unused)] fn main() { match state { QueueState::Online(count) && !is_full(count) => { println!("queue is online and not full"); } QueueState::Online(count) && is_full(count) || // <- логическое ИЛИ! QueueState::Offline(count) && is_full(count) => { println!("queue is full"); } _ => println!("queue is in an unknown state"), } }
Что здесь замечательно, так это то, что поскольку логическое И (&&) имеет приоритет над логическим ИЛИ (||) (ссылка), нам не нужно прибегать к обёртыванию наших паттернов, чтобы объединить их в цепочку. О, и, конечно, также приятно, что почти любой программист, независимо от знакомства с Rust, должен быть способен понять, что здесь происходит.
Но что ещё важнее: посмотрите на это! Это код, который я хочу писать. Он не пытается быть умным или изобретать что-то совершенно новое. Его определяющая черта — просто то, насколько обычным он выглядит. И это действительно соответствует моему чувству того, каким должен быть язык общего назначения.
Порядок вычисления в if let
Мне не нравится читать операторы if-let, потому что они меняют порядок чтения справа-налево. Мне вдвойне не нравится читать операторы if-let, когда они объединены в цепочки, потому что их нужно читать центр-лево-право. Мне нравится оператор is, потому что он позволяет нам это исправить. Позвольте показать вам, как это сделать, взяв наш последний пример с if let..else, убрав все комментарии:
#![allow(unused)] fn main() { if let QueueState::Online(count) = state && !is_full(count) { println!("queue is online and not full"); } else if let QueueState::Online(count) = state && is_full(count) || let QueueState::Offline(count) = state && is_full(count) { println!("queue is full"); } else { println!("queue is in an unknown state"); } }
Насколько мне ни нравятся здесь булевы операторы, мне не нравится порядок, в котором вычисляются утверждения. Первая строка в нашем примере выше вычисляется следующим образом:
#![allow(unused)] fn main() { if let QueueState::Online(cond) = state && !is_full(count) { .. } // ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^ ^^^^^^^^^^^^^^^ // | | | // | это вычисляется первым | // | | // это вычисляется вторым | // | // это вычисляется последним }
RFC 3573 предлагает добавить в язык оператор is, вдохновлённый одноимённой функцией в C#. Это исправит порядок вычисления, заставив тот же код читаться сверху вниз, слева направо:
#![allow(unused)] fn main() { if state is QueueState::Online(count) && !is_full(count) { println!("queue is online and not full"); } else if state is QueueState::Online(count) && is_full(count) || state is QueueState::Offline(count) && is_full(count) { println!("queue is full"); } else { println!("queue is in an unknown state"); } }
Для меня это Rust таким, каким он должен быть. Смысл всегда заключался в том, чтобы создать язык, максимально практичный в использовании, без ущерба для корректности, производительности и контроля. Сделать порядок вычислений более простым для понимания кажется вполне соответствующим этой цели. Вот та же строка с аннотацией, но с использованием нотации is:
#![allow(unused)] fn main() { if state is QueueState::Online(cond) && !is_full(count) { .. } // ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^ // | | | // это вычисляется первым | | // | | // это вычисляется вторым | // | // это вычисляется последним }
Это также даёт дополнительное преимущество: рефакторинг кода из формы if..else в форму match даёт невероятно маленькую разницу. В основном это тот же код, но с обрезанным началом каждого условия:
#![allow(unused)] fn main() { match state { QueueState::Online(count) && !is_full(count) => { println!("queue is online and not full"); } QueueState::Online(count) && is_full(count) || QueueState::Offline(count) && is_full(count) => { println!("queue is full"); } _ => println!("queue is in an unknown state"), } }
Это позволило бы объяснять условное управление потоком более постепенно. Я вижу потенциал для создания небольшой дидактической лестницы на знаменитом крутом пути обучения Rust, которая выглядела бы примерно так:
- Начните с представления
if..else, который большинство программистов уже знают. - Затем представьте
isкак более мощную версиюtypeofв других языках. - Наконец, представьте
matchкак более эргономичную версиюif..else+is. Какswitchв других языках, но с исчерпывающей проверкой всех случаев.
Мы никогда не отнимаем никакие концепции и не делаем больших изменений в нотации. Каждая концепция аккуратно строится на предыдущей, и нам не нужно ждать, чтобы представить if-let гораздо позже, как мы делаем сейчас.
Заключительные слова
Ещё одна вещь, о которой я не был до конца уверен, когда говорить в контексте RFC guard patterns, — это его взаимодействие с типами паттернов. Внесение if-guards в паттерны кажется, что меняет нотацию паттернов с довольно управляемого/ограниченного подмножества, которое мы можем оценить за разумное время, в систему для произвольных предусловий/постусловий. Если, конечно, мы не запретим такие паттерны в типах паттернов — что создаст расходящиеся нотации паттернов.
В целом, я чувствую, что языку есть много чего выиграть, сделав выражения match более согласованными с остальными условными выражениями. После написания основной части этого поста я вспомнил, что то, чем я делюсь здесь, на самом деле не является уникальным прозрением. RFC 3796 cfg_logical_ops Джейкоба Пратта предлагает добавление булевых операторов &&, || и ! в атрибутах cfg. Фактически заменяя существующие доменно-специфичные операторы all(), any() и not().
#![allow(unused)] fn main() { // текущий вариант #[cfg(all(any(a, b), any(c, d), not(e)))] struct MyStruct {} // предложенный вариант #[cfg((a || b) && (c || d) && !e)] struct MyStruct {} }
Мне нравится то, что предлагает этот RFC. Мне вдвойне нравится, когда мы рассматриваем это как часть более широких усилий по сокращению количества микросинтаксисов, разбросанных по всему языку. В свою очередь, делая Rust более простым языком благодаря тому, что он меньше и более последователен.
Синтаксические размышления о типах-представлениях (View Types)
— 2025-04-04 Yoshua Wuyts Источник
Вот одно глупенькое маленькое прозрение, которое у меня было на днях: если прищуриться, то и Типы-представления (View Types), и Типы-паттерны (Pattern Types) выглядят как облегчённые формы Уточнённых типов (Refinement Types)1. Оба позволят ограничивать типы, но слегка разными и дополняющими друг друга способами. Давайте взглянем на это на примере структуры Rgb, содержащей поля для отдельных каналов Красного, Зелёного и Синего, хранящихся как usize2:
#![allow(unused)] fn main() { #[derive(Debug, Default)] struct Rgb { r: usize, g: usize, b: usize, } }
Типы-паттерны дадут нам возможность напрямую использовать такие вещи, как диапазоны или элементы перечислений, в сигнатурах типов. В следующем примере тип usize уточняется так, чтобы статически разрешать только значения от 0 до 255. Это похоже на типы NonZero* в стандартной библиотеке, но как часть языка и применимо с произвольными паттернами:
#![allow(unused)] fn main() { impl Rgb { fn set_red(&mut self, num: usize is ..255) { .. } // ^^^^^^^^ паттерн диапазона } }
Типы-представления касаются сегментирования полей, содержащихся в self, чтобы несколько (изменяемых) методов могли работать с одним и тем же типом, не вызывая проблем с проверкой заимствований. В следующем примере мы предоставляем изменяемый доступ к отдельным полям с помощью отдельных методов. Ни один из этих методов не захватывает пересекающиеся заимствования полей в Self. Это означает, что мы можем свободно вызывать все эти методы, наблюдать за их возвращаемыми типами, и у нас не будет ошибок от borrow checker. Вот пример, использующий синтаксис из последнего поста Нико:
#![allow(unused)] fn main() { impl Rgb { fn red_mut(self: &mut { r } Self) -> .. { .. } // ^^^^^ представление fn green_mut(self: &mut { g } Self) -> .. { .. } // ^^^^^ представление fn blue_mut(self: &mut { b } Self) -> .. { .. } // ^^^^^ представление } }
Вот забавный вопрос: что произойдёт, если мы объединим Типы-паттерны и Типы-представления? Оба служат разным целям, и я знаю, что у меня есть случаи, где я хотел бы их совместить. Так как бы это выглядело? В абстрактном виде кажется, что мы получим что-то вроде следующего:
#![allow(unused)] fn main() { impl Rgb { fn foo(self: &mut { r, g } Self is Self { r: ..255, g: ..255, .. }) {} // ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // представление паттерн } }
Мне это кажется слишком много для чтения. Но также довольно... избыточно? И Типы-представления, и Типы-паттерны здесь уточняют Self. Я бы ожидал, что мы сможем их объединить. А что, если бы Типы-представления и Типы-паттерны вместо этого занимали одну и ту же синтаксическую позицию. Есть причина, по которой Типам-представлениям приходится использовать is, так давайте использовать его. С этим мы могли бы переписать наш предыдущий пример вот так:
#![allow(unused)] fn main() { impl Rgb { fn foo(&mut self is Self { r: ..255, g: ..255, .. }) {} // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // представление + паттерн } }
Это кажется мне намного более читаемым. Объединение обеих возможностей здесь кажется гораздо менее хлопотным и, возможно, даже... приятным? Это обновлённое обозначение также повлияло бы на наш предыдущий пример с Типами-представлениями. Используя обновлённую нотацию, это теперь записывалось бы так же, но без использования паттернов:
#![allow(unused)] fn main() { impl Rgb { fn red_mut(&mut self is Self { r, .. }) -> .. { .. } // ^^^^^^^^^ представление fn green_mut(&mut self is Self { g, .. }) -> .. { .. } // ^^^^^^^^^ представление fn blue_mut(&mut self is Self { b, .. }) -> .. { .. } // ^^^^^^^^^ представление } }
Мне кажется, что это довольно аккуратно унифицировало бы то, как мы говорим об уточнениях в сигнатурах функций. В плане нотации это переосмысливает Типы-представления как Типы-паттерны с игнорируемыми полями. Хотя нам не обязательно принимать эту нотацию: больше всего меня волнует, чтобы мы подумали о том, как обе функции предназначены для совместной работы в языке, чтобы создать целостный опыт. Спасибо за чтение!
Примечания
-
Мне нравится термин «лёгкие уточнённые типы» для категории расширений, включающих типы-паттерны и типы-представления. Для меня это напоминает лёгкие формальные методы: менее тщательные, чем полный вариант, но с невероятной отдачей при относительных затраченных усилиях. ← ↩
-
Не слишком задумывайтесь, почему мы храним эти значения как
usize. Это немного глупо. Для целей этого примера просто представьте, что есть какая-то причина, по которой мы должны делать именно так. ← ↩
Обзор всех вариантов итераторов
— 2025-02-12 Yoshua Wuyts Источник
- введение
- базовый итератор
- ограниченный итератор
- объединённый итератор
- потокобезопасный итератор
- dyn-совместимый итератор
- двусторонний итератор
- позиционируемый итератор
- итератор времени компиляции
- заимствующий итератор
- итератор с возвращаемым значением
- итератор с аргументом в next
- итератор с досрочным завершением
- чувствительный к адресу итератор
- итератор с гарантированным разрушением
- асинхронный итератор
- конкурентный итератор
- заключение
Введение
О да, тебе нравятся итераторы? Назови их все. —
Я всё больше склоняюсь к мысли, что прежде чем мы сможем осмысленно обсуждать пространство решений, мы должны сначала приложить усилия для достижения консенсуса по пространству проблем. Трудно планировать путешествие вместе, если мы не знаем, каким будет путь. Или хуже: если мы заранее не договоримся, куда хотим прийти.
В Rust трейт Iterator — самый сложный трейт в стандартной библиотеке. Он предоставляет 76 методов и, по моим оценкам (я остановился на 120), имеет около 150 реализаций трейтов только в стандартной библиотеке. Он также включает широкий спектр трейтов-расширений, таких как FusedIterator и ExactSizeIterator, которые предоставляют дополнительные возможности. И сам по себе он является трейтовым выражением одного из основных эффектов управления потоком в Rust; то есть он находится в центре многих интересных вопросов о том, как мы комбинируем такие эффекты.
Несмотря на всё это, мы знаем, что трейт Iterator сегодня недостаточен, и мы хотели бы расширить его возможности. Асинхронная итерация — одна из популярных возможностей, которых не хватает в качестве встроенной. Другая — возможность создавать чувствительные к адресу (самоссылающиеся) итераторы. Менее модные, но не менее важные — итераторы, которые могут заимствовать элементы с временем жизни, условно завершаться досрочно и принимать внешние аргументы при каждом вызове next.
Я пишу этот пост, чтобы перечислить все варианты итераторов, используемые сегодня в Rust. Чтобы мы могли определить границы пространства проблем, учитывая все из них. Я также делаю это, потому что начал слышать разговоры о возможном (мягком) устаревании Iterator. Я считаю, что у нас есть только один шанс провести такое устаревание1, и если мы собираемся это сделать, нужно убедиться, что это решение, которое, вероятно, позволит нам преодолеть все наши известные ограничения. Не только одно или два.
Итак, без лишних слов: давайте перечислим каждый вариант существующего трейта Iterator, который, как мы знаем, мы хотим написать, и обсудим, как эти варианты взаимодействуют друг с другом, создавая новые интересные виды итераторов.
Базовый итератор
Iterator — это трейт, представляющий состояние компонента итерации; IntoIterator представляет способность типа быть итерируемым. Вот трейт Iterator, каким он есть сегодня в стандартной библиотеке. Трейты Iterator и IntoIterator тесно связаны, поэтому на протяжении всего поста мы будем показывать оба, чтобы получить более полную картину.
На протяжении этого поста мы будем рассматривать варианты Iterator, которые предоставляют новые возможности. Этот трейт представляет отсутствие этих возможностей: он блокирующий, не может быть вычислен во время компиляции, строго последовательный и так далее. Вот как сегодня выглядит основа подмодуля core::iter:
#![allow(unused)] fn main() { /// Итерируемый тип. pub trait IntoIterator { /// Тип элементов, выдаваемых итератором. type Item; /// В какой вид итератора мы превращаем этот тип? type IntoIter: Iterator<Item = Self::Item>; /// Возвращает итератор по элементам этого типа. fn into_iter(self) -> Self::IntoIter; } /// Тип, который выдаёт значения одно за другим. pub trait Iterator { /// Тип элементов, выдаваемых итератором. type Item; /// Продвигает итератор и выдаёт следующее значение. fn next(&mut self) -> Option<Self::Item>; /// Возвращает границы оставшейся длины итератора. fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
Хотя это не строго необходимо: ассоциированный тип IntoIterator::Item существует для удобства. Таким образом, люди, использующие трейт, могут напрямую указать Item с помощью impl IntoIterator<Item = Foo>, что намного удобнее, чем I: <IntoIterator::IntoIter as Iterator>::Item и т.д.
Ограниченный итератор
Базовый трейт Iterator представляет потенциально бесконечную (неограниченную) последовательность элементов. У него есть метод size_hint, который возвращает, сколько элементов итератор ещё ожидает выдать. Однако это значение не гарантированно является правильным и предназначено только для оптимизаций. Из документации:
Примечания по реализации: не требуется, чтобы реализация итератора выдавала объявленное количество элементов. Ошибочный итератор может выдать меньше нижней границы или больше верхней границы элементов.
size_hint()в первую очередь предназначен для оптимизаций, таких как резервирование места для элементов итератора, но не должен использоваться для, например, пропуска проверок границ в небезопасном коде. Неправильная реализацияsize_hint()не должна приводить к нарушениям безопасности памяти.
Стандартная библиотека Rust предоставляет два подтрейта Iterator, которые позволяют гарантировать его ограниченность: ExactSizeIterator (стабильный) и TrustedLen (нестабильный). Оба трейта принимают : Iterator в качестве супертрейта и на поверхности кажутся почти идентичными. Но есть одно ключевое отличие: TrustedLen является unsafe для реализации, что позволяет использовать его для гарантии инвариантов безопасности.
#![allow(unused)] fn main() { /// Итератор, который знает свою точную длину. pub trait ExactSizeIterator: Iterator { /// Возвращает точную оставшуюся длину итератора. fn len(&self) -> usize { .. } /// Возвращает `true`, если итератор пуст. fn is_empty(&self) -> bool { .. } } /// Итератор, который сообщает точную длину с помощью `size_hint`. #[unstable(feature = "trusted_len")] pub unsafe trait TrustedLen: Iterator {} }
ExactSizeIterator имеет те же гарантии безопасности памяти, что и Iterator::size_hint, то есть: на него нельзя полагаться как на правильный. Это означает, что если вы, скажем, собираете элементы из итератора в вектор, вы не можете опустить проверки границ и использовать ExactSizeIterator::len в качестве аргумента для Vec::set_len. Однако если реализован TrustedLen, проверки границ можно опустить, поскольку значение, возвращаемое size_hint, теперь является инвариантом безопасности итератора.
Объединённый итератор
При работе с итератором мы обычно перебираем элементы, пока итератор не вернёт None, после чего считаем итератор «завершённым». Однако документация для Iterator::next включает следующее:
Возвращает
None, когда итерация завершена. Отдельные реализации итераторов могут возобновить итерацию, поэтому повторный вызовnext()может или не может снова начать возвращатьSome(Item)в какой-то момент.
Редко приходится работать с итераторами, которые возвращают None, а затем снова возобновляют работу, но трейт Iterator явно это позволяет. Так же как он позволяет итератору паниковать, если next вызывается снова после того, как None был возвращён один раз. К счастью, существует подтрейт FusedIterator, который гарантирует, что после того, как None был возвращён один раз из итератора, все последующие вызовы next продолжат возвращать None.
#![allow(unused)] fn main() { /// Итератор, который всегда продолжает возвращать `None` после исчерпания. pub trait FusedIterator: Iterator {} }
Большинство итераторов в стандартной библиотеке реализуют FusedIterator. Для итераторов, которые не являются объединёнными, можно вызвать комбинатор Iterator::fuse. Документация стандартной библиотеки рекомендует никогда не использовать ограничение FusedIterator, отдавая предпочтение Iterator в ограничениях и вызову fuse для гарантии объединённого поведения. Для итераторов, которые уже реализуют FusedIterator, это считается отсутствием операции.
Дизайн FusedIterator работает, потому что Option::None идемпотентен: никогда не возникает ситуации, когда мы не можем создать новый экземпляр None. Сравните это с перечислениями, такими как Poll, у которых отсутствует состояние «завершено», — и вы увидите, что экосистемные трейты, такие как FusedFuture, пытаются вернуть эту недостающую выразительность другими способами. Необходимость идемпотентного состояния «завершено» важно помнить по мере изучения других вариантов итераторов в этом посте.
Потокобезопасный итератор
В то время как ограниченные и объединённые итераторы можно получить, уточнив трейт Iterator с помощью подтрейтов, потокобезопасные итераторы получаются путём композиции автотрейтов Send и Sync с трейтом Iterator. Это означает, что нет необходимости в специальных трейтах SendIterator или SyncIterator. «Потокобезопасный итератор» становится композицией Iterator и Send / Sync:
#![allow(unused)] fn main() { struct Cat; // Ручная реализация `Iterator` impl Iterator for Cat { .. } // Эти реализации подразумеваются тем, // что `Send` и `Sync` являются автотрейтами unsafe impl Send for Cat {} unsafe impl Sync for Cat {} }
И при использовании impl в ограничениях мы снова можем выразить наше намерение, комбинируя трейты в ограничениях. Я уже высказывал мнение, что использование Iterator непосредственно в ограничениях — это редко то, что люди действительно хотят, поэтому ограничение будет выглядеть так:
#![allow(unused)] fn main() { fn thread_safe_sink(iter: impl IntoIterator + Send) { .. } }
Если мы также хотим, чтобы отдельные элементы, выдаваемые итератором, были помечены как потокобезопасные, мы должны добавить дополнительные ограничения:
#![allow(unused)] fn main() { fn thread_safe_sink<I>(iter: I) where I: IntoIterator<Item: Send> + Send, { .. } }
Хотя здесь нет недостатка выразительности, иногда такие ограничения могут становиться довольно многословными. Это не следует воспринимать как осуждение самой системы, а скорее как вызов для улучшения эргономики наиболее распространённых случаев.
Dyn-совместимый итератор
Dyn-совместимость — это ещё одна ось, по которой разделяются трейты. В отличие от, например, потокобезопасности, dyn-совместимость является неотъемлемой частью трейта и регулируется ограничениями Sized. К счастью, оба трейта Iterator и IntoIterator по своей природе являются dyn-совместимыми. Это означает, что их можно использовать для создания объектов трейта с помощью ключевого слова dyn:
#![allow(unused)] fn main() { struct Cat; impl Iterator for Cat { .. } let cat = Cat {}; let dyn_cat: &dyn Iterator = &cat; // ок }
Некоторые комбинаторы итераторов, такие как count, принимают дополнительное ограничение Self: Sized2. Но поскольку объекты трейта сами по себе являются Sized, всё в основном работает как ожидается:
#![allow(unused)] fn main() { let mut cat = Cat {}; let dyn_cat: &mut dyn Iterator = &mut cat; assert_eq!(dyn_cat.count(), 1); // ок }
Двусторонний итератор
Часто итераторы по коллекциям хранят все данные в памяти, и их можно обходить в любом направлении. Для этой цели Rust предоставляет трейт DoubleEndedIterator. В то время как Iterator основан на методе next, DoubleEndedIterator основан на методе next_back. Это позволяет брать элементы как с логического начала, так и с конца итератора. И как только оба курсора встречаются, итерация считается завершённой.
#![allow(unused)] fn main() { /// Итератор, способный выдавать элементы с обоих концов. pub trait DoubleEndedIterator: Iterator { /// Удаляет и возвращает элемент с конца итератора. fn next_back(&mut self) -> Option<Self::Item>; } }
Хотя можно ожидать, что этот трейт будет реализован для, например, VecDeque, интересно отметить, что он также реализован для Vec, String и других коллекций, которые растут только в одном направлении. Также, в отличие от некоторых других уточнений итератора, которые мы видели, DoubleEndedIterator имеет обязательный метод, который используется в качестве основы для нескольких новых методов, таких как rfold (обратная свёртка) и rfind (обратный поиск).
Позиционируемый итератор
Как трейт Iterator, так и Read в Rust предоставляют абстракции для потоковой итерации. Основное различие в том, что Iterator работает, возвращая произвольные владеемые типы при вызове next, в то время как Read ограничен чтением байтов в буферы. Но обе абстракции хранят курсор, который отслеживает, какие данные уже обработаны, а какие ещё нужно обработать.
Но не все потоки созданы равными. Когда мы читаем данные из обычного файла3 на диске, мы можем быть уверены, что, пока не было записей, мы можем прочитать тот же файл снова и получить тот же вывод. Та же гарантия, однако, не верна для сокетов, где после того, как мы прочитали данные из него, мы не можем прочитать те же данные снова. В Rust это различие проявляется через трейт Seek, который даёт контроль над курсором Read в типах, которые его поддерживают.
В Rust трейт Iterator не предоставляет механизма для управления базовым курсором, несмотря на его сходство с Read. Язык, который предоставляет абстракцию для этого, — это C++ в виде random_access_iterator. Это концепт C++ (аналог трейта), который дополнительно уточняет bidirectional_iterator. Мои знания C++ ограничены, поэтому я лучше процитирую документацию напрямую, чем попытаюсь перефразировать:
[...]
random_access_iteratorуточняетbidirectional_iterator, добавляя поддержку постоянного времени продвижения с помощью операторов+=,+,-=и-, постоянного времени вычисления расстояния с помощью-и нотации массива с индексированием[].
Возможность напрямую управлять курсором в реализациях Iterator может оказаться полезной при работе с типами коллекций в памяти, такими как Vec, а также при работе с удалёнными объектами, такими как API с постраничным выводом. Очевидной отправной точкой для такого трейта было бы зеркальное отражение существующего трейта io::Seek и адаптация его в качестве подтрейта Iterator:
#![allow(unused)] fn main() { /// Перечисление возможных методов позиционирования внутри итератора. pub enum SeekFrom { /// Устанавливает смещение на указанный индекс. Start(usize), /// Устанавливает смещение на размер этого объекта плюс указанный индекс. End(isize), /// Устанавливает смещение на текущую позицию плюс указанный индекс. Current(isize), } /// Итератор с курсором, который можно перемещать. pub trait SeekingIterator: Iterator { /// Переместиться к смещению в итераторе. fn seek(&mut self, pos: SeekFrom) -> Result<usize>; } }
Итератор времени компиляции
В Rust мы можем использовать блоки const {} для выполнения кода во время компиляции. Только функции const fn могут вызываться из блоков const {}. Свободные функции и методы const fn стабильны, но методы трейтов const fn — нет. Это означает, что такие трейты, как Iterator, ещё нельзя вызывать из блоков const {}, и поэтому выражения for..in также нельзя.
Мы знаем, что хотим поддерживать итерацию в блоках const {}, но мы ещё не знаем, как мы хотим обозначить как объявление трейта, так и ограничения трейта. Самый интересный открытый вопрос здесь — как мы в итоге будем передавать трейт Destruct, который необходим, чтобы типы могли быть уничтожаемыми в контекстах const. Это приводит к дополнительным вопросам о том, должны ли ограничения const трейта подразумевать const Destruct. И должна ли аннотация const быть частью объявления трейта, отдельных методов или, возможно, и того, и другого.
Этот пост — не подходящее место для обсуждения всех компромиссов. Но чтобы дать представление о том, как может выглядеть совместимый со временем компиляции трейт Iterator: вот вариант, в котором и трейт, и отдельные методы помечены const:
#![allow(unused)] fn main() { pub const trait IntoIterator { // ← `const` type Item; type IntoIter: const Iterator<Item = Self::Item>; // ← `const` const fn into_iter(self) -> Self::IntoIter; // ← `const` } pub const trait Iterator { // ← `const` type Item; const fn next(&mut self) -> Option<Self::Item>; // ← `const` const fn size_hint(&self) -> (usize, Option<usize>) { .. } // ← `const` } }
Заимствующий итератор
Хотя сегодня в стандартной библиотеке существует множество итераторов, которые возвращают ссылки на элементы, значения, на которые ссылаются, никогда не принадлежат самому итератору. Чтобы писать итераторы, которые владеют элементами, которые они выдают, и выдают их по ссылке, ассоциированный тип Item в итераторе должен иметь время жизни:
#![allow(unused)] fn main() { pub trait IntoIterator { type Item<'a> // ← Время жизни where Self: 'a; // ← Ограничение type IntoIter: for<'a> Iterator<Item<'a> = Self::Item<'a>>; // ← Время жизни fn into_iter(self) -> Self::IntoIter; } pub trait Iterator { type Item<'a> // ← Время жизни where Self: 'a; // ← Ограничение fn next(&mut self) -> Option<Self::Item<'_>>; // ← Время жизни fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
Были разговоры о добавлении времени жизни 'self в язык в качестве сокращения для where Self:'a. Но даже с этим дополнением этот набор сигнатур — не для слабонервных. Причина в том, что он использует GAT, которые и полезны, и мощны, — но по сути являются функцией системы типов для экспертов и могут быть немного сложными для понимания.
Заимствующая итерация также будет важна, когда мы добавим специальный синтаксис для создания итераторов с помощью ключевого слова yield. Следующий пример показывает блок gen {}, который создаёт строку, а затем возвращает ссылку на неё. Это идеально4 соответствует трейту Iterator, который мы определили:
#![allow(unused)] fn main() { let iter = gen { let name = String::from("Chashu"); yield &name; // ← Заимствует локальное значение, хранящееся в куче. }; }
Итератор с возвращаемым значением
Текущий трейт Iterator имеет ассоциированный тип Item, который соответствует ключевому слову yield в Rust. Но у него нет ассоциированного типа, который соответствовал бы ключевому слову return. Способ думать об итераторах сегодня — это то, что их тип возврата жёстко закодирован как unit. Если мы хотим, чтобы функции-генераторы и блоки могли не только выдавать, но и возвращать, нам потребуется какой-то способ выразить это.
#![allow(unused)] fn main() { let counting_iter = gen { let mut total = 0; for item in iter { total += 1; yield item; // ← Выдаёт один тип. } total // ← Возвращает другой тип. }; }
Очевидный способ написать трейт для «итератора, который может возвращать» — это дать Iterator дополнительный ассоциированный элемент Output, который соответствует логическому возвращаемому значению. Чтобы иметь возможность выразить семантику объединения (fuse), функция next должна быть способна возвращать три различных состояния:
- Выдача ассоциированного типа
Item - Возврат ассоциированного типа
Output - Итератор исчерпан (завершён)
Один из способов сделать это — чтобы next возвращал Option<ControlFlow>, где Some(Continue) соответствует yield, Some(Break) — return, а None — завершению. Без финального состояния «завершено» вызов next снова на итераторе после того, как он закончил выдавать значения, вероятно, всегда должен приводить к панике. Это то, что делают большинство фьючерсов в асинхронном Rust, и это станет проблемой, если мы когда-нибудь захотим гарантировать отсутствие паник.
#![allow(unused)] fn main() { pub trait IntoIterator { type Output; // ← Output type Item; type IntoIter: Iterator<Output = Self::Output, Item = Self::Item>; // ← Output fn into_iter(self) -> Self::IntoIter; } pub trait Iterator { type Output; // ← Output type Item; fn next(&mut self) -> Option<ControlFlow<Self::Output, Self::Item>>; // ← Output fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
То, как это будет работать на стороне вызывающего и в комбинаторах, довольно интересно. Начнём с предоставленного метода for_each: он захочет возвращать Iterator::Output, а не (), после того как итератор завершится. Критически важно, что замыкание F, предоставленное for_each, работает только с Self::Output и не имеет знаний о Self::Output. Потому что если бы замыкание имело прямое знание об Output, оно могло бы завершиться досрочно, возвращая Output раньше ожидаемого, что является другим видом итератора, чем имеющий возвращаемое значение.
#![allow(unused)] fn main() { fn for_each<F>(self, f: F) -> Self::Output // ← Output where Self: Sized, F: FnMut(Self::Item), { .. } }
Если мы попробуем преобразовать «итератор с возвращаемым значением» в for..in, всё становится ещё интереснее. В Rust выражения loop сами могут вычисляться в не-unit типы, вызывая break с некоторым значением. Выражения for..in ещё не могут делать это в общем случае, за исключением обработки ошибок с помощью ?. Но нетрудно представить, как это можно заставить работать, концептуально это эквивалентно вызову Iterator::try_for_each и возврату Some(Break(value)):
#![allow(unused)] fn main() { let ty: u32 = for item in iter { // ← Вычисляется в `u32` break 12u32 // ← Вызывает `break` из тела цикла }; }
Предполагая, что у нас есть Iterator с собственным возвращаемым значением, это означало бы, что выражения for..in смогут вычисляться в типы возврата, отличные от unit, без вызова break из тела цикла:
#![allow(unused)] fn main() { let ty: u32 = for item in iter { // ← Вычисляется в `u32` dbg!(item); // ← Не вызывает `break` из тела цикла }; }
Это, конечно, приводит к вопросам о том, как сочетать «итератор с возвращаемым значением» и «использование break внутри выражения for..in». Я оставлю это как упражнение для читателя, как это выразить (я уверен, что это можно сделать, просто думаю, что это интересно). Обобщение всех режимов досрочного возврата из выражений for..in на вызовы комбинаторов for_each — интересная задача, которую мы рассмотрим более подробно позже, когда будем обсуждать итераторы с досрочным завершением (ошибочные).
Итератор с аргументом в Next
В блоках-генераторах ключевое слово yield может использоваться для повторяющейся выдачи значений из итератора. Но что, если вызывающая сторона не просто хочет получать значения, но и передавать новые значения обратно в итератор? Это потребовало бы, чтобы yield мог вычисляться в тип, отличный от unit. Итераторы, имеющие такую функциональность, часто называют «сопрограммами», и они особенно полезны при реализации протоколов без ввода-вывода.
#![allow(unused)] fn main() { /// Какой-то RPC-протокол enum Proto { /// Какое-то состояние протокола MsgLen(u32), } let rpc_handler = gen { let len = message.len(); let next_state = yield Proto::MsgLen(len); // ← `yield` вычисляется в значение .. }; }
Чтобы поддержать это, Iterator::next должен быть способен принимать дополнительный аргумент в виде нового ассоциированного типа Args. Этот ассоциированный тип имеет то же имя, что и входные аргументы Fn и AsyncFn. Если «итератор с аргументом в next» можно рассматривать как представляющий «сопрограмму», то семейство трейтов Fn можно рассматривать как представляющую обычную «подпрограмму» (функцию).
#![allow(unused)] fn main() { pub trait IntoIterator { type Item; type Args; // ← Args type IntoIter: Iterator<Item = Self::Item, Args = Self::Args>; // ← Args fn into_iter(self) -> Self::IntoIter; } pub trait Iterator { type Item; type Args; // ← Args fn next(&mut self, args: Self::Args) -> Option<Self::Item>; // ← Args fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
Здесь также интересно рассмотреть сторону вызывающего. Начиная с гипотетической функции for_each: ей нужно будет принимать начальное значение, передаваемое в next, а затем из замыкания возвращать дополнительные значения, передаваемые в последующие вызовы next. Сигнатура для этого выглядела бы так:
#![allow(unused)] fn main() { fn for_each<F>(self, args: Self::Args, f: F) // ← Args where Self: Sized, F: FnMut(Self::Item) -> Self::Args, // ← Args { .. } }
Чтобы лучше проиллюстрировать, как это будет работать, и развить некоторую интуицию, рассмотрим ручные вызовы next внутри цикла. Мы начнём с создания некоторого начального состояния, которое передаётся в первый вызов next. Это создаст элемент, который можно использовать для создания следующего аргумента для next. И так далее, пока у итератора не останется больше элементов для выдачи:
#![allow(unused)] fn main() { let mut iter = some_value.into_iter(); let mut arg = some_initial_value; // Цикл, пока есть элементы для выдачи while let Some(item) = iter.next(arg) { // используем `item` и вычисляем следующий `arg` arg = process_item(item); } }
Если бы мы перебирали итератор с функцией next с помощью выражения for..in, эквивалентом возврата значения из замыкания было бы либо продолжение со значением. Это потенциально также может быть конечным выражением в теле цикла, которое, как вы можете думать, само по себе сегодня является подразумеваемым continue. Единственный оставшийся вопрос — как передавать начальные значения при создании цикла, но это в основном кажется упражнением в дизайне синтаксиса:
#![allow(unused)] fn main() { // Передача значения в функцию `next` кажется логично // отображаемой на выражения `continue`. // (передача начального состояния в цикл намеренно опущена) for item in iter { continue process_item(item); // ← `continue` со значением }; // Можно представить, что выражения `for..in` имеют // подразумеваемый `continue ()` в конце. Как функции // имеют подразумеваемый `return ()`. Что, если бы он мог принимать // значение? // (передача начального состояния в цикл намеренно опущена) for item in iter { process_item(item) // ← `continue` со значением }; }
«Итератор с возвращаемым значением» и «итератор с аргументом в next» кажутся особенно хорошо соответствующими break и continue. Я думаю о них как о двойственных, позволяя обоим выражениям переносить не-unit типы. Это кажется важным прозрением, которое я раньше ни от кого не слышал.
Возможность передавать значение в next — одна из отличительных особенностей трейта Coroutine в стандартной библиотеке. Однако, в отличие от наброска трейта, который мы предоставили здесь, в Coroutine тип значения, передаваемого в next, определяется как общий параметр в трейте, а не как ассоциированный тип. Предположительно, это сделано для того, чтобы Coroutine мог иметь несколько реализаций на одном и том же типе, зависящих от типа ввода. Я проверил, используется ли это сегодня, и, кажется, нет. Вот почему я подозреваю, что, вероятно, можно использовать ассоциированный тип для входных аргументов.
Итератор с досрочным завершением
Хотя сегодня возможно возвращать Try-типы, такие как Result и Option, из итератора, ещё невозможно немедленно остановить выполнение в случае ошибки5. Такое поведение обычно называют «досрочным завершением»: приостановка нормальной работы и запуск исключительного состояния (как электрический выключатель в здании).
В нестабильном Rust у нас есть блоки try {}, которые полагаются на нестабильный трейт Try, но у нас ещё нет функций try fn. Если мы хотим, чтобы они функционировали в трейтах так, как мы хотим, они должны десугерироваться в impl Try. Вместо того чтобы строить догадки о том, как может выглядеть потенциальный синтаксис try fn в будущем, мы будем писать наши примеры, напрямую используя -> impl Try. Вот как сегодня выглядят трейты Try (и FromResidual):
#![allow(unused)] fn main() { /// Оператор `?` и блоки `try {}`. pub trait Try: FromResidual { /// Тип значения, производимого `?`, /// когда НЕ происходит досрочное завершение. type Output; /// Тип значения, передаваемого в `FromResidual::from_residual` /// как часть `?` при досрочном завершении. type Residual; /// Создаёт тип из его типа `Output`. fn from_output(output: Self::Output) -> Self; /// Используется в `?`, чтобы решить, должен ли оператор производить /// значение или распространять значение обратно вызывающей стороне. fn branch(self) -> ControlFlow<Self::Residual, Self::Output>; } /// Используется для указания, какие остаточные значения могут быть преобразованы /// в какие типы `Try`. pub trait FromResidual<R = <Self as Try>::Residual> { /// Создаёт тип из совместимого типа `Residual`. fn from_residual(residual: R) -> Self; } }
Можно думать об итераторе с досрочным завершением как об особом случае «итератора с возвращаемым значением». В своей базовой форме он будет возвращаться досрочно только в случае исключения, в то время как его логический тип возврата остаётся жёстко закодированным как unit. Тип возврата fn next должен быть impl Try, возвращающим Option, со значением Residual, установленным в ассоциированный тип Residual. Это позволяет всем комбинаторам использовать один и тот же Residual, обеспечивая поток типов.
#![allow(unused)] fn main() { pub trait IntoIterator { type Item; type Residual; type IntoIter: Iterator< Item = Self::Item, Residual = Residual // ← Residual >; fn into_iter(self) -> Self::IntoIter; } pub trait Iterator { type Item; type Residual; // ← Residual fn next(&mut self) -> impl Try< // ← impl Try Output = Option<Self::Item>, Residual = Self::Residual, // ← Residual >; } }
Если мы снова рассмотрим сторону вызывающего, мы захотим предоставить способ для выражений for..in досрочно завершаться. Что интересно здесь, так это то, что базовый трейт итератора уже предоставляет метод try_for_each. Разница между этим методом и for_each, который мы собираемся увидеть, заключается в том, как получается тип Residual. В try_for_each значение локально для метода, в то время как если сам трейт «досрочно завершающийся», тип Residual определяется ассоциированным типом Self::Residual. Или, иными словами: в досрочно завершающемся итераторе тип, с которым мы досрочно завершаем, является свойством трейта, а не свойством метода.
#![allow(unused)] fn main() { fn for_each<F, R>(self, f: F) -> R // ← Тип возврата where Self: Sized, F: FnMut(Self::Item) -> R, // ← Тип возврата R: Try<Output = (), Residual = Self::Residual>, // ← `impl Try` { .. } }
Как упоминалось ранее в этом посте: взаимодействие между «итератором с типом возврата» и «досрочно завершающимся итератором» — интересное. Возврат Option<ControlFlow> из fn next способен кодировать три различных состояния, но эта комбинация возможностей требует от нас кодирования четырёх состояний:
- выдать следующий элемент
- завершиться с остаточным значением (residual)
- вернуть конечный результат (output)
- итератор завершён (идемпотентно)
Причина, по которой мы хотим иметь возможность кодировать такую сигнатуру, заключается в том, что при написании функций gen fn вполне разумно хотеть иметь тип возврата, досрочно завершаться при ошибке с помощью ?, а также выдавать значения. Это работает как обычные функции сегодня, но с добавленной возможностью вызывать yield. Наивный способ кодирования этого — возвращать impl Try из Option<ControlFlow<_>> с различными ассоциированными типами для Item, Output и Residual. Однако это начинает казаться немного выходящим из-под контроля, хотя, возможно, нотация try fn первого класса может принести некоторое облегчение.
#![allow(unused)] fn main() { pub trait IntoIterator { type Item; type Output; // ← `Output` type Residual; // ← `Residual` type IntoIterator: Iter< Item = Self::Item, Residual = Self::Residual, // ← `Residual` Output = Self::Output, // ← `Output` >; fn into_iter(self) -> Self::IntoIter; } pub trait Iterator { type Item; type Output; // ← `Output` type Residual; // ← `Residual` fn next(&mut self) -> impl Try< // ← `impl Try` Output = Option<ControlFlow< // ← `ControlFlow Self::Output, // ← `Output Self::Item, >>, Residual = Self::Residual, // ← `Residual` >; } }
Чувствительный к адресу итератор
Преобразование генераторов в Rust может создавать самоссылающиеся типы. То есть: типы, которые имеют поля, заимствующие из других полей того же типа. Мы называем эти типы «чувствительными к адресу», потому что после того, как тип создан, его адрес в памяти должен оставаться стабильным. Это возникает при написании блоков gen {}, которые имеют локальные переменные, размещённые на стеке6, которые сохраняются живыми при вызовах yield. Что является или не является «локальной переменной, размещённой на стеке», может быть немного сложным. Но важно подчеркнуть, что, например, вызов IntoIterator::into_iter на типе и повторная выдача всех элементов — это то, что просто работает (площадка для игр):
#![allow(unused)] fn main() { // Этот пример работает сегодня let iter = gen { let cat_iter = cats.into_iter(); for cat in cat_iter { yield cat; } }; }
И чтобы дать представление о том, что, например, не работает, вот один из примеров, собранных Tmandry (T-Lang). Он создаёт промежуточное заимствование, что приводит к ошибке: «Заимствование всё ещё может использоваться, когда тело gen fn выдаёт значение» (площадка для игр):
#![allow(unused)] fn main() { gen fn iter_set_rc<T: Clone>(xs: Rc<RefCell<HashSet<T>>>) ... { for x in xs.borrow().iter() { yield x.clone(); } } }
Чтобы включить работу таких примеров, как последний, Rust должен быть способен выражать некоторую форму «чувствительного к адресу итератора». Очевидной отправной точкой было бы создание нового трейта PinnedIterator, который изменяет тип self метода next, чтобы принимать Pin<&mut Self> вместо &mut self:
#![allow(unused)] fn main() { trait IntoIterator { type Item; type IntoIter: Iterator<Item = Self::Item>; fn into_iter(self) -> Self::IntoIter; } trait Iterator { type Item; fn next(self: Pin<&mut Self>) -> Option<Self::Item>; // ← `Pin<&mut Self>` fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
Перечисление всех проблем Pin заслуживает отдельного поста в блоге. Но всё же кажется достаточно важным указать, что это определение имеет то, что Rust for Linux называет Проблемой безопасной инициализации закреплённых типов (The Safe Pinned Initialization Problem). IntoIterator::into_iter не может возвращать тип, чувствительный к адресу во время создания; вместо этого чувствительность к адресу — это то, что можно гарантировать только позже, после того как тип будет закреплён (pin!) на месте.
В начале этого поста я использовал фразу: «(мягкое) устаревание трейта Iterator». Под этим я имел в виду одно предложение, которое позволяет gen {} возвращать новый трейт Generator с той же сигнатурой, что и наш пример. А также некоторые имплементации моста для совместимости. Основная часть системы совместимости будет следующей:
#![allow(unused)] fn main() { /// Все `Iterator` являются `Generator`. impl<I: IntoIterator> IntoGenerator for I { type Item = I::Item; type IntoGen = IteratorGenerator<I::IntoIter>; fn into_gen(self) -> Self::IntoGen { IteratorGenerator(self.into_iter()) } } /// Только закреплённые `Generator` являются `Iterator`. impl<G> Iterator for Pin<G> where G: DerefMut, G::Target: Generator, { type Item = <<G as Deref>::Target as Generator>::Item; fn next(&mut self) -> Option<Self::Item> { Generator::next(self.as_mut()) } } }
Это создаёт ситуацию, которую я описываю как «совместимость в одну-с-половиной стороны», в отличие от обычной двусторонней совместимости. А нам нужна двусторонняя совместимость, чтобы не было ломающих изменений. Это приводит к ситуации, когда изменение ограничения с приёма Iterator на Generator обратно совместимо. Но изменение имплементации с возврата Iterator на возврат Generator не является обратно совместимым. Очевидным решением тогда было бы мигрировать всю экосистему на использование ограничений Generator везде. В сочетании с тем, что gen {} всегда возвращает Generator, а не Iterator: это устаревание Iterator во всём, кроме имени.
На первый взгляд может показаться, что нас заставляют устаревать Iterator из-за ограничений Pin. Очевидный ответ на это — решить проблемы с Pin, заменив его чем-то лучшим. Но это создаёт ложную дихотомию: ничто не заставляет нас принимать решение по этому поводу сегодня. Как мы установили в начале этого раздела: удивительное количество случаев использования уже работает без необходимости в чувствительных к адресу итераторах. И как мы видели на протяжении этого поста: чувствительная к адресу итерация — далеко не единственная функция, которую блоки gen {} не смогут поддерживать с первого дня.
Итератор с гарантированным разрушением
Текущая формулировка thread::scope требует, чтобы поток, на котором она вызывается, оставался заблокированным до тех пор, пока все потоки не будут присоединены. Это требует входа в замыкание и выполнения всего кода внутри него. Сравните это с чем-то вроде FutureGroup, который логически владеет вычислениями и может свободно перемещаться. Значения фьючерсов, разрешённых внутри, в свою очередь могут быть выданы наружу. Но в отличие от thread::scope, он не может гарантировать, что все вычисления завершатся, и поэтому параллельная версия FutureGroup не может изменяемо удерживать изменяемые заимствования, как это может делать thread::scope.
#![allow(unused)] fn main() { // Пример использования `thread::scope`, // возможность порождать новые потоки // доступна только внутри замыкания. thread::scope(|s| { s.spawn(|| ..); s.spawn(|| ..); // ← Все потоки присоединяются здесь. }); // Пример использования `FutureGroup`, // замыкания не требуются. let mut group = FutureGroup::new(); group.insert(future::ready(2)); group.insert(future::ready(4)); group.for_each(|_| ()).await; }
Если мы хотим написать тип, аналогичный FutureGroup, с такими же гарантиями, как у thread::scope, нам нужно либо гарантировать, что FutureGroup никогда не может быть уничтожен, либо гарантировать, что деструктор FutureGroup всегда выполняется. Оказывается, довольно непрактично иметь типы, которые нельзя уничтожить в языке, где любая функция может паниковать. Поэтому единственный реальный вариант здесь — иметь типы, деструкторы которых гарантированно выполняются.
Наиболее правдоподобный известный нам способ сделать это — ввести новый автотрейт Leak, запрещающий передачу типов в mem::forget, Box::leak и т.д. Для получения дополнительной информации о дизайне прочитайте Линейные типы: краткий обзор (Linear Types One-Pager). Поскольку Leak является автотрейтом, мы могли бы компоновать его с существующими трейтами Iterator и IntoIterator, подобно Send и Move:
#![allow(unused)] fn main() { fn linear_sink(iter: impl IntoIterator<IntoIter: ?Leak>) { .. } }
Асинхронный итератор
В Rust ключевое слово async может преобразовывать императивные тела функций в стейт-машины, которые можно вручную продвигать, вызывая метод Future::poll. Под капотом это делается с помощью так называемого преобразования сопрограмм, которое мы также используем для десугаринга блоков gen {}. Но это только механика; ключевое слово async в Rust также вводит две новые возможности: ad-hoc конкурентность и ad-hoc отмену. Вместе эти возможности можно комбинировать для создания новых операций управления потоком, таких как Future::race и Future::timeout.
Асинхронные функции в трейтах (Async Functions in Traits) были стабилизированы год назад в Rust 1.75, что позволило использовать async fn в трейтах. Заставить трейт Iterator работать с async — в основном вопрос добавления префикса async к next:
#![allow(unused)] fn main() { trait IntoIterator { type Item; type IntoIter: Iterator<Item = Self::Item>; fn into_iter(self) -> Self::IntoIter; } trait Iterator { type Item; async fn next(&mut self) -> Option<Self::Item>; // ← async fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
Хотя метод next здесь был бы аннотирован ключевым словом async, метод size_hint, вероятно, не должен. Причина в том, что он действует как простой геттер, и ему действительно не следует выполнять какие-либо асинхронные вычисления. Также неясно, должен ли into_iter быть async fn или нет. Здесь, вероятно, нужно установить шаблон, и это вполне может быть так.
Комбинация вариантов итераторов, которая недавно вызвала некоторый интерес, — это чувствительный к адресу асинхронный итератор. Мы могли бы представить себе его, заставив next принимать self: Pin<&mut Self>:
#![allow(unused)] fn main() { trait IntoIterator { type Item; type IntoIter: Iterator<Item = Self::Item>; async fn into_iter(self) -> Self::IntoIter; } trait Iterator { type Item; async fn next(self: Pin<&mut Self>) -> Option<Self::Item>; // ← async + `Pin<&mut Self>` fn size_hint(&self) -> (usize, Option<usize>) { .. } } }
Эта сигнатура, вероятно, собьёт с толку некоторых людей. async fn next возвращает impl Future, который сам должен быть закреплён перед опросом. В этом примере мы отдельно требуем, чтобы Self также был закреплён. Это потому, что «состояние итератора» и «состояние фьючерса» — не одно и то же состояние. Мы интуитивно понимаем это при работе с неасинхронными чувствительными к адресу итераторами: локальные переменные, созданные внутри next, не захватываются включающим итератором и могут быть закреплены на стеке на время вызова next. Но при работе с асинхронным чувствительным к адресу итератором почему-то люди предполагают, что все локальные переменные, определённые в fn next, теперь должны принадлежать итератору, а не фьючерсу.
В экосистеме асинхронного Rust существует популярная вариация трейта асинхронного итератора под названием Stream. Вместо того чтобы разделять состояние итератора (self) и функцию next, он объединяет оба в одно состояние. Трейт имеет единственный метод poll_next, который действует как смесь Future::poll и Iterator::next. С предоставленной удобной функцией async fn next, которая является тонкой обёрткой вокруг poll_next.
#![allow(unused)] fn main() { trait IntoStream { type Item; type IntoStream: Stream<Item = Self::Item>; fn into_stream(self) -> Self::IntoStream; } pub trait Stream { type Item; fn poll_next( // ← `fn poll_next` self: Pin<&mut Self>, // ← `Pin<&mut Self>` cx: &mut Context<'_>, // ← `task::Context` ) -> Poll<Option<Self::Item>>; // ← `task::Poll` async fn next(&mut self) -> Self::Item // ← `async` where Self: Unpin // ← `Self: Unpin` { .. } fn size_hint(&self) -> (usize, Option<usize>) { ... } } }
Объединяя оба состояния в одно, этот трейт нарушает один из основных принципов дизайна асинхронного Rust: возможность единообразно сообщать об отмене путём уничтожения фьючерсов. Здесь, если фьючерс от fn next уничтожается, это отсутствие операции, и отмена не произойдёт. Это приводит к тому, что композиционные операторы управления асинхронным потоком, такие как Future::race, не работают, несмотря на компиляцию.
Чтобы вместо этого отменить текущий вызов next, вы вынуждены либо уничтожить весь поток, либо использовать какой-то специальный метод для отмены только состояния фьючерса. Отмена в асинхронном Rust печально известна тем, что её сложно правильно реализовать, что понятно, когда (среди прочего) основные трейты в экосистеме не обрабатывают её корректно.
Конкурентный итератор
Поскольку мы приближаемся к концу нашего изложения, давайте поговорим о самых сложных вариантах Iterator. Первым в очереди: крейт rayon и трейт ParallelIterator. Rayon предоставляет так называемые «параллельные итераторы», которые обрабатывают элементы конкурентно, а не последовательно, используя потоки операционной системы. Это, как правило, значительно повышает пропускную способность по сравнению с последовательной обработкой, но имеет оговорку, что все потребляемые элементы должны реализовывать Send. Чтобы увидеть, насколько знакомыми могут быть параллельные итераторы: следующий пример выглядит почти идентично последовательному итератору, за исключением вызова into_par_iter вместо into_iter.
#![allow(unused)] fn main() { use rayon::prelude::*; (0..100) .into_par_iter() // ← Вместо вызова `into_iter`. .for_each(|x| println!("{:?}", x)); }
Однако трейт ParallelIterator поставляется в паре с трейтом Consumer. Это может немного ошеломить, но способ работы Rayon заключается в том, что комбинаторы могут быть связаны в цепочку для создания обработчика, который в конце цепочки копируется в каждый поток и используется там для обработки элементов. Это, конечно, упрощённое объяснение; я отсылаю к сопровождающим Rayon за подробным объяснением. Чтобы дать вам представление о том, насколько эти трейты отличаются от обычных трейтов Iterator, вот они (упрощённо):
#![allow(unused)] fn main() { /// Потребитель — это, по сути, обобщённая операция «свёртки». pub trait Consumer<Item>: Send + Sized { /// Тип папки, в которую этот потребитель может быть преобразован. type Folder: Folder<Item, Result = Self::Result>; /// Тип редуктора, который создаётся, если этот потребитель разделён. type Reducer: Reducer<Self::Result>; /// Тип результата, который в конечном итоге произведёт этот потребитель. type Result: Send; } /// Тип, который можно итерировать параллельно. pub trait IntoParallelIterator { /// Какой итератор мы возвращаем? type Iter: ParallelIterator<Item = Self::Item>; /// Какой тип элемента мы выдаём? type Item: Send; /// Возвращает итератор с сохранением состояния для параллельной обработки. fn into_par_iter(self) -> Self::Iter; } /// Параллельная версия стандартного трейта итератора. pub trait ParallelIterator: Sized + Send { /// Тип элемента, который производит этот параллельный итератор. type Item: Send; /// Внутренний метод, используемый для определения поведения этого /// параллельного итератора. Вам не следует вызывать его напрямую. fn drive_unindexed<C>(self, consumer: C) -> C::Result where C: UnindexedConsumer<Self::Item>; } }
Что здесь наиболее важно, так это то, что использование трейта ParallelIterator ощущается похожим на обычный итератор. Всё, что вам нужно сделать, это вызвать into_par_iter вместо into_iter, и вы в деле. На стороне потребления кажется, что мы должны быть способны создать какую-то вариацию for..in для потребления параллельных итераторов. Вместо того чтобы строить догадки о синтаксисе, мы можем посмотреть на сигнатуру ParallelIterator::for_each, чтобы понять, какие гарантии для этого потребуются.
#![allow(unused)] fn main() { fn for_each<F>(self, f: F) where F: Fn(Self::Item) + Sync + Send { .. } }
Мы можем наблюдать три изменения здесь по сравнению с базовым трейтом итератора:
Selfбольше не должен бытьSized.- Несколько предсказуемо, замыкание
Fдолжно быть потокобезопасным. - Замыкание
Fдолжно реализовыватьFn, а неFnMut, чтобы предотвратить гонки данных.
Затем мы можем сделать вывод, что в случае параллельного выражения for..in тело цикла не сможет захватывать какие-либо изменяемые ссылки. Это дополнение к существующему ограничению, что тела циклов уже не могут выражать семантику FnOnce и перемещать значения (например, «Предупреждение: это значение было перемещено на предыдущей итерации цикла»).
Интересная комбинация — это «параллельная итерация» и «асинхронная итерация». Интересный аспект ключевого слова async в Rust заключается в том, что оно позволяет ad-hoc конкурентное выполнение фьючерсов без необходимости полагаться на специальные системные вызовы или потоки ОС. Это означает, что конкурентность и параллелизм могут быть отделены друг от друга. Хотя мы ещё не видели трейта «параллельный асинхронный итератор» в экосистеме, крейт futures-concurrency кодирует «конкурентный асинхронный итератор»7. Так же, как ParallelIterator, ConcurrentAsyncIterator поставляется в паре с трейтом Consumer.
#![allow(unused)] fn main() { /// Описывает тип, который может получать данные. pub trait Consumer<Item, Fut> where Fut: Future<Output = Item>, { /// Какой тип элемента мы возвращаем при завершении? type Output; /// Отправить элемент на следующий шаг в очереди обработки. async fn send(self: Pin<&mut Self>, fut: Fut) -> ConsumerState; /// Двигаться вперёд в потребителе, занимаясь чем-то ещё. async fn progress(self: Pin<&mut Self>) -> ConsumerState; /// У нас не осталось данных для отправки `Consumer`; /// ждём его вывода. async fn flush(self: Pin<&mut Self>) -> Self::Output; } pub trait IntoConcurrentAsyncIterator { type Item; type IntoConcurrentAsyncIter: ConcurrentAsyncIterator<Item = Self::Item>; fn into_co_iter(self) -> Self::IntoConcurrentAsyncIter; } pub trait ConcurrentAsyncIterator { type Item; type Future: Future<Output = Self::Item>; /// Внутренний метод, используемый для определения поведения /// этого конкурентного итератора. Вам не следует /// вызывать его напрямую. async fn drive<C>(self, consumer: C) -> C::Output where C: Consumer<Self::Item, Self::Future>; } }
Хотя ParallelIterator и ConcurrentAsyncIterator имеют сходства как в использовании, так и в дизайне, они достаточно различны, чтобы мы не могли считать один просто асинхронной, не потокобезопасной версией другого. Возможно, можно сблизить оба трейта так, чтобы единственной разницей было несколько стратегически размещённых ключевых слов async, но необходимы дополнительные исследования, чтобы проверить, возможно ли это.
Ещё один интересный момент, на который стоит обратить внимание: конкурентная итерация также взаимно исключает заимствующую итерацию. Заимствующий итератор полагается на то, что выдаваемые элементы имеют последовательные времена жизни, в то время как конкурентные времена жизни полагаются на то, что выдаваемые элементы имеют перекрывающиеся времена жизни. Это принципиально несовместимые концепции.
Заключение
И это каждый вариант итератора, о котором я знаю, что доводит нас до 17 различных вариаций. Если убрать варианты, которые мы можем выразить с помощью подтрейтов (4 варианта) и автотрейтов (5 вариантов), у нас останется 9 различных вариантов. Это 9 вариантов, с 76 методами и примерно 150 реализациями трейтов в стандартной библиотеке. Это большая поверхность API, и это даже не учитывая все различные комбинации итераторов.
Iterator, вероятно, самый сложный трейт в языке. Это перекрёсток в языке, где пересекаются каждый эффект, автотрейт и функция времени жизни. И в отличие от подобных перекрёстков, таких как семейство трейтов Fn; трейт Iterator стабилен, широко используется и имеет множество комбинаторов. Это означает, что он имеет как широкую область применения, так и строгие гарантии обратной совместимости, которые нам необходимо поддерживать.
В то же время Iterator также не так уж и особенный. В конце концов, это довольно простой трейт для написания вручную. Я в основном думаю о нём как о канарейке для общеязыковых недостатков. Iterator, например, не уникален в своём требовании стабильных адресов: мы хотим иметь возможность гарантировать это для произвольных типов и использовать это с произвольными интерфейсами. Я считаю, что вопрос, который следует задать здесь, звучит так: что мешает нам использовать чувствительные к адресу типы с произвольными типами и произвольными интерфейсами? Если мы сможем ответить на это, у нас будет не только ответ для Iterator — мы решим эту проблему для всех других интерфейсов, которые мы сознательно не предвидели, что захотят взаимодействовать с этим8.
В этом посте я сделал всё возможное, чтобы на примерах показать, какие ограничения имеет сегодня трейт Iterator и как каждый вариант может их преодолеть. И хотя я считаю, что мы должны пытаться со временем устранить эти ограничения, я не думаю, что кто-то слишком жаждет, чтобы мы создали 9 новых вариантов подмодуля core::iter. Или тысячу возможных комбинаций этих подмодулей (ура, комбинаторика). Единственный осуществимый подход, который я вижу для навигации в пространстве проблем, — это расширение, а не замена трейта Iterator. Вот мои текущие мысли о том, как мы могли бы расширить Iterator для поддержки всех вариантов итераторов:
- базовый трейт: по умолчанию, уже поддерживается
- dyn-совместимый: по умолчанию, уже поддерживается
- ограниченный: подтрейт, уже поддерживается
- объединённый: подтрейт, уже поддерживается
- потокобезопасный: автотрейт, уже поддерживается
- позиционируемый: подтрейт
- времени компиляции: полиморфизм по эффекту (const)9
- заимствующий: время жизни
'move10 - с возвращаемым значением: не уверен11
- с аргументом в next: значение по умолчанию + опциональный/вариативный аргумент
- с досрочным завершением: полиморфизм по эффекту (try)
- чувствительный к адресу: автотрейт
- с гарантированным разрушением: автотрейт
- асинхронный: полиморфизм по эффекту (async)
- конкурентный: новый вариант трейта (ов)
Развивая язык, я считаю, что вся наша работа заключается в балансировании разработки функций и управления сложностью. Если всё сделано правильно, со временем язык должен не только становиться более способным, но и проще и легче для расширения. Чтобы процитировать то, что TC (T-Lang) сказал в недавнем разговоре: «Мы должны становиться лучше в том, чтобы становиться лучше каждый год»12.
Размышляя о том, как мы хотим преодолеть вызовы, представленные в этом посте, я искренне надеюсь, что мы всё больше начнём думать о способах решения классов проблем, которые просто сначала появляются с Iterator. В то же время ища возможности выпускать функции раньше, не блокируя себя на поддержке всех случаев использования сразу.
Примечания
-
Я основываю это на своём опыте участия в рабочей группе Node.js Streams WG в середине 2010-х. По моим подсчётам, Node.js теперь имеет пять различных поколений абстракций потоков. Неудивительно, что это не особенно приятно в использовании, и в идеале этого следует избегать в Rust. Хотя я и не против проведения устаревания основных трейтов в Rust, я считаю, что для этого мы хотим приблизиться к 100% уверенности, что это будет последнее устаревание, которое мы сделаем. Если мы собираемся отправиться в путешествие длиной более десяти лет с проблемами миграции, мы должны быть абсолютно уверены, что оно того стоит. ↩
-
Признаюсь, мои знания о
dynв лучшем случае отрывочны. Из всего в этом посте раздел оdynбыл тем, о чём у меня было наименьшее представление. ↩ -
Нет, файлы в
/proc— это не «обычные файлы». Да, я знаю, что сокеты технически являются файловыми дескрипторами. ↩ -
Помните, что строки выделяются в куче, поэтому нам не нужно учитывать чувствительность к адресу. Хотя компилятор ещё не может выполнить эту десугаризацию, в языке нет ничего, что запрещало бы это. ↩
-
Под этим я подразумеваю: ошибку, а не панику. ↩
-
Это также затрагивает локальные переменные, выделенные в куче, но это не ограничение языка, только реализации. ↩
-
Технически этот трейт называется
ConcurrentStream, но здесь мало что зависит отStream. Я назвал его так, потому что он совместим с трейтомfutures_core::Stream, посколькуfutures-concurrencyпредназначен быть производственным крейтом. ↩ -
Это смежно с «известными неизвестными» и «неизвестными неизвестными» — мы не должны обслуживать только случаи, которые можем предвидеть, но и случаи, которые не можем. И это требует анализа паттернов и мышления в системах. ↩
-
Сам эффект
constуже полиморфен по отношению к эффекту времени компиляции, посколькуconst fnозначает: «функция, которая может быть выполнена либо во время компиляции, либо во время выполнения». Из всех вариантов эффектовconst, скорее всего, произойдёт в ближайшей перспективе. ↩ -
Мы хотим выразить, что у нас есть ассоциированный тип, который МОЖЕТ принимать время жизни, а НЕ ДОЛЖЕН принимать время жизни. Таким образом, мы можем передать тип по значению там, где в противном случае ожидается передача типа по ссылке. Это отличается как от времён жизни
'static, так и от ссылок&own. ↩ -
Я пытался ответить, как добавить возвращаемые значения в трейт
Iterator, год назад в своём посте Iterator as an Alias, но у меня не получилось. Как я упоминал ранее в посте: сочетание «возврат со значением» и «может досрочно завершаться с ошибкой» кажется сложным. Возможно, здесь есть комбинация, которую я упускаю / мы можем как-то особо обработать. Но я ещё этого не видел. ↩ -
Я недавно заметил TC, что начал думать об управлении и эволюции языка как о производной языка и проекта. Вместо того чтобы измерять прямые результаты, мы измеряем процесс, который производит эти результаты. TC заметил, что нас на самом деле должна волновать вторая производная. Мы должны не только улучшать наши результаты со временем; процессы, которые производят эти результаты, также должны улучшаться со временем. Или, иными словами: мы должны становиться лучше в том, чтобы становиться лучше каждый год! Мне нравится эта цитата, и я хотел, чтобы вы тоже её прочитали. ↩
Размышления о названиях трейтов итераторов
— 2025-01-20 Yoshua Wuyts Источник
- глаголы, существительные и трейты
- глагол для итерации
- сбор элементов
- асинхронность
- заключение
В конце моего предыдущего поста я упомянул, что одна из главных проблем с трейтом IntoIterator — его довольно неприятно писать. Меня не было рядом, когда его впервые представили, но нетрудно понять, что первоначальные авторы предполагали, что Iterator будет основным интерфейсом, а IntoIterator — дополнительным удобством.
Однако так получилось не совсем, и общепринятой практикой стало использование IntoIterator как в ограничениях, так и в имплементациях. В редакции Rust 2024 мы меняем тип диапазона, чтобы он реализовывал IntoIterator, а не Iterator.1 И, например, в Swift эквивалентный трейт IntoIterator (Sequence) является основным интерфейсом, используемым для итерации. В то время как интерфейс, эквивалентный Iterator (IteratorProtocol), имеет гораздо более сложное для использования название.
Так если не Iterator, то какое имя мы могли бы использовать? Что ж, недавно я написал небольшую библиотеку под названием Iterate, которая пытается ответить на этот вопрос. Позвольте мне провести вас по ней.
Примечание: этот пост предназначен для публичного исследования, а не для конкретного предложения. Это отправная точка, задающая вопрос: «... а что, если?» Я твёрдо выступаю за то, чтобы делиться идеями публично, особенно если они ещё не до конца проработаны. На это есть много веских причин, но прежде всего: я думаю, это весело!
Глаголы, существительные и трейты
В Rust большинство интерфейсов используют глаголы в качестве своих названий. Чтобы читать байты из потока, вы используете трейт с названием Read. Чтобы записывать байты — Write. Чтобы отлаживать что-то — Debug. А для вычисления чисел вы можете использовать Add, Mul (умножение) или Sub (вычитание). Большинство трейтов в стандартной библиотеке Rust используются для выполнения конкретных операций, и для этого принято использовать глаголы.
В стандартной библиотеке есть одна особенно интересная пара в виде Hash (глагол) и Hasher (существительное). Из документации: «Типы, реализующие Hash, могут быть хешированы с помощью экземпляра Hasher». Или, иными словами: трейт Hash представляет операцию, а трейт Hasher представляет состояние.
#![allow(unused)] fn main() { /// Тип, который можно хешировать. pub trait Hash { fn hash<H: Hasher>(&self, state: &mut H); } /// Представляет состояние, которое изменяется при хешировании данных. pub trait Hasher { fn finish(&self) -> u64; fn write(&mut self, bytes: &[u8]); } }
Глагол для итерации
Что на самом деле представляет трейт IntoIterator — это «Итерируемый тип». А трейт Iterator можно разумно описать как: «Состояние, которое изменяется при итерации по элементам». Разделение глагол/существительное, присутствующее в Hash/Hasher, кажется, легко применимо и к итерации.
Если Iterator — это существительное, представляющее состояние итерации, то что же будет глаголом, представляющим способность? Очевидный выбор — Iterate. Что, как мне кажется, в итоге работает довольно неплохо. Чтобы итерировать элементы, вы реализуете Iterate, который предоставляет вам состояние Iterator.
#![allow(unused)] fn main() { /// Итерируемый тип. pub trait Iterate { type Item; type Iterator: Iterator<Item = Self::Item>; fn iterate(self) -> Self::Iterator; } /// Представляет состояние, которое изменяется при итерации. pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
С нашей целью сделать использование IntoIterator в интерфейсах менее раздражающим, название Iterate кажется не таким уж плохим. И оно аккуратно следует существующему разделению глагол/существительное, которое мы уже используем в других местах стандартной библиотеки.
Сбор элементов
Люди, знакомые со стандартной библиотекой Rust, быстро заметят, что Iterator и IntoIterator — не единственные трейты итерации в использовании. У нас также есть FromIterator, который функционирует как обратный к IntoIterator. Где один существует для преобразования типов в итераторы, другой — для преобразования итераторов обратно в типы. Последний обычно используется через функцию Iterator::collect.
Но у IntoIterator есть менее известный, но столь же полезный собрат: Extend. Где IntoIterator собирает элементы в новые экземпляры типов, трейт Extend используется для сбора элементов в существующие экземпляры типов. Было бы довольно странно переименовать IntoIterator в Iterate, но оставить FromIterator как есть. Что, если вместо того, чтобы рассматривать FromIterator как двойник IntoIterator, мы будем считать его собратом Extend. Очевидным глаголом для этого был бы Collect:
#![allow(unused)] fn main() { /// Создаёт коллекцию с содержимым итератора. pub trait Collect<A>: Sized { fn collect<T>(iter: T) -> Self where T: Iterate<Item = A>; } }
Интересно отметить, что тип T в FromIterator ограничен IntoIterator, а не Iterator. Возможность использовать T: Iterate в качестве ограничения здесь определённо кажется немного приятнее. И, говоря о приятном: это также сделает предоставленные методы Iterator::collect и Iterator::collect_into немного лучше:
#![allow(unused)] fn main() { /// Представляет состояние, которое изменяется при итерации. pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; /// Создать коллекцию с содержимым этого итератора. fn collect<C>(self) -> C where C: Collect<Self::Item>, Self: Sized, /// Расширить коллекцию содержимым этого итератора. fn collect_into<E>(self, collection: &mut E) -> &mut E where E: Extend<Self::Item>, Self: Sized; } }
Мне это не кажется плохим. И, честно говоря: это также может быть более последовательным в целом, поскольку трейты, представляющие другие эффекты, не имеют эквивалента FromIterator. Трейт Future имеет только IntoFuture, а варианты этого в экосистеме, такие как Race. Отсутствие трейта с названием FromIterator помогло бы устранить некоторую путаницу.
Асинхронность
Полагаю, мы затронули тему асинхронности, так что, наверное, стоит продолжить. Мы добавили трейт IntoFuture в Rust в 2022 году, потому что хотели получить эквивалент IntoIterator, но для эффекта async. Некоторые мотивирующие случаи использования этого можно найти в моём посте async builders (2019). Мы выбрали название IntoFuture, потому что оно соответствовало существующему соглашению, установленному IntoIterator/Iterator.
У нас уже есть Try для ошибочности, мы только что обсудили использование Iterate для итерации, каким было бы название трейта на основе глагола для асинхронности? Очевидный выбор — что-то вроде Await, так как это название операции:
#![allow(unused)] fn main() { trait Await { type Output; type Future = Future<Output = Self::Output>; fn await(self) -> Self::Future; } }
Однако это сталкивается с одним серьёзным ограничением: await — зарезервированное ключевое слово, что означает, что мы не можем использовать его в качестве названия метода. А значит, я не уверен, как на самом деле следует называть этот трейт. С итераторами нам повезло, что у нас нет недостатка в связанных словах: loop, iterate, generate, while, sequence и так далее. С async у нас слов немного меньше. Если у кого-то есть хорошие идеи для глаголов, которые можно здесь использовать, я буду рад услышать предложения!
Заключение
TLDR: Я бы совсем не возражал, если бы Iterate было основным названием интерфейса для итерации в Rust. Кажется, это было бы шагом вперёд по сравнению с написанием IntoIterator в ограничениях повсюду. Просто изменив название, без необходимости в каких-либо специальных новых языковых возможностях.
Теперь относительно того, следует ли нам вносить это изменение:... возможно? Честно говоря, я не знаю. Это не просто вопрос введения простого псевдонима трейта: названия методов и ассоциированные типы также различны, и мы не можем создать для них псевдонимы. И я не особо заинтересован в том, чтобы Rust начал экспериментировать с дополнительными иерархиями трейтов здесь. Итерация и так достаточно сложна, дополнительные супертрейты не сделают её проще.
#![allow(unused)] fn main() { //! Переименования и создания псевдонима для трейта недостаточно, //! названия методов и ассоциированных типов также нужно будет заменить. pub trait Iterate { .. } pub trait IntoIterator = Iterate; }
Так что я думаю, единственный способ, при котором это переименование действительно имело бы смысл, — если бы процесс внесения подобных изменений сделал бы это изменение лёгким. Я не верю, что это легко сегодня, но я определённо считаю, что мы должны стремиться к тому, чтобы это стало легко в будущем. Было бы здорово, если бы мы могли свободно переименовывать трейты, методы и, возможно, даже типы в разных редакциях, не вызывая поломок.
В любом случае: мне было очень весело писать этот пост. Если вы хотите попробовать трейт Iterate сегодня, чтобы лучше его прочувствовать, — загляните в крейт iterate-trait. В нём есть всё, что я описал в этом посте, а также комбинаторы итераторов, такие как map. Вероятно, не стоит использовать его для чего-то серьёзного, но определённо повеселитесь с ним.
Примечания
-
Спасибо Лукасу Вирту (Lukas Wirth) за то, что указал, что изменение типа диапазона в итоге не попало в редакцию. Прошло пару месяцев с тех пор, как я проверял, и, кажется, его убрали для этой редакции. Насколько я понимаю, это изменение всё ещё желательно и может попасть в будущую редакцию. ↩
Проблема с автотрейтами в gen
— 2025-01-13 Yoshua Wuyts Источник
- утечка автотрейтов из тел
gen - предотвращение утечки
- как насчёт других эффектов и автотрейтов?
- заключение
Один из открытых вопросов, связанных с нестабильной функцией gen {}, — должен ли он возвращать Iterator или IntoIterator. У людей было ощущение, что могут быть веские причины для возврата IntoIterator, но они не обязательно могли их четко сформулировать. Вот почему это было включено в раздел «нерешённых вопросов» RFC по блокам gen.
Поскольку я хотел бы, чтобы блоки gen {} стабилизировались скорее, я подумал, что стоит потратить некоторое время на изучение этого вопроса и выяснить, есть ли причины выбрать одно вместо другого. И я обнаружил то, что считаю довольно неприятной проблемой с возвратом Iterator из gen, которую я начал называть проблемой автотрейтов в gen. В этом посте я расскажу, в чём заключается эта проблема, а также о том, как возврат IntoIterator из gen предотвратил бы её. Итак, без дальнейших церемоний, давайте погрузимся!
Утечка автотрейтов из тел gen
Проблема, которую я обнаружил, связана с реализациями автотрейтов для конкретизированных экземпляров gen {}. Возьмём API thread::spawn: он принимает замыкание, которое возвращает тип T. Если вы раньше не видели его определение, вот оно:
#![allow(unused)] fn main() { pub fn spawn<F, T>(f: F) -> JoinHandle<T> where F: FnOnce() -> T + Send + 'static, T: Send + 'static, }
Эта сигнатура утверждает, что замыкание F и тип возврата T должны быть Send. Но она не утверждает, что все локальные значения, созданные внутри замыкания F, также должны быть Send, — что часто встречается в асинхронном мире. Теперь давайте создадим итератор с помощью блока gen {}, который мы попытаемся отправить между потоками. Мы можем создать итератор, который выдаёт одно u32:
#![allow(unused)] fn main() { let iter = gen { yield 12u32; }; }
Теперь, если мы попытаемся передать это в thread::spawn, проблем не возникнет. Наш итератор реализует + Send, и всё будет работать как ожидалось:
#![allow(unused)] fn main() { // ✅ Ok thread::spawn(move || { for num in iter { println("{num}"); } }).unwrap(); }
Теперь, чтобы показать вам проблему, давайте попробуем снова, но на этот раз с блоком gen {}, который внутри создаёт std::rc::Rc. Этот тип !Send, что означает, что любой тип, который содержит его, также будет !Send. Это означает, что iter в нашем примере теперь больше не является потокобезопасным:
#![allow(unused)] fn main() { let iter = gen { // `-> impl Iterator + !Send` let rc = Rc::new(...); yield 12u32; rc.do_something(); }; }
Это означает, что если мы попытаемся отправить его между потоками, мы получим ошибку компилятора:
#![allow(unused)] fn main() { // ❌ нельзя отправить тип `!Send` между потоками thread::spawn(move || { // ← `iter` перемещается for num in iter { // ← `iter` используется println("{num}"); } }).unwrap(); }
И вот в чём проблема. Несмотря на то, что итераторы ленивые и Rc в нашем блоке gen {} фактически не создаётся до начала итерации в новом потоке, генераторный блок должен резервировать для него место внутри, и поэтому он наследует ограничение !Send. Это приводит к несовместимостям, когда локальные переменные, определённые полностью внутри самого gen {}, влияют на публичный API. Это оказывается очень тонким и сложным для отладки, если вы уже не знакомы с десугарингом генераторов.
Предотвращение утечки
Решить проблему автотрейтов в gen не так уж сложно. Мы хотим, чтобы поля !Send в генераторе не появлялись в сгенерированном типе до тех пор, пока мы не будем готовы начать итерацию по нему. Звучит немного страшно, но на практике всё, что нам нужно сделать, — это заставить gen {} возвращать impl IntoIterator, а не impl Iterator. Сам Iterator всё равно будет !Send, но наш тип IntoIterator будет Send:
#![allow(unused)] fn main() { let iter = gen { // `-> impl IntoIterator + Send` let rc = Rc::new(...); yield 12u32; rc.do_something(); }; }
Поскольку наше значение iter теперь реализует Send, мы можем без проблем передавать его через границы потоков. И наш код продолжает работать как ожидалось, поскольку for..in работает с IntoIterator, который реализован для всех Iterator:
#![allow(unused)] fn main() { // ✅ Ok thread::spawn(move || { // ← `iter` перемещается for num in iter { // ← `iter` используется println("{num}"); } }).unwrap(); }
Если подумать, с теоретической точки зрения это также имеет много смысла. Мы можем рассматривать for..in как наш способ обработки эффекта итерации, который ожидает IntoIterator. gen {} — это двойник этого, используемый для создания новых экземпляров эффекта итерации. Совсем не странно, что он возвращает тот же трейт, который ожидает for..in. С дополнительным бонусом в том, что он не просачивает автотрейты из тел реализаций в сигнатуру типа.
Как насчёт других эффектов и автотрейтов?
Эта проблема в первую очередь затрагивает эффекты, которые используют преобразование генератора. А именно: итерацию и асинхронность. Теоретически я считаю, что да, async {}, вероятно, должен возвращать IntoFuture, а не Future. В рабочей группе Async мы регулярно видим проблемы, связанные с утечкой автотрейтов из тел функций в сигнатуры типов. Если бы async {} (2019) возвращал IntoFuture (2022), а не Future (2019), это, определённо, могло бы помочь. Хотя сегодня, когда всё уже стабильно, было бы сложнее внести такое изменение.
Со стороны трейтов это затрагивает не только Send: это относится ко всем автотрейтам, настоящим и будущим. Хотя Send — безусловно, самый распространённый трейт, с которым люди сталкиваются сегодня, в меньшей степени это уже относится и к Sync. А в будущем, возможно, и к автотрейтам вроде Freeze, Leak и Move. Хотя это не должно быть главным мотиватором, предотвращение потенциальных будущих проблем тоже небесполезно.
Заключение
Признаюсь, худшая часть возврата IntoIterator из gen {} — это то, что название трейта откровенно неудачное. IntoIterator звучит как вспомогательный по отношению к Iterator, и поэтому кажется немного неправильным делать его тем, что мы возвращаем из gen {}. Но помимо этого: это много символов для написания.
Интересно, что бы произошло, если бы мы взяли подход, более похожий на Swift. В Swift есть Sequence, который похож на IntoIterator в Rust, и IteratorProtocol, который похож на Iterator в Rust. Основной интерфейс, который люди должны использовать, короткий и запоминающийся. В то время как вторичный интерфейс не предназначен для прямого использования, поэтому у него гораздо более длинное и менее запоминающееся название. Поскольку мы всё чаще думаем об IntoIterator как об основном интерфейсе для асинхронной итерации, возможно, в будущем мы захотим пересмотреть схему именования трейтов.
В заключение: кажется хорошей идеей предотвратить утечку автотрейтов из тел gen при конкретизации. Это как раз та проблема, которую, если мы можем предотвратить, мы должны предотвратить, поскольку её может быть трудно диагностировать и сложно обойти. Тот факт, что утечка автотрейтов не является проблемой для блоков-генераторов, кажется стоящим.
Что такое временная и пространственная безопасность памяти?
— 2024-12-15 Yoshua Wuyts Источник
Прекрасные люди из Google, работающие над безопасностью, недавно пишут о «временной (памяти) безопасности» (temporal memory safety) и «пространственной (памяти) безопасности» (spatial memory safety). Когда я впервые увидел эти термины, мне потребовалась минутка, чтобы понять, что они означают, поскольку поиск в интернете не дал мгновенных ответов. Поэтому я подумал, что, возможно, будет полезно записать это для других:
- Пространственная безопасность памяти (spatial memory safety): описывает нарушения, такие как выход за границы. Например, у вас есть вектор из 10 элементов. Попытка чтения из места в памяти несуществующего 11-го элемента — это неопределённое поведение. Можно думать об этом как о нарушениях, связанных с областями памяти (пространство).
- Временная безопасность памяти (temporal memory safety): описывает нарушения, такие как использование после освобождения (use-after-free). Например, у вас есть тип, который уже был деинициализирован («уничтожен» в Rust). Попытка затем прочитать любое из его полей — это неопределённое поведение. Можно думать об этом как о нарушениях, связанных с порядком операций с памятью (время).
Моя единственная претензия к этим терминам в том, что я иногда видел, как люди опускают квалификатор «памяти». Я думаю, это одна из причин, по которой эти термины могут сбивать с толку: существует множество других свойств безопасности¹, связанных с расположением или порядком, которые можно моделировать и которые не имеют ничего общего с безопасностью памяти. Беглый просмотр записей о Временной логике (Temporal Logic) или TLA+ должен прояснить это².
Моя просьба заключается в том, чтобы при обсуждении пространственной безопасности памяти и временной безопасности памяти ссылаться на них полностью. Не пытайтесь сокращать их, опуская квалификатор «памяти». Потому что при обсуждении формально смоделированных свойств определённо стоит быть конкретным в отношении того, что именно вы пытаетесь гарантировать.
Примечания
Почему Pin является частью сигнатур трейтов (и почему это проблема)
— 2024-10-15 Yoshua Wuyts Источник
- почему Pin является частью сигнатур методов
- последствия
- заключение
Некоторое время я задавался вопросом, почему метод Future::poll принимает self: Pin<&mut Self> в своей сигнатуре. Я предполагал, что на это должна быть веская причина, но когда я спросил своих коллег из рабочей группы Async, никто, кажется, не знал навскидку, почему именно так. Или, может быть, они знали, а мне просто было трудно понять. В любом случае, я думаю, что разобрался, и хочу изложить это для потомков, чтобы другие тоже могли понять.
Почему Pin является частью сигнатур методов
Возьмём, к примеру, тип MyType и трейт MyTrait. Мы можем написать реализацию MyTrait, которая доступна только тогда, когда MyType закреплён:
#![allow(unused)] fn main() { trait MyTrait { fn my_method(&mut self) {} } struct MyType; impl<T> MyTrait for Pin<&mut MyType> {} }
Внутри функций мы даже можем написать ограничения для этого. Отдельное спасибо Эрику Холку (Eric Holk), который показал мне, что, оказывается, левая часть ограничений трейта может содержать произвольные типы — не только обобщённые типы или даже типы, являющиеся частью сигнатуры функции. Я не знал.
С этим мы можем выразить, что мы принимаем некоторый тип T по значению, и как только мы закрепим это значение, оно будет реализовывать MyTrait:
#![allow(unused)] fn main() { fn my_function<T>(input: T) where for<'a> Pin<&'a mut T>: MyTrait, { let pinned_input = pin!(input); } }
Внутри MyTrait::my_method тип self будет &mut Pin<&mut Self>. Это не то же самое, что владеемый тип Pin<&mut Self>, но, к счастью, мы можем преобразовать это во владеемый тип, вызвав Pin::as_mut. Документация содержит большое объяснение, почему здесь безопасно переходить от изменяемой ссылки к владеемому экземпляру, что интуитивно противоречит правилам владения Rust.
Но что произойдёт теперь, если вместо написания обобщённого типа T с условием where мы захотим использовать impl trait в ассоциированной позиции (APIT). Мы можем захотеть написать что-то вроде этого:
#![allow(unused)] fn main() { // как мы выражаем те же самые ограничения здесь? fn my_function<T>(input: impl ???) { let pinned_input = pin!(input); } }
Но у нас нет возможности выразить это точное ограничение. В отличие от обычных обобщённых типов, APIT не могут выражать левую часть ограничения (lvalue), они могут называть только правую часть (rvalue). Это становится ещё более заметным, когда мы пытаемся использовать impl Trait в позиции возврата (RPIT).
Возьмём, к примеру, функцию, которая возвращает некоторый тип T. Используя конкретные ограничения трейтов, мы можем выразить, что она возвращает тип, который при закреплении реализует MyTrait:
#![allow(unused)] fn main() { fn my_function<T>() -> T where for<'a> Pin<&'a mut T>: MyTrait, { MyType {} } }
Но если мы попытаемся выразить ту же функцию, используя RPIT, мы теряем возможность выразить это ограничение. Единственное решение для выражения -> impl Trait, который раскрывает функциональность при закреплении, — это сделать Pin непосредственно частью сигнатуры методов и не реализовывать трейт для Pin<&mut Type>:
#![allow(unused)] fn main() { trait MyTrait { fn my_method(self: Pin<&mut Self>) {} // ← обратите внимание на сигнатуру self здесь } struct MyType; impl MyTrait for MyType {} // ← больше не реализован для `Pin<&mut MyType>` }
И теперь внезапно мы можем выразить -> impl MyTrait, методы которого можно вызывать только тогда, когда MyType закреплён. Unpin является отказом для типов, для которых это не так.
#![allow(unused)] fn main() { fn my_function() -> impl MyTrait { // Может быть как закреплённым, так и нет! MyType {} } }
Последствия
Конкретно это означает, что если вы хотите иметь трейт, который хочет работать с закреплёнными значениями и работать со всеми языковыми возможностями как обычно, вы должны использовать self: Pin<&mut Self> как часть сигнатуры метода. Может быть, это не большая проблема для новых трейтов, но это имеет последствия для каждого существующего трейта в стандартной библиотеке.
Возьмём, к примеру, трейт Iterator. Мы не можем просто написать impl Iterator for Pin<&mut T> и ожидать, что RPIT будет работать. Вместо этого ожидаемый путь здесь, кажется, должен заключаться во введении нового трейта PinnedIterator, который принимает self: Pin<&mut T>. Это обратно несовместимое изменение, общее для всех существующих трейтов в стандартной библиотеке, за исключением Future, который уже принимает self: Pin<&mut Self>. Это довольно большое ограничение, и его стоит учитывать в обсуждениях о жизнеспособности Pin за пределами Future. Для Iterator это означает, что мы захотим создать как минимум следующие варианты:
#![allow(unused)] fn main() { // итератор trait Iterator { type Item; fn next(&mut self) -> Option<Item>; } // чувствительный к адресу итератор trait PinnedIterator { type Item; fn next(self: Pin<&mut self>) -> Option<Item>; } }
Чтобы пробежаться по ещё нескольким последствиям этого: если мы хотим, чтобы пользователи Rust могли объявлять чувствительные к адресу типы в Rust, то наиболее вероятный путь сейчас — дублирование трейтов в подмодуле std::io, принимающее форму, похожую на эту:
#![allow(unused)] fn main() { mod io { pub trait Read { ... } pub trait PinnedRead { ... } pub trait Write { ... } pub trait PinnedWrite { ... } pub trait Seek { ... } pub trait PinnedSeek { ... } pub trait BufRead { ... } pub trait PinnedBufRead { ... } } }
Закрепление (Pinning), как async и try, является комбинаторным свойством трейтов, которое приводит к экспоненциальному количеству дублирования. К счастью для нас, дублирование трейтов — не единственный возможный путь, который мы можем выбрать: некоторая форма полиморфизма существующих интерфейсов относительно Pin также кажется возможной — если мы готовы изменить нашу формулировку. Это то, что привело меня к формулировке моего дизайна для автотрейта Move, который является композируемым, как, например, Send и Sync.
Заключение
Я хочу быстро поблагодарить моих коллег из рабочей группы Async. Мы много говорили об этом, и совместная работа над этим была полезной. Даже если у меня ушло пару месяцев, чтобы наконец опубликовать это. Любые ошибки в этом посте, однако, определённо мои собственные.
В этом посте я в основном хотел объяснить, почему Future принимает self: Pin<&mut Self>, а не &mut self, и полагается на impl Future for Pin<&mut T>. Я думаю, я нашёл хорошую причину для этого, и она снова связана с левой и правой частями ограничений. Для меня это также подтверждает мою гипотезу о том, что любой дизайн для обобщённых самоссылающихся типов должен уметь учитывать следующее:
- Возможность пометить тип как неперемещаемый
- Возможность переводить типы из перемещаемых в неперемещаемые
- Возможность конструировать неперемещаемые типы на месте
- Возможность расширять существующие интерфейсы с учётом неперемещаемости
- Возможность описывать самоссылающиеся времена жизни
- Возможность безопасно инициализировать самоссылки без
Option
Этот пост специально затрагивал четвёртое требование: возможность расширять существующие интерфейсы с учётом неперемещаемости. Решение этого может принимать форму дублирования интерфейсов (например, Iterator против PinnedIterator) или композиции через (авто-)трейты (например, Move). Другие методы также могут быть возможны, и я бы призвал людей с идеями делиться ими.
PoignardAzur независимо описал, почему и как закреплённые типы должны быть способны конструироваться на месте. Его пост показал примеры как третьего, так и шестого требований в списке. Он представил форму размещения с помощью нотации -> pin Type. Это похоже на более общую нотацию -> super Type, которую я представил в своём посте, адаптированную из поста Мары (Mara) о super let.
Я надеюсь, что этот пост поможет хотя бы частично прояснить, почему Pin должен быть частью интерфейсов. А также поможет изложить некоторые логические последствия, как только мы рассмотрим, как это взаимодействует со строгими требованиями обратной совместимости стандартной библиотеки. Потому что я считаю, что мы можем и должны сделать лучше, чем дублировать целые интерфейсы по оси неперемещаемости.
Дальнейшее упрощение самоссылающихся типов для Rust
— 2024-07-08
Yoshua WuytsИсточник
- не все самоссылающиеся типы являются !Move
- время жизни 'self недостаточно
- автоматическая стабильность ссылок
- операции с сырыми указателями и автоматическая стабильность ссылок
- Relocate, вероятно, должен принимать &own self
- переработанный мотивирующий пример
- когда неперемещаемые типы всё ещё нужны
- заключение
В моём предыдущем посте я обсуждал, как мы могли бы ввести эргономичные самоссылающиеся типы (SRT) в Rust, в основном за счёт внедрения функций, которые, как мы знаем, всё равно хотим в той или иной форме. Перечисленные функции были:
- Какая-либо форма времён жизни
'unsafeи'self. - Безопасная нотация указателей на выход (out-pointer) для Rust (
super let/-> super Type). - Способ введения указателей на выход без нарушения обратной совместимости.
- Новый автотрейт
Move, который можно использовать для пометки типов как неперемещаемых (!Move). - Типы-представления (view types), которые делают возможной безопасную инициализацию самоссылающихся типов.
Этот пост был воспринят довольно хорошо, и я подумал, что последовавшее обсуждение было весьма интересным. Я узнал о нескольких вещах, которые, как мне кажется, помогли бы дополнительно улучшить дизайн, и подумал, что было бы хорошо записать это.
Не все самоссылающиеся типы являются !Move
Нико Мацакис (Niko Matsakis) отметил, что не все самоссылающиеся типы обязательно являются !Move. Например: если данные, на которые ссылаются, размещены в куче, то тип на самом деле не должен быть !Move. При написании парсеров протоколов на самом деле довольно часто сначала считывают данные в тип, размещённый в куче. Вероятно, что значительное количество самоссылающихся типов на самом деле не нуждаются в !Move или вообще в каком-либо понятии Move для функционирования. Что также означает, что нам не нужна какая-либо форма super let / -> super Type для конструирования типов на месте.
Если мы просто хотим включить самоссылки для типов, размещённых в куче, то всё, что нам для этого нужно, — это способ их инициализировать (типы-представления) и возможность описывать самовремена жизни (минимум 'unsafe). Это должно дать нам хорошее представление о том, что мы можем расставить по приоритетам, чтобы начать включать ограниченную форму самоссылок.
Время жизни 'self недостаточно
Говоря о временах жизни, Mattieum указал, что 'self, вероятно, будет недостаточно. 'self указывает на всю структуру, что в итоге оказывается слишком грубым для практического использования. Вместо этого нам нужно иметь возможность указывать на отдельные поля для описания времён жизни.
Оказывается, Нико также придумал функцию для этого в виде времён жизни, основанных на местах (places). Вместо того чтобы иметь абстрактные времена жизни, такие как 'a, которые мы используем для связи со значениями, было бы лучше, если бы ссылки всегда имели неявные уникальные имена времён жизни. Имея доступ к этому, мы должны переписать мотивирующий пример из нашего предыдущего поста, основанный на 'self:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, // ← Обратите внимание на время жизни `'self` } }
В пример, основанный на путях:
#![allow(unused)] fn main() { struct GiveManyPatsFuture { resume_from: GivePatsState, first: Option<String>, data: Option<&'self.first str>, // ← Обратите внимание на время жизни `'self.first` } }
В этом примере это может показаться не столь важным; но как только мы введём изменяемость, всё быстро усложнится. И не введение магического 'self в пользу постоянного требования 'self.field, кажется, в целом было бы лучше. А для этого требуются времена жизни, которые могут быть основаны на местах, что, кажется, отличной идеей независимо ни от чего.
Автоматическая стабильность ссылок
Ранее в этом посте мы установили, что нам на самом деле не нужно кодировать !Move для самоссылающихся типов, которые хранят свои значения в куче. Это не все самоссылающиеся типы — но описывает значительное их количество. А что, если бы нам не нужно было кодировать !Move почти для всего оставшегося множества самоссылающихся типов?
Если это звучит как конструкторы перемещения, вы были бы правы — но с оговоркой! В отличие от трейта Relocate, который я описал в своём предыдущем посте, DoveOfHope отметил, что нам, возможно, даже не нужно, чтобы это работало. В конце концов: если компилятор уже знает, что мы указываем на поле, содержащееся в структуре, — разве компилятор не может гарантировать обновление указателей, когда мы пытаемся переместить структуру?
Я скептически относился к возможности этого, пока не прочитал о временах жизни, основанных на местах. С этим, кажется, у нас действительно было бы достаточно детализации, чтобы знать, как обновлять какие поля при их перемещении. С точки зрения стоимости: это просто обновление значения указателя при перемещении — что практически бесплатно. И это почти полностью избавило бы нас от необходимости кодировать !Move.
Единственные случаи, не охваченные этим, — это ссылки 'unsafe или фактические указатели *const T / *mut T на данные в стеке. Компилятор на самом деле не знает, на что они указывают, и поэтому не может обновить их при перемещении. Для этого какая-либо форма трейта Relocate действительно кажется полезной. Но это то, что тоже не нужно было бы добавлять сразу.
Операции с сырыми указателями и автоматическая стабильность ссылок
Этот раздел был добавлен после публикации, 2024-07-08.
Хотя компилятор должен иметь возможность гарантировать, что генерация кода корректна для, например, mem::swap, мы не можем дать те же гарантии для операций с сырыми указателями, таких как ptr::swap. И поскольку существующие структуры могут свободно использовать эти операции внутри, это означает, что самоссылающиеся типы в стеке не могут просто работать без каких-либо оговорок, как мы можем для SRT в куче. Это действительно проблема, и я хочу поблагодарить The_8472 за то, что указал на это.
Мне очень хотелось избежать дополнительных ограничений, чтобы SRT в стеке могли соответствовать опыту SRT в куче. Но это, кажется, невозможно, поэтому, возможно, минимальное количество ограничений, которые мы можем сделать включёнными по умолчанию (как Sized) в определённой редакции, может быть достаточно, чтобы справиться с этим. В настоящее время я думаю о чём-то вроде:
- Ввести новый автотрейт-маркер
Transferв дополнение кRelocate, как двойник системеDestruct/Dropв Rust.Transfer— это название ограничения, которое люди будут использовать,Relocateпредоставляет хуки для расширения системыTransfer. - Все типы с временем жизни
'selfавтоматически реализуютTransfer. - Только ограничения, включающие
+ Transfer, могут принимать типыimpl Transfer. - Все соответствующие операции перемещения сырых указателей должны соблюдать дополнительные инварианты безопасности того, что делать с типами
impl Transfer. - Мы постепенно обновляем стандартную библиотеку для поддержки
+ Transferво всех ограничениях. - В какой-то редакции мы делаем отказ, а не согласие (
T: Transfer→T: ?Transfer).
#![allow(unused)] fn main() { auto trait Transfer {} trait Relocate { ... } }
Мне очень хотелось избежать чего-то подобного. И это ставит под вопрос, действительно ли это проще, чем неперемещаемые типы. Но the_8472 совершенно прав, что это проблема, и поэтому нам нужно её решать. К счастью, мы уже делали что-то подобное с const. И я не думаю, что мы можем это обобщить. Я напишу об этом подробнее в какой-нибудь другой раз.
Relocate, вероятно, должен принимать &own self
Теперь, даже если мы не ожидаем, что людям понадобится писать свою собственную логику обновления указателей, практически когда-либо, это всё равно должно быть предоставлено. И когда мы это сделаем, мы должны правильно это закодировать. Надриериль (Nadrieril) очень любезно указал, что ограничение &mut self в трейте Relocate может быть не совсем тем, что мы хотим, — потому что мы не просто заимствуем значение — мы фактически хотим его уничтожить. Вместо этого они сообщили мне о работе, проделанной в отношении &own, которая дала бы доступ к так называемым «владеемым ссылкам».
Даниэль Генри-Мантилья (Daniel Henry-Mantilla) — автор крейта stackbox, а также основной ответственный за систему расширения времени жизни, стоящую за макросом pin! в стандартной библиотеке. Некоторое время назад он поделился очень полезным описанием &own. Суть идеи в том, что мы должны разделить понятия: «Где конкретно хранятся данные?» и «Кто логически владеет данными?» В результате возникает идея иметь ссылку, которая не просто предоставляет временный уникальный доступ, — но может получить постоянный уникальный доступ. В своём посте Даниэль любезно предоставляет следующую таблицу:
Семантика для T | Для базового выделения памяти | |
|---|---|---|
&T | Общий доступ | Заимствовано |
&mut T | Эксклюзивный доступ | Заимствовано |
&own T | Владеемый доступ (ответственность за уничтожение) | Заимствовано |
Применяя это к нашему посту, мы бы использовали это, чтобы изменить трейт Relocate с приёма &mut self, который временно получает эксклюзивный доступ к типу, — но не может фактически уничтожить тип:
#![allow(unused)] fn main() { trait Relocate { fn relocate(&mut self) -> super Self; } }
На приём &own, который получает постоянный эксклюзивный доступ к типу и может фактически уничтожить тип:
#![allow(unused)] fn main() { trait Relocate { fn relocate(&own self) -> super Self; } }
редакция 2024-07-08: Этот пример был добавлен позже. Чтобы объяснить, что решает &own, давайте взглянем на пример реализации Relocate из нашего предыдущего поста. В нём мы говорим следующее:
Мы делаем одно сомнительное предположение здесь: нам нужно иметь возможность взять владеемые данные из
self, не сталкиваясь с проблемой, когда данные не могут быть перемещены, потому что они уже заимствованы изself.
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl Cat { fn new(data: String) -> super Self { ... } } impl Relocate for Cat { fn relocate(&mut self) -> super Self { let mut data = String::new(); // ← фиктивный тип, не выделяет память mem::swap(&mut self.data, &mut data); // ← взять владеемые данные super let cat = Cat { data }; // ← создать новый экземпляр cat.name = cat.data.split(' ').next().unwrap(); // ← создать самоссылку cat // ← вернуть новый экземпляр } } }
Что даёт нам &own — это способ правильно закодировать семантику здесь. Поскольку тип не перемещается, мы не можем фактически переместить его по значению. Но логически мы всё ещё хотим получить уникальное владение значением, чтобы мы могли уничтожить тип и переместить отдельные поля. Это своего рода способ, которым перемещение Box по значению тоже работает, но вместо выделения памяти в куче выделение может быть где угодно. С этим мы могли бы переписать довольно сомнительный код mem::swap выше в более нормально выглядящий код деструктуризации + инициализации:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl Cat { fn new(data: String) -> super Self { ... } } impl Relocate for Cat { fn relocate(&mut self) -> super Self { let Self { data, .. } = self; // ← деструктурировать `self` super let cat = Cat { data }; // ← создать новый экземпляр cat.name = cat.data.split(' ').next().unwrap(); // ← создать самоссылку cat // ← вернуть новый экземпляр } } }
Теперь, поскольку это действительно необходимо для создания типов в фиксированных местах памяти, этому трейту потребуется какая-либо форма синтаксиса -> super Self. В конце концов: это было бы единственное место, где это всё ещё было бы нужно. Для всех, кто интересуется последними новостями об &own, вот issue Rust для него (который, кстати, также был открыт Нико).
Переработанный мотивирующий пример
Имея это в виду, мы можем снова переработать мотивирующий пример из предыдущего поста. Чтобы освежить память всем, вот высокоуровневый код на основе async/.await в Rust, который мы хотели бы иметь:
async fn give_pats() { let data = "chashu tuna".to_string(); let name = data.split(' ').take().unwrap(); pat_cat(&name).await; println!("patted {name}"); } async fn main() { give_pats().await; }
Используя обновления в этом посте, мы можем приступить к его десугарингу. На этот раз без необходимости в каких-либо ссылках на Move или конструировании на месте, благодаря временам жизни на основе путей и автоматическому сохранению референциальной стабильности компилятором:
#![allow(unused)] fn main() { enum GivePatsState { Created, Suspend1, Complete, } struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self.data str>, // ← Обратите внимание на время жизни `'self.data` } impl GivePatsFuture { // ← Конструирование на месте не требуется fn new() -> Self { Self { resume_from: GivePatsState::Created, data: None, name: None } } } impl Future for GivePatsFuture { type Output = (); fn poll(&mut self, cx: &mut Context<'_>) // ← `Pin` не требуется -> Poll<Self::Output> { ... } } }
Это значительно проще, чем то, что у нас было раньше, в своём определении. И даже сам десугаринг вызова оказывается проще: больше не нужен промежуточный конструктор IntoFuture для гарантии конструирования на месте.
#![allow(unused)] fn main() { let into_future = GivePatsFuture::new(); let mut future = into_future.into_future(); // ← `pin!` не требуется loop { match future.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
Всё, что нужно для этого, — чтобы компилятор обновлял адреса самоссылок при перемещении. Это небольшое дополнительное количество кодагена всякий раз, когда значение перемещается, — помимо просто битового копирования, ему также нужно обновлять значения указателей. Но это кажется вполне реализуемым, должно работать действительно хорошо и, что наиболее важно: пользователям редко или никогда не нужно будет об этом думать. Написание &'self.field всегда просто будет работать.
Когда неперемещаемые типы всё ещё нужны
Я не хочу полностью отвергать идею неперемещаемых типов. Определённо есть преимущества в наличии типов, которые нельзя перемещать. Особенно при работе со структурами FFI, требующими неперемещаемости. Или некоторые высокопроизводительные структуры данных, которые используют множество самоссылок на данные в стеке, обновление которых было бы слишком дорогостоящим. Случаи использования определённо существуют, но они будут довольно нишевыми. Например: Rust for Linux использует неперемещаемые типы для своих интрузивных связных списков — и я думаю, что им, вероятно, нужна какая-либо форма неперемещаемости для фактической работы.
Однако, если компилятор не требует неперемещаемых типов для предоставления самоссылок, то неперемещаемые типы внезапно переходят от основополагающих к чему-то более близкому к оптимизации. Вероятно, всё ещё стоит добавить их, поскольку они, безусловно, более эффективны. Но если мы сделаем это правильно, добавление неперемещаемых типов будет обратно совместимым и будет тем, что мы можем ввести позже в качестве оптимизации.
Что касается того, должен ли async {} возвращать impl Future или impl IntoFuture: я думаю, ответ действительно должен быть impl IntoFuture. В редакции 2024 мы меняем синтаксис диапазонов (0..12) с возврата Iterator на возврат IntoIterator. Это соответствует поведению Swift, где 0..12 возвращает Sequence, а не IteratorProtocol. Я думаю, это хороший показатель того, что async {} и gen {}, вероятно, также должны возвращать impl Into* трейты, а не свои соответствующие трейты.
Заключение
Мне нравится, когда то, что я пишу, обсуждается, и в итоге я узнаю о другой соответствующей работе. Я думаю, чтобы включить самоссылающиеся типы, я теперь определённо склоняюсь к форме встроенного обновления указателей как части языка, а не к неперемещаемым типам (редакция: возможно, я поторопился). Однако, если мы действительно хотим неперемещаемые типы — я думаю, мой предыдущий пост предоставляет последовательный и удобный для пользователя дизайн, чтобы достичь этого.
Существует довольно большое количество зависимостей, если мы хотим реализовать полную историю для самоссылающихся типов. К счастью, мы можем внедрять функции по одной, включая всё более выразительные формы самоссылающихся типов. С точки зрения важности: какая-либо форма 'unsafe кажется хорошей отправной точкой. За ней следуют времена жизни на основе мест. Типы-представления кажутся полезными, но не находятся на критическом пути, поскольку мы можем обойти поэтапную инициализацию, используя танец с Option. Вот график всех функций и их зависимостей.
Граф, показывающий различные зависимости между языковыми элементами
Разбивка функций на самом деле ещё больше укрепила моё восприятие того, что всё это кажется вполне выполнимым. 'unsafe не кажется таким уж далёким. И Нико высказывался несколько серьёзно о временах жизни на основе путей и типах-представлениях. Посмотрим, как быстро они на самом деле будут разработаны на практике, — но, изложив всё это таким образом, я чувствую некоторый оптимизм!
Эргономичные самоссылающиеся типы для Rust
— 2024-07-01
Yoshua Wuyts Источник
- мотивирующий пример
- самоссылающиеся времена жизни
- конструирование типов на месте
- преобразование в неперемещаемые типы
- неперемещаемые типы
- мотивирующий пример, переработанный
- поэтапная инициализация
- миграция с Pin на Move
- превращение неперемещаемых типов в перемещаемые
- дальнейшее чтение
- заключение
Я недавно немного размышлял о самоссылающихся типах, и, хотя технически возможно писать их сегодня с помощью Pin (с ограничениями), они совсем не удобны. Так что же потребуется, чтобы сделать их удобными? Ну, насколько я могу судить, есть четыре компонента, необходимых для их реализации:
- Возможность писать время жизни
'self. - Возможность конструировать типы из функций в фиксированных местах памяти.
- Способ пометить типы как «неперемещаемые» в системе типов.
- Возможность безопасно инициализировать самоссылки в структурах без танцев с
Option.
Только когда у нас есть все четыре этих компонента, написание самоссылающихся типов может стать доступным большинству обычных программистов на Rust. И это кажется важным, потому что, как мы видели с async {} и Future: как только вы начинаете писать достаточно сложные стейт-машины, возможность отслеживать ссылки на данные становится невероятно полезной.
Говоря об async и Future: в этом посте мы будем использовать их в качестве мотивирующего примера того, как эти функции могут работать вместе. Потому что если кажется реалистичным, что мы сможем заставить работать такой сложный случай, как этот, то другие, более простые случаи, вероятно, тоже будут работать.
О, и прежде чем мы углубимся, я хочу выразить огромную благодарность Эрику Холку (Eric Holk). Мы потратили несколько часов, работая вместе над последствиями !Move для системы типов и разбирая множество крайних случаев и проблем. Я не могу быть единственным, кому принадлежат идеи в этом посте. Однако любые ошибки в этом посте — мои, и я не претендую на то, чтобы говорить от нашего имени.
Отказ от ответственности: Этот пост не является полностью сформированным дизайном. Это раннее исследование того, как несколько функций могут работать вместе для решения более широкой проблемы. Моя цель — в первую очередь сузить пространство дизайна до конкретного списка функций, которые можно постепенно реализовывать, и поделиться им с более широким сообществом Rust для обратной связи. Я не вхожу в языковую группу и не говорю от её имени.
Мотивирующий пример
Давайте возьмём async {} и Future в качестве примеров здесь. Когда мы заимствуем локальные переменные в блоке async {} через точки .await, результирующая стейт-машина будет хранить как конкретное значение, так и ссылку на это значение в одной и той же структуре стейт-машины. Эту стейт-машину мы называем самоссылающейся, потому что у неё есть ссылка, которая указывает на что-то внутри self. И поскольку ссылки — это указатели на конкретные адреса памяти, возникают сложности с гарантией того, что они никогда не станут недействительными, так как это привело бы к неопределённому поведению. Давайте посмотрим на пример асинхронной функции:
async fn give_pats() { let data = "chashu tuna".to_string(); // ← Объявлено владеемое значение let name = data.split(' ').next().unwrap(); // ← Получена ссылка pat_cat(&name).await; // ← Точка `.await` здесь println!("patted {name}"); // ← Ссылка используется здесь } async fn main() { give_pats().await; // Вызывает функцию `give_pats`. }
Это довольно простая программа, но идея должна быть достаточно понятна: мы объявляем владеемое значение внутри, вызываем функцию с .await, а позже снова ссылаемся на владеемое значение. Это сохраняет ссылку живой через точку .await, и для этого требуются самоссылающиеся типы. Мы можем десугарировать это в стейт-машину фьючерса примерно так:
#![allow(unused)] fn main() { enum GivePatsState { Created, // ← Отмечает, что наш фьючерс создан Suspend1, // ← Отмечает первую точку `.await` Complete, // ← Отмечает, что фьючерс теперь завершён } struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&str>, // ← Обратите внимание на отсутствие времени жизни здесь } impl GivePatsFuture { fn new() -> Self { Self { resume_from: GivePatsState::Created, data: None, name: None, } } } impl Future for GivePatsFuture { type Output = (); fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { ... } } }
Время жизни GivePatsFuture::name неизвестно, в основном потому, что мы не можем его назвать. И поскольку десугаринг происходит в компиляторе, ему тоже не нужно называть время жизни. Мы поговорим об этом подробнее позже в этом посте. Поскольку это генерирует самоссылающуюся стейт-машину, этот фьючерс нужно будет сначала зафиксировать на месте с помощью Pin. После закрепления метод Future::poll можно вызывать в цикле до тех пор, пока фьючерс не вернёт Ready. Десугаринг для этого будет выглядеть примерно так:
#![allow(unused)] fn main() { let mut future = IntoFuture::into_future(GivePatsFuture::new()); let mut pinned = unsafe { Pin::new_unchecked(&mut future) }; loop { match pinned.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
И, наконец, просто для справки, вот как выглядят используемые нами трейты сегодня. Основной момент, который интересен здесь для целей этого поста, заключается в том, что Future принимает Pin<&mut Self>, и мы объясним, как его можно заменить более простой системой на протяжении оставшейся части этого поста.
#![allow(unused)] fn main() { pub trait IntoFuture { type Output; type IntoFuture: Future<Output = Self::Output>; fn into_future(self) -> Self::IntoFuture; } pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Теперь, когда мы взглянули на то, как компилятор сегодня десугарит самоссылающиеся фьючерсы, давайте посмотрим, как мы можем постепенно заменить это безопасной, конструируемой пользователем системой.
Самоссылающиеся времена жизни
В нашем мотивирующем примере мы показали GivePatsFuture, у которого есть поле name, указывающее на поле data. Это явно ссылка, но она не несёт никакого времени жизни:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&str>, // ← Обратите внимание на отсутствие времени жизни } }
Причина, по которой здесь нет времени жизни, не является внутренней; это потому, что мы на самом деле не можем назвать время жизни здесь. Это не 'static, потому что оно не действительно до конца программы. В сегодняшнем компиляторе, я считаю, мы можем просто опустить время жизни, потому что генерация кода происходит после того, как времена жизни уже были проверены. Но скажем, мы хотели бы написать это вручную сегодня как есть; нам понадобилась бы концепция «непроверенного времени жизни», что-то вроде этого:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'unsafe str>, // ← Непроверенное время жизни } }
Возможность написать время жизни, которое не проверяется компилятором, была бы первой ступенькой для включения написания самоссылающихся структур вручную. Это просто потребовало бы много unsafe и тревоги, чтобы сделать правильно. Но, по крайней мере, это было бы возможно. Я считаю, что люди из T-compiler уже работают над добавлением этого, что кажется отличной идеей.
Но ещё лучше было бы, если бы мы могли описывать здесь проверенные времена жизни. Что мы на самом деле хотим написать здесь, так это время жизни, которое действительно на протяжении всего существования значения — и оно всегда гарантированно было бы действительным. Добавление этого времени жизни потребовало бы дополнительных ограничений, которые мы рассмотрим позже в этом посте (например, тип не сможет перемещаться), но что мы действительно хотим, так это иметь возможность написать что-то вроде этого:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, // ← Действительно на протяжении всего существования `Self` } }
Чтобы немного отвлечься; добавление именованного времени жизни 'self также могло бы позволить нам убрать шаблон where Self: 'a при использовании времён жизни в обобщённых ассоциированных типах. Если вы когда-либо работали со временем жизни в ассоциированных типах, то, вероятно, сталкивались с ошибкой «missing required bounds». Нико предложил в этом issue использовать 'self в качестве одного из возможных решений для шаблонности ограничений. Я думаю, его значение было бы немного другим, чем при использовании с самоссылками? Но я не верю, что использование этого было бы двусмысленным. И в целом, я думаю, это выглядит довольно аккуратно:
#![allow(unused)] fn main() { // Трейт заимствующего итератора, как мы должны писать его сегодня: trait LendingIterator { type Item<'a> where Self: 'a; fn next(&mut self) -> Self::Item<'_>; } // Трейт заимствующего итератора, если бы у нас был `'self`: trait LendingIterator { type Item<'self>; fn next(&mut self) -> Self::Item<'_>; } }
Конструирование типов на месте
Чтобы 'self было действительным, мы должны обещать, что наше значение не будет перемещаться в памяти. И прежде чем мы сможем обещать, что значение не будет перемещаться, мы должны сначала сконструировать его где-то, где можем быть уверены, что оно может остаться и не будет перемещено дальше. Глядя на наш мотивирующий пример, способ, которым мы достигаем этого с помощью Pin, немного дурацкий. Вот тот же пример снова:
impl GivePatsFuture { fn new() -> Self { Self { resume_from: GivePatsState::Created, data: None, name: None, } } } async fn main() { let mut future = IntoFuture::into_future(GivePatsFuture::new()); let mut pinned = unsafe { Pin::new_unchecked(&mut future) }; loop { match pinned.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
В этом примере мы видим, что GivePatsFuture конструируется внутри функции new, перемещается из неё и только затем закрепляется на месте с помощью Pin::new_unchecked. Даже если GivePatsFuture: !Unpin, трейт Unpin влияет на типы только после того, как они находятся внутри структуры Pin. И мы не можем просто возвращать Pin из new, потому что стековые фреймы функции отбрасываются в момент возврата из функции.
Было бы лучше, если бы мы позволили типам описывать, как они могут конструировать себя на месте. Это означает больше никаких внешних вызовов Pin::new_unchecked; только внутренне предоставленные конструкторы. Это позволяет нам делать самоссылающиеся типы полностью самодостаточными, с внутренне предоставленными конструкторами, заменяющими внешний танец с закреплением. Вот как мы могли бы переписать GivePatsFuture::new, чтобы использовать внутренний конструктор:
#![allow(unused)] fn main() { impl GivePatsFuture { fn new(slot: Pin<&mut MaybeUninit<Self>>) { let slot = unsafe { slot.get_unchecked_mut() }; let this: *mut Self = slot.as_mut_ptr(); unsafe { addr_of_mut!((*this).resume_from).write(GivePatsState::Created); addr_of_mut!((*this).data).write(None); addr_of_mut!((*this).name).write(None); }; } } }
Если вам это не нравится: это понятно. Я не думаю, что кому-то нравится. Но потерпите; мы вместе отправляемся в небольшое дидактическое путешествие по изготовлению колбасы. Мне жаль, что вы это видите; давайте быстро двинемся дальше.
В недавнем посте в блоге я предположил, что мы могли бы рассматривать такие параметры как «острое возвращаемое значение». Джеймс Маннс (James Munns) указал, что в C++ у этой функции есть название: out-указатели. И Джек Хьюи (Jack Huey) сделал интересную связь между этим и дизайном super let. Чтобы нам снова не пришлось смотреть на кучу небезопасного кода, давайте представим, что мы можем объединить это во что-то связное:
#![allow(unused)] fn main() { impl GivePatsFuture { fn new() -> super Pin<&'super mut Self> { pin!(Self { // просто представьте, что это работает resume_from: GivePatsState::Created, data: None, name: None, }) } } }
Я знаю, знаю — я показываю здесь синтаксис. Лично мне всё равно, как это выглядит, и мы поговорим позже о том, как мы можем это улучшить, но я надеюсь, мы все согласимся, что само тело функции примерно на 400% более читаемо, чем куча unsafe, с которой мы работали ранее. Мы также дойдём до того, как мы можем полностью убрать Pin из сигнатуры, так что, пожалуйста, не зацикливайтесь на этом слишком сильно.
Я прошу вас немного поиграть вместе сейчас и предположить, что мы могли бы сделать что-то подобное, чтобы мы могли добраться до части этого поста, где мы действительно сможем это исправить. Или, возможно, сформулировать это более чётко: этот пост меньше о предложении конкретных дизайнов для проблемы, а больше о том, как мы можем разделить проблему «неперемещаемых типов» на отдельные функции, которые мы можем решать независимо друг от друга.
Преобразование в неперемещаемые типы
Хорошо, у нас есть представление о том, как мы могли бы конструировать неперемещаемые типы на месте, предоставляемые как конструктор, определённый на типе. Теперь, хотя это и приятно, мы также потеряли важное свойство: всякий раз, когда конструируется GivePatsFuture, ему нужно иметь фиксированное место в памяти. Раньше мы могли свободно перемещать его, пока не начинали .await.
Одна из основных причин, почему async полезен, заключается в том, что он позволяет ad-hoc конкурентное выполнение. Это означает, что мы хотим иметь возможность брать фьючерсы и передавать их операциям конкурентности, чтобы обеспечить конкурентность через композицию. Мы не можем перемещать фьючерсы, которые имеют фиксированное местоположение в памяти, поэтому нам нужен краткий момент, когда фьючерсы могут быть перемещены, прежде чем они будут готовы оставаться на месте и опрашиваться до завершения.
Способ, которым Pin работает с этим сегодня, заключается в том, что тип может быть !Unpin — но это становится актуальным только после того, как он помещён внутрь структуры Pin. С фьючерсами это обычно не происходит до тех пор, пока не начинается их опрос, обычно через .await, и поэтому мы получаем свободу перемещать фьючерсы !Unpin до тех пор, пока не начинаем их .await. Вот почему !Unpin не отмечает: «Тип, который нельзя перемещать», он отмечает: «Тип, который нельзя перемещать после того, как он был закреплён». Это определённо сбивает с толку, так что не волнуйтесь, если трудно следовать.
#![allow(unused)] fn main() { fn foo<T: Unpin>(t: &mut T); // Тип не закреплён, тип можно перемещать. fn foo<T: !Unpin>(t: &mut T); // Тип не закреплён, тип можно перемещать. fn foo<T: Unpin>(t: Pin<&mut T>); // Тип закреплён, тип можно перемещать. fn foo<T: !Unpin>(t: Pin<&mut T>); // Тип закреплён, тип нельзя перемещать. }
Если мы хотим, чтобы «неперемещаемость» была безусловной частью типа, мы не можем заставить её вести себя так же, как Unpin. Вместо этого кажется лучше разделить требования перемещаемости / неперемещаемости на два отдельных типа. Мы сначала конструируем тип, который можно свободно перемещать, — и как только мы готовы довести его до завершения, мы преобразуем его в тип, который является неперемещаемым, и начинаем вызывать его. Это идеально соответствует разделению между IntoFuture и Future, которое мы уже используем.
Давайте снова взглянем на наш первый пример, но немного изменим его. То, что я предлагаю здесь, заключается в том, что вместо того, чтобы give_pats возвращал impl Future, он должен возвращать impl IntoFuture. Этот тип не закреплён и может свободно перемещаться. Только когда мы готовы его .await, мы вызываем .into_future, чтобы получить неперемещаемый фьючерс, — и затем вызываем его.
#![allow(unused)] fn main() { struct GivePatsFuture { ... } impl GivePatsFuture { fn new() -> super Pin<&'super mut Self> { ... } // пожалуйста, поверьте на время } struct GivePatsIntoFuture; impl IntoFuture for GivePatsIntoFuture { type Output = (); type IntoFuture = GivePatsFuture; // Мы вызываем конструктор `Future::new`, который даёт нам // `Pin<&'super GivePatsFuture>`, и затем вместо того, чтобы записывать // его в стековый фрейм текущей функции, мы записываем его в стековый фрейм // вызывающей стороны. // // (продолжайте верить ещё немного) fn into_future(self) -> super Pin<&'super mut GivePatsFuture> { GivePatsFuture::new() // создать в области видимости вызывающей стороны } } }
Так же, как мы можем продолжать возвращать значения из функций, чтобы передавать их дальше по стеку вызовов, мы должны иметь возможность использовать out-указатели / размещение / острое выделение в стековом фрейме дальше по стеку вызовов. Хотя, даже если мы не поддерживали бы это с самого начала, мы, вероятно, могли бы встроить GivePatsFuture::new в GivePatsIntoFuture::into_future, и всё равно всё работало бы. И с этим наш десугаринг .await мог бы выглядеть примерно так:
async fn main() { let into_future: GivePatsIntoFuture = give_pats(); let mut future: Pin<&mut GivePats> = GivePatsIntoFuture.into_future(); loop { match future.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
Чтобы повторить, почему этот раздел существует: мы можем получить ту же функциональность, которую предоставляют сегодня Pin + Unpin, создав два отдельных типа. Один тип, который можно свободно перемещать. И другой тип, который после конструирования не будет перемещаться в памяти.
Пока что единственная формулировка «неперемещаемых типов», которую я видел, — это единые типы, которые обладают обоими этими свойствами, — так же, как Unpin делает сегодня. То, что я пытаюсь здесь выразить, заключается в том, что мы можем избежать этой проблемы, если решим создать два типа вместо одного, позволяя одному конструировать другой и заставляя их предоставлять отдельные гарантии. Я думаю, это новое понимание, и я счёл важным уделить ему некоторое время.
Неперемещаемые типы
Хорошо, я просил людей поверить, что мы действительно можем каким-то образом выполнить конструирование на месте типа Pin<&mut Self>, и всё сработает так, как мы хотим. Я и сам не уверен, но для повествования этого поста было проще, если бы мы просто на секунду притворились, что можем.
Настоящее решение здесь, конечно, — полностью избавиться от Pin. Вместо этого сами типы должны иметь возможность сообщать, имеют ли они стабильное местоположение в памяти или нет. Простейшая формулировка для этого — добавить новый встроенный автотрейт Move, который сообщает компилятору, можно ли перемещать тип или нет.
#![allow(unused)] fn main() { auto trait Move {} }
Это, конечно, не новая идея: мы знали о возможности Move по крайней мере с 2017 года. Это было до того, как я начал работать с Rust. В сообществе Rust были некоторые стойкие сторонники Move, но в конечном итоге это не тот дизайн, с которым мы остановились. Я думаю, ретроспективно большинство из нас признают, что недостатки Pin достаточно реальны, поэтому пересмотр Move и проработка его ограничений кажется хорошей идеей1. Чтобы объяснить, что такое трейт Move: это был бы трейт на уровне языка, который регулирует доступ к следующим возможностям:
- Возможность передаваться по значению в функции и типы.
- Возможность передаваться по изменяемой ссылке в
mem::swap,mem::takeиmem::replace. - Возможность использоваться с любыми синтаксическими эквивалентами предыдущих пунктов, такими как присваивание изменяемым ссылкам, захваты замыканий и так далее.
И наоборот, когда тип реализует !Move, у него не будет доступа ни к одной из этих возможностей, что делает невозможным его перемещение после того, как он получил фиксированное местоположение в памяти. И по умолчанию мы бы предполагали во всех ограничениях, что типы являются Move, за исключением мест, которые явно отказываются, используя + ?Move. Вот примеры того, что считается перемещением:
#![allow(unused)] fn main() { // # примеры перемещения // ## обмен двух значений let mut x = new_thing(); let mut y = new_thing(); swap(&mut x, &mut y); // ## передача по значению fn foo<T>(x: T) {} let x = new_thing(); foo(x); // ## возврат значения fn make_value() -> Foo { Foo { x: 42 } } // ## захваты замыканий с `move` let x = new_thing(); thread::spawn(move || { let x = x; }) }
А вот некоторые вещи, которые не считаются перемещением:
#![allow(unused)] fn main() { // # вещи, которые не являются перемещением // ## передача ссылки fn take_ref<T>(x: &T) {} let x = new_thing(); take_ref(&x); // ## передача изменяемых ссылок тоже нормально, // но нужно быть осторожным, как вы их используете fn take_mut_ref<T>(x: &mut T) {} let mut x = new_thing(); take_mut_ref(&mut x); }
Передача типов по значению никогда не будет совместима с типами !Move, потому что это и есть перемещение. Передача типов по ссылке всегда будет совместима с типами !Move, потому что они неизменяемы2. Единственное место с некоторой неоднозначностью — когда мы работаем с изменяемыми ссылками, поскольку такие операции, как mem::swap, позволяют нам нарушать гарантии неперемещаемости.
Если функция хочет принимать изменяемую ссылку, которая может быть неперемещаемой, ей придётся добавить + ?Move. Если функция не использует + ?Move для своей изменяемой ссылки, то тип !Move не может быть ей передан. На практике это будет работать следующим образом:
#![allow(unused)] fn main() { fn meow<T>(cat: T); // по значению, нельзя передавать значения `!Move` fn meow<T>(cat: &T); // по ссылке, можно передавать значения `!Move` fn meow<T>(cat: &mut T); // по изм. ссылке, нельзя передавать значения `!Move` fn meow<T: ?Move>(cat: &mut T) // по изм. ссылке, можно передавать значения `!Move` }
По умолчанию все ограничения cat: &mut T подразумевали бы + Move. И только там, где мы соглашаемся на + ?Move, могли бы передаваться типы !Move. На практике, вероятно, в большинстве мест можно будет добавить + ?Move, поскольку гораздо чаще записывают в поле изменяемой ссылки, чем заменяют её целиком с помощью mem::swap. Такие вещи, как внутренняя изменяемость, вероятно, также в основном допустимы в соответствии с этими правилами, поскольку даже если доступ осуществляется через общие ссылки, обновление значений в указателях должно будет взаимодействовать с ранее установленными нами правилами, — и они по умолчанию безопасны.
Чтобы быть полностью точными, мы также должны учитывать внутреннюю изменяемость. Это позволяет нам изменять значения через общие ссылки — но только за счёт возможности условно преобразовывать их в ссылки &mut во время выполнения. То, что мы разрешаем приведение &T к &mut T во время выполнения, не означает, что правила, которые мы применили к системе, всё ещё не работают. Скажем, мы держали &mut T: !Move внутри Mutex. Если бы мы попытались вызвать метод deref_mut, мы получили бы ошибку компиляции, потому что это ограничение ещё не объявило, что T: ?Move. Мы могли бы, вероятно, добавить это, но поскольку по умолчанию это не работает, у нас была бы возможность проверить его корректность перед добавлением.
В любом случае, пока достаточно теории о том, как это, вероятно, должно работать. Давайте попробуем обновить наш предыдущий пример, заменив Pin на !Move. Это должно быть так же просто, как добавить impl !Move для GivePatsFuture.
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, } impl !Move for GivePatsFuture {} }
И как только у нас это есть, мы можем изменить наши конструкторы, чтобы возвращать super Self вместо super Pin<&'super mut Self>. Мы уже знаем, что размещение с использованием чего-то вроде super Self (не фактическая нотация) для записи в фиксированные места памяти кажется правдоподобным. Всё, что нам тогда нужно сделать, — это добавить автотрейт, который сообщает системе типов, что дальнейшие операции перемещения не разрешены.
#![allow(unused)] fn main() { struct GivePatsFuture { ... } impl !Move for GivePatsFuture {} impl GivePatsFuture { fn new() -> super Self { ... } // создать в области видимости вызывающей стороны } struct GivePatsIntoFuture; impl IntoFuture for GivePatsIntoFuture { type Output = (); type IntoFuture = GivePatsFuture; fn into_future(self) -> super GivePatsFuture { GivePatsFuture::new() // создать в области видимости вызывающей стороны } } }
Наверное, мне следовало сказать это раньше, но скажу сейчас: в этом посте я намеренно не беспокоюсь об обратной совместимости. Снова суть в том, чтобы разбить сложное пространство дизайна «неперемещаемых типов» на более мелкие проблемы, которые мы можем решать одну за другой. Выяснение того, как соединить Pin и !Move, — это то, что мы захотим понять в какой-то момент, но не сейчас.
Что касается async {} и Future: это должно работать! Это позволяет нам свободно перемещать блоки async, которые десугарируются в IntoFuture. И только когда мы готовы начать их опрашивать, мы вызываем into_future, чтобы получить impl Future + !Move. Система, подобная этой, эквивалентна существующей системе Pin, но не нуждается в Pin в своей сигнатуре. Для верности вот как мы смогли бы переписать сигнатуру Future с этим изменением:
#![allow(unused)] fn main() { // Текущий трейт `Future` // использующий `Pin<&mut Self>` pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } // Трейт `Future`, использующий `Move` // использующий `&mut self` pub trait Future { type Output; fn poll(&mut self, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Это также означало бы: больше никаких проекций Pin. Больше никаких несовместимостей с Drop. Поскольку это автотрейт, который регулирует поведение языка, пока базовые правила корректны, взаимодействие со всеми другими частями Rust будет корректным.
Также, что, вероятно, наиболее актуально для меня, это сделало бы возможным писать стейт-машины фьючерсов с использованием методов и функций, в отличие от текущего статус-кво, когда мы просто сваливаем всё в тело функции poll. После того как за последние шесть лет я написал невероятное количество фьючерсов вручную, я не могу передать, как сильно я хотел бы иметь такую возможность.
Мотивирующий пример, переработанный
Теперь, когда мы рассмотрели самоссылающиеся времена жизни, конструирование на месте, поняли, что async {} должен возвращать IntoFuture, и увидели !Move, мы готовы объединить эти функции, чтобы переработать наш мотивирующий пример. Вот с чего мы начали, используя обычный код async/.await:
async fn give_pats() { let data = "chashu tuna".to_string(); let name = data.split(' ').next().unwrap(); pat_cat(&name).await; println!("patted {name}"); } async fn main() { give_pats().await; }
И вот во что, с этими новыми возможностями, мог бы десугариться async fn give_pats. Обратите внимание на время жизни 'self, impl !Move для фьючерса, отсутствие Pin везде и конструирование типа на месте.
#![allow(unused)] fn main() { enum GivePatsState { Created, Suspend1, Complete, } struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, // ← Обратите внимание на время жизни `'self` } impl !Move for GivePatsFuture {} // ← Этот тип неперемещаемый impl Future for GivePatsFuture { type Output = (); fn poll(&mut self, cx: &mut Context<'_>) // ← `Pin` не требуется -> Poll<Self::Output> { ... } } struct IntoGivePatsFuture {} impl IntoFuture for IntoGivePatsFuture { type Output = (); type IntoFuture: GivePatsFuture fn into_future(self) -> super GivePatsFuture { // ← Записывает в стабильный адрес Self { resume_from: GivePatsState::Created, data: None, name: None, } } } }
И, наконец, мы можем затем десугарить вызов give_pats().await в конкретные типы, которые мы конструируем и вызываем до завершения:
#![allow(unused)] fn main() { let into_future = IntoGivePatsFuture {}; let mut future = into_future.into_future(); // ← Неперемещаемый без `Pin` loop { match future.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
И с этим у нас должен быть рабочий пример блоков async {}, десугарированных в конкретные типы и трейты, которые вообще не используют Pin. Доступ к полям внутри не проходил бы через какую-либо проекцию закрепления, и больше не было бы необходимости в таких вещах, как стековое закрепление. Неперемещаемость была бы просто свойством самих типов, конструируемых, когда они нам нужны, в том месте, где мы хотим их использовать.
О, и, наверное, стоит упомянуть: функции, работающие с этими трейтами, всегда будут хотеть использовать T: IntoFuture, а не T: Future. Это не большое изменение и, на самом деле, то, что люди уже должны делать сегодня. Но я подумал, что упомяну это на случай, если люди запутаются в том, какими должны быть ограничения для операций конкурентности.
Поэтапная инициализация
Мы не показали этого в нашем примере, но есть ещё один аспект самоссылающихся типов, который стоит осветить: поэтапная инициализация. Это когда вы инициализируете части типа в разные моменты времени. В нашем мотивирующем примере нам не пришлось использовать это, потому что самоссылки находились внутри Option. Это означает, что когда мы инициализировали тип, мы могли просто передать None, и всё было хорошо. Однако, скажем, мы действительно хотим инициализировать самоссылку, как мы поступим?
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl !Move for Cat {} impl Cat { fn new(data: String) -> super Self { Cat { data: "chashu tuna".to_string(), name: /* Как мы ссылаемся здесь на `self.data`? */ } } } }
Конечно, поскольку String выделяется в куче, её адрес фактически стабилен, и поэтому мы могли бы написать что-то вроде этого:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl !Move for Cat {} impl Cat { fn new(data: String) -> super Self { let data = "chashu tuna".to_string(); Cat { name: data.split(' ').next().unwrap(), data, } } } }
Это явно жульничество, и не то, что мы хотим, чтобы люди делали. Но это указывает нам на то, как, вероятно, должно работать решение: сначала нам нужен стабильный адрес, на который можно указывать. И как только у нас есть этот адрес, мы можем ссылаться на него. Мы не можем сделать этого, если должны построить всё за один раз. Но что, если бы мы могли сделать это в несколько этапов? Именно об этом говорилось в недавнем посте Нико о проверке заимствований и типах-представлениях (view types). Это позволило бы нам изменить наш пример, чтобы он вместо этого был написан так:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl !Move for Cat {} impl Cat { fn new(data: String) -> super Self { super let this = Cat { data: "chashu tuna".to_string() }; // ← частичная инициализация this.name = this.data.split(' ').next().unwrap(); // ← завершение инициализации this } } }
Мы сначала инициализируем владеемые данные в Cat. И как только они у нас есть, мы можем затем инициализировать ссылки на них. Эти ссылки были бы 'self, мы добавляем аннотацию super let, чтобы указать, что помещаем это в область видимости вызывающей стороны, и всё должно затем проверяться.
Миграция с Pin на Move
Что мы не рассмотрели в этом посте, так это какую-либо историю миграции с существующих API на основе Pin на новую систему на основе Move. Если мы хотим отказаться от Pin в пользу Move, единственный правдоподобный путь, который я вижу, — это создание новых трейтов, которые не несут Pin в своей сигнатуре, и предоставление имплементаций-мостов от старых трейтов к новым. Базовая конверсия с явным методом могла бы выглядеть так, хотя общие имплементации также могли бы быть возможностью:
#![allow(unused)] fn main() { pub trait NewFuture { type Output; fn poll(&mut self, ...) { ... } } pub trait Future { type Output; fn poll(self: Pin<&mut Self>, ...) { ... } /// Преобразует этот фьючерс в `NewFuture`. fn into_new_future(self: Pin<&mut Self>) -> NewFutureWrapper<&mut Self> { ... } } /// Обёртка, связывающая старый трейт фьючерса с новым. struct NewFutureWrapper<'a, F: Future>(Pin<&'a mut F>); impl !Move for NewFutureWrapper {} impl<'a, F> NewFuture for NewFutureWrapper<'a, F> { ... } }
Я повторяю эту строку как минимум три года: но если мы хотим исправить проблемы с Pin, первый шаг, который нам нужно сделать, — это не усугублять проблему. Если стандартной библиотеке нужно исправить трейт Future один раз, это неприятно, но нормально, и мы найдём способ сделать это. Но если мы свяжем Pin с рядом других трейтов, проблемы усугубятся, и я больше не уверен, сможем ли мы избавиться от Pin. И это проблема, потому что Pin широко не любим, и мы активно хотим от него избавиться.
Совместимость с самоссылающимися типами важна не только для итерации; это обобщённое свойство, которое в конечном итоге взаимодействует почти с каждым трейтом, функцией и языковой возможностью. Move просто компонуется с любым другим трейтом, и поэтому нет необходимости в специальном PinnedRead или чём-то подобном. Вместо этого тип просто реализовал бы Read + Move, и этого было бы достаточно для работы самоссылающегося читателя. И мы можем повторять это для любой другой комбинации трейтов.
Конструирование на месте, конечно, меняет сигнатуру трейтов. Но чтобы поддерживать это обратно совместимым образом, всё, что нам нужно сделать, — это позволить трейтам соглашаться на «может выполнять конструирование на месте». И возможность постепенно внедрять такие возможности — именно то, над чем мы работаем в рамках обобщённых эффектов.
#![allow(unused)] fn main() { pub trait IntoFuture { type Output; type IntoFuture: Future<Output = Self::Output>; // Помечен как совместимый с конструированием на месте, при этом // реализации могут решать, хотят ли они использовать это или нет. fn into_future(self) -> #[maybe(super)] Self::IntoFuture; } }
Если мы хотим, чтобы самоссылающиеся типы были общеупотребительными, они должны практически компоноваться с большинством других возможностей, которые у нас есть. И поэтому действительно первый шаг к этому — прекратить стабилизировать любые новые трейты в стандартной библиотеке, которые используют Pin в своей сигнатуре.
Превращение неперемещаемых типов в перемещаемые
До сих пор мы много говорили о самоссылающихся типах и о том, что нам нужно гарантировать, что они не могут быть перемещены, потому что их перемещение было бы плохо. Но что, если бы мы всё-таки разрешили их перемещать? В C++ это возможно с помощью функции под названием «конструкторы перемещения», и если мы поддерживаем самоссылающиеся типы в Rust, это не кажется большим скачком — поддержать это тоже.
Прежде чем мы пойдём дальше, я хочу предупредить: я слышал от людей, которые работали с конструкторами перемещения в C++, что с ними может быть довольно сложно работать. Я сам с ними не работал, поэтому не могу говорить по опыту. Лично у меня нет особых случаев использования, когда бы я чувствовал, что хотел бы конструкторы перемещения, поэтому я не особо за или против их поддержки. Я пишу этот раздел в основном из академического интереса, потому что знаю, что найдутся люди, которые об этом задумаются. И правила того, как это должно работать, кажутся довольно простыми.
Нико Мацакис недавно написал две части о трейте Claim (первая, вторая), предлагая новый трейт Claim, чтобы заполнить пробел между Clone и Copy. Этот трейт был бы для типов, которые «дёшево» клонировать, таких как типы Arc и Rc. И с помощью autoclaim компилятор автоматически вставлял бы вызовы .claim по мере необходимости. Например, когда замыкание move || захватывает тип, реализующий Claim, но он уже используется где-то ещё, — оно автоматически вызовет .claim, чтобы скомпилировалось.
Включение возможности релокации для неперемещаемых типов работало бы примерно так же, как и авто-захват. Нам нужно было бы ввести новый трейт, который мы здесь назовём Relocate, с методом relocate. Всякий раз, когда мы пытались бы переместить в противном случае неперемещаемое значение, мы автоматически вызывали бы .relocate вместо этого. Сигнатура трейта Relocate принимала бы self как изменяемую ссылку. И возвращала бы экземпляр Self, сконструированный на месте:
#![allow(unused)] fn main() { trait Relocate { fn relocate(&mut self) -> super Self; } }
Обратите внимание на сигнатуру self здесь: мы принимаем его по изменяемой ссылке — не по владению и не по общей ссылке. Это потому, что то, что мы пишем, по сути является неперемещаемым эквивалентом Into, но мы не можем взять self по значению — поэтому мы должны взять его по ссылке и сказать людям просто сделать mem::swap. Применяя это к нашему предыдущему примеру с Cat, мы смогли бы реализовать это следующим образом:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl Cat { fn new(data: String) -> super Self { ... } } impl Relocate for Cat { fn relocate(&mut self) -> super Self { let mut data = String::new(); // фиктивный тип, не выделяет память mem::swap(&mut self.data, &mut data); // взять владеемые данные super let cat = Cat { data }; // сконструировать новый экземпляр cat.name = cat.data.split(' ').next().unwrap(); // создать самоссылку cat } } }
Мы делаем одно сомнительное предположение здесь: нам нужно иметь возможность взять владеемые данные из self, не сталкиваясь с проблемой, когда данные не могут быть перемещены, потому что они уже заимствованы из self. Это общая проблема, которую нам нужно решить, и один из способов, которым мы могли бы, например, обойти это, — создание фиктивных указателей в основной структуре, чтобы гарантировать, что типы всегда действительны, — но мы делаем типы недействительными:
#![allow(unused)] fn main() { struct Cat { data: String, dummy_data: String, // никогда не инициализируется значением name: &'self str, } impl Relocate for Cat { fn relocate(&mut self) -> super Self { self.name = &self.dummy_data; // больше нет ссылок на `self.data` let data = mem::take(&mut self.data); // короче, чем `mem::swap` super let cat = Cat { data }; cat.name = cat.data.split(' ').next().unwrap(); cat } } }
В этом примере Cat реализовал бы Move, даже если у него есть время жизни 'self, потому что мы можем свободно перемещать его. Когда тип уничтожается после того, как был передан в Relocate, он не должен вызывать свою impl Drop. Потому что семантически мы не пытаемся уничтожить тип — всё, что мы делаем, — это обновляем его местоположение в памяти. В соответствии с этими правилами доступ к 'self в структурах был бы доступен как если Self: !Move, так и если Self: Relocate.
Я хочу снова подчеркнуть, что я здесь не прямо выступаю за введение конструкторов перемещения в Rust. Лично я довольно нейтрально к ним отношусь, и меня можно убедить в любую сторону. В основном я хотел хотя бы один раз пройтись по тому, как могли бы работать конструкторы перемещения, потому что кажется хорошей идеей знать, что постепенный путь здесь возможен. Надеюсь, эта мысль здесь доходит нормально.
Дальнейшее чтение
RFC Pin — интересное чтение, так как оно описывает систему неперемещаемых типов, с которой мы в итоге остановились сегодня. В частности, сравнение между Pin и Move, а также раздел о недостатках интересно перечитать. Особенно когда мы сравниваем его с документацией по pin и видим, чего не было в RFC, но что позже оказалось серьёзными практическими проблемами (например, проекции pin, взаимодействие с остальной частью языка).
Tmandry представил интересную серию (блог 1, блог 2, доклад) о внутренностях async. В частности, он рассказывает, как блоки async {} десугарируются в стейт-машины на основе Future. Этот пост использует это десугаринг в качестве мотивирующего примера, так что для тех, кто хочет узнать больше о том, что происходит за кулисами, это отличный ресурс. Раздел о десугаринге .await в справочнике Rust также стоит прочитать, так как он отражает текущее положение дел в компиляторе.
Более недавно доклад и крейт Мигеля Янга де ла Сота (mcyoung) для поддержки конструкторов перемещения C++ интересно почитать. Что-то, что я ещё не до конца осмыслил, но что интересно, — это трейт New, который он предоставляет. Его можно использовать для конструирования типов на месте как в стеке, так и в куче, что, в идеале, что-то вроде нотации super let / super Type тоже могло бы поддерживать. Можно думать о конструкторах перемещения C++ как о дальнейшей эволюции неперемещаемых типов, так что неудивительно, что существует много общих концепций.
Два года назад я пытался сформулировать способ, которым мы могли бы использовать типы-представления для безопасной проекции pin (пост), и у меня не получилось в нескольких аспектах. В частности, я не был уверен, как разобраться с взаимодействиями с #[repr(packed)], как сделать Drop совместимым и как пометить Unpin как unsafe. Возможно, есть путь для последнего, но я не знаю никаких практических решений для первых двух проблем. Этот пост, по сути, является продолжением того поста, но меняющим предпосылку с: «Как мы можем исправить Pin?» на «Как мы можем заменить Pin?».
Серия Нико о типах-представлениях также стоит прочтения. Его первый пост обсуждает, что такое типы-представления, как они будут работать и почему они полезны. И в одном из его самых последних постов он обсуждает, как типы-представления вписываются в более широкую «дорожную карту из 4 частей для borrow checker» (также известную как «borrow checker within»). В своём последнем посте он также напрямую освещает поэтапную инициализацию с использованием типов-представлений, что является одной из функций, которые мы обсуждаем в этом посте в связи с самоссылающимися типами.
Наконец, я бы предложил взглянуть на крейт ouroboros. Он позволяет безопасную поэтапную инициализацию для самоссылающихся типов в стабильном Rust, используя макросы и замыкания. Способ его работы заключается в том, что сначала инициализируются поля, использующие владеемые данные. А затем выполняются замыкания для инициализации полей, ссылающихся на данные. Поэтапная инициализация с использованием типов-представлений, описанная в этом посте, имитирует этот подход, но включает её напрямую из языка через общеполезную функцию.
Заключение
В этом посте мы разобрали «самоссылающиеся типы» на четыре составные части:
- Времена жизни
'unsafe(непроверенные) и'self(проверенные), которые сделают возможным выражение самоссылающихся времён жизни. - Моральный эквивалент
super let/-> super Typeдля безопасной поддержки out-указателей. - Способ обратно совместимо добавлять опциональные нотации
-> super Type. - Новый автотрейт
Move, который регулирует доступ к операциям перемещения. - Функция типов-представлений, которая сделает возможным конструирование самоссылающихся типов без танцев с
Option.
Финальное прозрение, которое предоставляет этот пост, заключается в том, что сегодняшнюю систему Pin + Unpin можно эмулировать с помощью Move, создавая обёртки Move, которые могут возвращать типы !Move. В контексте async шаблоном было бы создание обёртки impl IntoFuture + Move, которая конструирует фьючерс impl Future + !Move на месте через out-указатель.
Люди в целом не любят Pin, и, насколько я могу судить, существует широкая поддержка исследования альтернативных решений, таких как Move. Сейчас единственный трейт, который использует Pin в стандартной библиотеке, — это Future. Чтобы облегчить миграцию с Pin на что-то вроде Move, нам будет хорошо не вводить дальше никаких API на основе Pin в стандартную библиотеку. Миграция с одного API потребует усилий, но в конечном итоге кажется выполнимой. Миграция с множества API потребует больше усилий и делает более вероятным, что мы навсегда останемся с трудностями Pin.
Цель этого поста заключалась в том, чтобы распутать большую страшную проблему «неперемещаемых типов» на её составные части, чтобы мы могли начать решать их одну за другой. Ни один из синтаксисов или семантик в этом посте не предназначены быть конкретными или окончательными. В основном я хотел хотя бы один раз пройти через всё необходимое для работы неперемещаемых типов, чтобы другие могли углубиться, подумать вместе, и мы могли начать уточнять конкретные детали.
Примечания
-
Для всех, кто хочет возложить здесь вину или вытащить скелеты из шкафов: пожалуйста, не делайте этого. Высший смысл здесь в том, что у нас есть
Pinсегодня, он явно не работает так хорошо, как надеялись в то время, и мы хотели бы заменить его чем-то лучшим. Я думаю, самое интересное, что здесь можно исследовать, — это как мы можем двигаться вперёд и делать лучше. ← ↩ -
Да, да, мы дойдём до внутренней изменяемости через секунду. ← ↩