В начале 2010-х годов в программировании появился новый подход к реализации параллелизма с использованием абстракций future и promise, а также синтаксического приёма async/await. Эти технологии облегчили работу с асинхронным кодом, но вызвали ожесточённые споры среди разработчиков. Автор статьи, системный программист Saoirse Shipwreckt*, рассматривает две противоположные точки зрения на future от их апологета Мариуса Эриксена и критика Боба Нистрома.
Под катом читайте о преимуществах и недостатках абстракций, а также о различных видах параллелизма в Rust и способах улучшения структуры кода с помощью future.
*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис.
2010-е годы – время расцвета языков, исследующих новые способы реализации параллелизма. В этот период была разработана абстракция для параллельных операций — «future» или «promise». Это единица работы, которая в итоге может завершиться, позволяет программисту манипулировать управляющей логикой в программе. На этой основе был введён синтаксический приём «async/await», облегчающий восприятие текста программы и позволяющий преобразовывать future в обычную линейную управляющую логику, которой мы чаще всего пользуемся. Этот подход был принят во многих основных языках программирования, что вызвало среди специалистов ожесточенные споры.
В то же время вышли две отличные статьи, где доходчиво были изложены две противоположные точки зрения по этому вопросу. Советую вам их прочитать:
Futures — это не замена потокам. Статья Мариуса Эриксена (Marius Eriksen) (2 апреля 2013 г.)
Какого цвета ваша функция? Статья Боба Нистрома (Bob Nystrom) (1 февраля 2015 г.)
Разберём мнения авторов статей подробнее.
Основной посыл Мариуса Эриксена в том, что по сравнению с потоками, future предполагают совершенно иной подход к параллелизму. Потоки позволяют использовать модель, в которой все операции происходят «синхронно», так как выполнение программы выглядит как стек вызовов функций, который возможно блокировать, если нужно дождаться выполнения параллельных операций. С future же все иначе — модель представляет параллельные операции в качестве асинхронно завершаемых future, поэтому позволяет реализовать ряд указанных Эриксеном преимуществ. Вот его аргументы, которые я считаю особенно убедительными:
Функция, выполняющая асинхронные операции, отличается от «чистой» функции, так как она должна возвращать не просто значение, а future. Это различие важно, так как позволяет определить, выполняет ли функция операции ввода-вывода или исключительно вычисления — от этого зависит очень многое.
Future создают непосредственное представление о выполняемой работе, поэтому их можно комбинировать различными способами — как последовательно, так и параллельно. Блокирующие вызовы функций можно комбинировать только последовательно, не запуская новый поток.
Future можно комбинировать параллельно. Это позволяет создать параллельный код, более точно отражающий логику происходящего. Можно создавать абстракции, представляющие определенные модели параллелизма, что даёт возможность освободить бизнес-логику от задач по распределению работы между потоками. Эриксен приводит примеры, такие, как оператор
flatMap
, организующий после начального запроса цепочку из множества параллельных сетевых запросов.
Нистром же занимает противоположную позицию. Он начинает с условного обозначения языка, в котором все функции «цветные» — либо СИНИЕ (BLUE), либо КРАСНЫЕ (RED). В этом воображаемом языке ключевое различие между двумя цветами функций заключается в том, что КРАСНЫЕ функции могут вызываться только другими КРАСНЫМИ функциями. По его мнению это разочаровывает пользователей языка, так как необходимость различать два типа функций раздражает, а вызов КРАСНЫХ функций требует использования сложного синтаксиса. Конечно, здесь подразумевается разница между синхронными и асинхронными функциями. То, что Эриксен считает преимуществом future — различие между функциями, возвращающими future, и функциями, которые этого не делают — Нистром видит как их главный недостаток.
Некоторые замечания Нистрома не относятся к async Rust. Например, он утверждает, что если вызвать функцию одного цвета так, как если бы она была функцией другого цвета, то могут произойти жуткие вещи:
При вызове функции необходимо использовать вызов, соответствующий ее цвету. А если ошибиться... последствия будут ужасными. Вспомните какой-нибудь давно забытый кошмар из детства, например клоуна со змеями вместо рук, прячущегося у вас под кроватью. Так вот: он выпрыгивает из монитора и высасывает ваши глаза.
Это вполне вероятно для JavaScript — нетипизированного языка, «прославившегося» нелепой семантикой. Однако в статически типизированном языке, таком как Rust, компилятор выдаст ошибку, которую можно исправить и продолжить работу.
Один из основных аргументов Нистрома заключается также в том, что вызов КРАСНОЙ функции значительно более «болезнен», чем вызов СИНЕЙ функции. Как он позже уточняет в своей статье, Нистром имеет в виду API на основе обратных вызовов, широко используемый в JavaScript в 2015 году. Он утверждает, что синтаксис async/await решает эту проблему:
[Async/await] позволяет выполнять асинхронные вызовы так же легко, как синхронные — нужно добавить всего лишь одно ключевое слово. Вызовы await можно использовать в выражениях, применять их в коде обработки исключений и интегрировать в управляющую логику.
Конечно же, он также приводит следующий ключевой аргумент в споре о «проблеме раскраски функций»:
Но... мир всё равно получается поделённым на две части. Эти асинхронные функции проще писать, но они все равно по сути своей остаются асинхронными.
Всё так же остаются два цвета. Async-await устраняет раздражающее правило №4: вызывать красные функции становится почти так же легко, как и синие. Однако все остальные правила никуда не делись.
Future представляют асинхронные операции иначе, чем синхронные. По мнению Эриксена, это даёт дополнительные возможности, которые являются ключевым преимуществом future. Нистром же считает это лишь дополнительным препятствием для вызова функций, которые возвращают future, а не блокируют.
Лично я – полностью на стороне Эриксена. Поэтому широкое распространение точки зрения Нистрома среди комментаторов на Hacker News или авторов гневных и самоуверенных интернет-постов стало для меня большим разочарованием. Несколько месяцев назад я написал статью об истории возникновения абстракции future и синтаксиса async/await в Rust и статью о функциях, которые я хотел бы видеть в async Rust для упрощения его использования.
Теперь мне бы хотелось вернуться на шаг назад и пересмотреть дизайн async Rust в контексте этого вопроса о полезности модели параллелизма на основе future. Что на самом деле дало использование future в async Rust? Представим себе, что трудности использования future частично или полностью решены, и предоставляемые им дополнительные возможности не только позволяют использовать async Rust так же просто, как обычный Rust, но и улучшают общий опыт его использования.
Асинхронные задачи не замена потоков
Обычно преимущества future объясняют пользователям с точки зрения повышения производительности: запуск потоков и переключение между ними требует больших затрат, поэтому возможность мультиплексировать множество одновременных операций в одном потоке позволяет выполнять больше операций одновременно. Как и Эриксен, я считаю, что акцент на дихотомии производительности между «потоковым IO» и «событийным IO», только отвлекает от сути дела. Все замечания Эриксена о преимуществах future для структурирования кода справедливы и для Rust.
Эриксен подразумевал контекст языков, использующих в качестве основы для абстракции future стиль передачи продолжений. Как я уже писал в статье об истории async Rust, для Rust выбрали другой подход. Уникальность Rust в том, что система основана на инверсии подхода с продолжением: вместо того, чтобы future вызывал продолжение выполнения, после завершения себя, его опрашивают о завершении извне. Чтобы понять важность этого момента, нужно вернуться назад и поговорить о «задачах».
Когда я пишу «задача», то имею в виду не только «единицу работы». В async Rust «задача» — это особый термин. Фундаментальной абстракцией для асинхронной работы является future — тип, реализующий trait Future. Чаще всего он реализуется с помощью асинхронной функции или асинхронного блока. Но чтобы выполнить любой асинхронный код, нам также нужно использовать «исполнителя», который может выполнять «задачи». Обычно за это отвечает «среда выполнения», которая также предоставляет другие вещи, например, типы для выполнения асинхронного ввода-вывода. Наиболее широко используемой средой выполнения является tokio.
Эти определения немного неточные. Задача — это любая future, запланированная для исполнителя. Большинство исполнителей, например tokio, могут выполнять несколько задач параллельно. Они могут использовать для этого один или несколько потоков. В случае нескольких потоков, задачи распределяются между ними (какой подход предпочтительнее — предмет дополнительных споров). Исполнители, которые могут запускать несколько задач одновременно, обычно предоставляют API с таким названием, как spawn
, — они «создают задачу». Существуют и другие исполнители, которые запускают только одну задачу за раз (например, pollster): они обычно предоставляют API с именем типа block_on
.
Все задачи — это future, но не все future — это задачи. Future становятся задачами, когда их передают исполнителю для выполнения. Чаще всего задача состоит из множества небольших future: при ожидании (await
) выполнения future внутри блока async состояние ожидаемой future встраивается в состояние общей асинхронной задачи. Обычно это приходится делать гораздо чаще использования spawn, поэтому большинство ваших future не будут задачами в предложенном мною смысле. Но между future и задачей нет различия на уровне типов: любую future можно превратить в задачу, запустив её на исполнителе.
Важным в этом различии между future и задачами является выделение состояния, необходимого для выполнения задачи, как единого объекта, размещенного в памяти. Каждая отдельная future, используемая в рамках задачи, не требует отдельного выделения. Мы часто описываем эту машину состояний как «стек идеального размера» для задачи — она достаточно велика, чтобы содержать все состояния, которые могут понадобиться задаче до выхода из неё.
Ещё одно следствие этого подхода — невозможность написать рекурсивную async-функцию без явной упаковки рекурсивного вызова. Это происходит по той же причине, по которой невозможно написать рекурсивное определение struct без упаковки рекурсивно встроенного типа.
У всего этого есть интересные последствия для представления параллельных операций в async Rust. Я хочу обозначить различие между двумя видами параллелизма, которые могут быть достигнуты с помощью модели задач: это многозадачный параллелизм, при котором параллельные операции представляются в виде отдельных задач, и параллелизм внутри задачи, при котором одна задача выполняет несколько операций одновременно.
Многозадачный параллелизм
Чтобы две операции выполнялись параллельно, можно, например, создать для каждой отдельную задачу. Это «многозадачный параллелизм», достигаемый за счёт использования нескольких параллельных задач.
Для многих пользователей многозадачный параллелизм в async Rust наиболее понятен, поскольку больше всего близок подходами к параллелизму на основе потоков. Подобно тому, как в обычном Rust можно создавать потоки для обеспечения параллелизма, в async Rust можно создавать задачи. Это позволяет легко применять его тем, кто привык к параллелизму с потоками.
Если у вас несколько асинхронных задач, вам, вероятно, понадобится способ передачи информации между ними. Подобное «межзадачное взаимодействие» достигается за счет использования какого-либо примитива синхронизации, например, блокировки или канала. Для асинхронных задач существует асинхронный эквивалент любого примитива блокирующей синхронизации: асинхронные Mutex
, RwLock
, mpsc
канал и так далее. Многие среды выполнения даже предоставляют примитивы синхронизации async, не имеющих аналогов в стандартной библиотеке. Если аналог все же существует, то интерфейс двух примитивов обычно очень близок: с точки зрения возможностей асинхронный Mutex
действительно напоминает блокирующий Mutex
. За исключением того, что метод блокировки не блокирующий, а асинхронный. Это стало концептуальной основой для среды выполнения async-std.
Однако стоит отметить, что у них абсолютно разная реализация. Код, который выполняется при создании асинхронной задачи, совсем не похож на создание потока. А, например, определение и реализация асинхронной блокировки сильно отличается от блокировки потока: обычно используется блокировка на основе atomics с добавлением очереди задач, ожидающих блокировки. Вместо того чтобы блокировать поток, они помещают эту задачу в очередь и выходят. Когда блокировка освобождается, они пробуждают первую задачу в очереди, чтобы она могла снова взять блокировку.
Для пользователей этих API многозадачный параллелизм очень напоминает многопоточный параллелизм. Однако это не единственный его вид, который позволяет абстракция future.
Параллелизм внутри задачи
Хотя многозадачный параллелизм имеет похожий API с многопоточным параллелизмом (за исключением расставления повсюду ключевых слов async и await), абстракция future также позволяет использовать другой вид параллелизма – «внутризадачный». У него нет аналогов в контексте потоков. Это означает, что одна и та же задача параллельно выполняет несколько асинхронных операций.
Вместо того, чтобы выделять отдельные задачи для каждой из параллельных операций, их можно выполнять, используя один и тот же объект задачи. Это улучшает локальность памяти, экономит накладные расходы на распределение и расширяет возможности оптимизации.
Я имею в виду, что при использовании примитива внутризадачного параллелизма (например, select!
), состояние двух обрабатываемых future будет встроено непосредственно в родительскую future, которая их обрабатывает. Набор широко известных примитивов внутризадачного параллелизма соответствует таблице асинхронных примитивов, о которых я рассказывал в предыдущих статьях: это select и join для Future
и merge и zip для AsyncIterator
.
|
| |
|
|
|
|
|
|
С помощью потоков можно предоставлять API, подобные этому, но только через создание новых потоков и использования каналов или вызова handle.join для передачи их результатов обратно в родительский поток. Это влечет за собой существенные издержки. В то время, как на внутризадачную реализацию этих примитивов в Rust потребуется минимум ресурсов.
Собственно, устранение издержек на эти комбинаторы и стало причиной того, что Rust перешел от стиля вызова продолжения выполнения к подходу, основанному на готовности, для абстракции Future. Когда Аарон Турон писал о future, которым требуется динамическое выделение памяти в стиле вызова продолжения выполнения, он не случайно взял в качестве примера join
. Именно для future, в которые встроены параллельные операции, и нужно обеспечить совместное владение продолжением (для вызова продолжения выполнения в случае завершения любой из параллельных операции). Поэтому future, основанные на готовности, были разработаны именно с целью оптимизации этих комбинаторов для внутризадачного параллелизма.
Как Райн убедительно доказывал в прошлом, «гетерогенная выборка — это суть async Rust». В частности, уникальное свойство async Rust по сравнению с обычным Rust и одна из его самых мощных возможностей – в рамках одной задачи и без дополнительных выделений выбирать множество future разных типов и ожидать, какой из них завершится первым.
Обычная архитектура сервера async Rust предполагает создание задачи для каждого сокета. Эти задачи часто внутренне мультиплексируют входящие и исходящие чтения и записи через этот сокет вместе с сообщениями от других задач, предназначенных для обслуживания другого конца сокета. Для этого они могут выбирать между несколькими future или объединять потоки событий, в зависимости от деталей их жизненного цикла.
Вид может показаться очень высокоуровневым и во многом напоминать модель акторов для асинхронного параллелизма, но благодаря параллелизму внутри задач будет компилироваться в единственную машину состояний для сокета, что во время выполнения очень напоминает написанные вручную асинхронные серверы на языке типа C.
Эта архитектура (и другие подобные ей) сочетает многозадачный параллелизм и параллелизм внутри задач, выбирая тот или иной подход в зависимости от ситуации. Понимание разницы между этими сценариями — ключевой навык для освоения async Rust. У параллелизма внутри задачи есть несколько ограничений: если ваш алгоритм может их выдержать, то этот параллелизм, вероятно, следует использовать.
Первое ограничение состоит в возможности достичь статической чёткости параллелизма только при параллелизме внутри задачи. То есть невозможно объединить (или выбрать и т. д.) произвольное количество future с внутризадачным параллелизмом: это количество должно быть зафиксировано во время компиляции. Это связано с тем, что компилятору необходимо уметь помещать состояние каждой параллельной future в состояние родительской future, и каждая future должно иметь статически определенный максимальный размер. Это в точности соответствует невозможности иметь динамически изменяемую коллекцию объектов в стеке, а нужно использовать что-то вроде Vec
с динамическим выделением памяти.
Второе ограничение заключается в том, что эти параллельные операции не выполняются независимо друг от друга или от ожидающего их родителя. Под этим я подразумеваю две вещи. Во-первых, конкурентность внутри задачи не обеспечивает никакого параллелизма: в итоге существует единственная задача с единственным методом опроса, и несколько потоков не могут одновременно опрашивать эту задачу. Это делает параллелизм внутри задачи малоподходящим для работы, связанной с вычислениями. (Все широко используемые режимы выполнения async также плохо подходят для этого; не думаю, что это существенно для модели async, но с библиотеками, доступными в настоящее время для использования async, это верно.)
Во-вторых, если пользователь отменяет эту задачу, все дочерние операции также обязательно отменяются, потому что все они были частью одной и той же задачи. Поэтому, если вы хотите, чтобы выполнение операций продолжались, даже если родительская задача будет отменена, они должны быть отдельными задачами.
Проблема отсутствия раскраски функции
Хочу на мгновение отвлечься, чтобы вернуться к статье Нистрома и внести в эту дискуссию совершенно другую тему. Обещаю, что и к ней мы ещё вернемся. Надеюсь, что в комплексе они обе станут частью единого вопроса и помогут его прояснить.
Предлагаю продолжить мысленный эксперимент о языке с цветными функциями и представить, что разработчик языка прочитал критику Нистрома и постарался смягчить дискомфорт от использования КРАСНЫХ и СИНИХ функций. В классическом стиле дизайнеров языка, которые не знают, когда остановиться, они добавили третий цвет — ЗЕЛЁНЫЕ функции, как они надеются, помогут разрешить все проблемы. Ну и конечно у этих функций собственный набор правил.
1. ЗЕЛЁНЫЕ функции можно вызывать так же, как и СИНИЕ
В отличие от КРАСНЫХ, для ЗЕЛЁНЫХ функций специального синтаксиса нет: их можно вызывать в любом месте, используя точно такой же синтаксис, как для СИНИХ. Вообще, глядя на их запись и на то, как они используются, сказать, что между ними есть хоть какая-то разница, невозможно. В документации просто будет указано, что функция ЗЕЛЁНАЯ. Ну или не будет, если автор не включит эту информацию.
Отлично! Больше не нужно беспокоиться о цвете той или иной функции; достаточно придерживаться СИНИХ и ЗЕЛЁНЫХ функций.
2. Для каждой примитивной КРАСНОЙ функции существует эквивалентная ЗЕЛЁНАЯ
Конечно, для этого нужно уметь реализовать программу без вызова КРАСНЫХ функций. Поэтому авторы языка добавили в стандартную библиотеку ЗЕЛЁНУЮ функцию для каждой операции, которая в противном случае была бы доступна только с помощью КРАСНОЙ функции.
Способы реализации отличаются друг от друга по производительности, что может быть важно (или нет) для конкретного случая. Я предлагаю пока не заострять внимания на фактической семантике кода, чтобы не увязнуть в этой теме.
3. Существует ЗЕЛЁНАЯ функция, обертывающая и вызывающая любую КРАСНУЮ функцию
Несмотря на наличие в стандартной библиотеке ЗЕЛЁНЫХ функций, пользователи все еще могут столкнуться с библиотеками, написанными с использованием КРАСНЫХ функций. Таким образом, разработчики языка нашли изобретательное решение: существует ЗЕЛЁНАЯ функция высшего порядка, которая в качестве аргумента принимает КРАСНУЮ функцию. Если опустить технические детали, то по сути она просто вызывает КРАСНУЮ функцию. Поскольку ЗЕЛЁНЫЕ функции можно вызывать из любой точки, это решает проблему вызова КРАСНЫХ изнутри СИНИХ функций.
4. Вызов ЗЕЛЁНОЙ функции внутри КРАСНОЙ крайне нежелателен
Конечно, у всего существует темная сторона. Ни при каких обстоятельствах не следует вызывать ЗЕЛЁНУЮ функцию изнутри КРАСНОЙ. Это, конечно, нельзя назвать неопределенным поведением уровня упомянутых в начале «клоунов со змеями вместо рук» на JavaScript, но если вы сделаете это, то определенно замедлите выполнение программы, а в худшем случае это приведёт к взаимной блокировке. Пользователям категорически не рекомендуется это делать. Программисты, которым нравится использовать КРАСНЫЕ функции, должны любой ценой избегать ЗЕЛЁНЫХ.
Но, с добавлением этих функций в язык связана одна проблема: они идентичны СИНИМ функциям, поэтому при вызове их невозможно отличить! При работе нужно опираться на документацию и держать в голове все ЗЕЛЁНЫЕ функции, чтобы ненароком не вызвать их из КРАСНОЙ.
У блокирующей функции нет цвета
Давайте вернемся к Rust. Вы, вероятно, уже поняли, что из себя по сути представляют ЗЕЛЁНЫЕ функции: это любые функции, блокирующие текущий поток. Специального синтаксиса или типов для различения потока, блокируемого в ожидании параллельного события, нет, и именно это Нистром считает большим преимуществом блокирующих функций. В отличие от многих языков с асинхронными функциями, Rust также поддерживает блокирующие функции. В его функционале есть API для выполнения любых операций ввода-вывода или синхронизации потоков путем блокировки, а также API block_on
, который принимает любой Future и блокирует поток до его готовности, позволяя вызывать асинхронные библиотеки как блокирующие.
Языки, не поддерживающие блокирующие операции, не сталкиваются с этой проблемой. Вместо этого при работе с ними становится актуальным то, о чем говорил Нистром — необходимость различать асинхронные и неасинхронные функции. В Rust возможно всё.
Поэтому пользователи, которые не хотят использовать future, могут почти полностью обойтись без них: единственной трудностью будет необходимость иногда использовать в открытых библиотеках (предоставляемых бесплатно и без гарантий) async Rust, а также block_on
для работы с ними из своего кода. Некоторым трудно смириться с таким положением.
Самая незавидная судьба у тех пользователей async Rust, кто не только должен работать с async Rust, но и сталкивается с необходимостью ни в коем случае не вызывать в своем async-коде блокирующую функцию. А ведь такие функции совершенно неотличимы от обычных! Именно это, по мнению Нистрома, должно быть их самым большим преимуществом.
Давным-давно (сразу после введения async/await) я предложил добавить атрибут для блокирующих функций, чтобы попытаться контролировать их вызов в async-контексте и помочь пользователям обнаруживать ошибки такого рода. По неизвестным мне причинам, эта идея в проекте Rust реализована не была. Хотелось бы, конечно, видеть больше усилий для помощи пользователям в нахождении таких ошибок.
Наиболее коварный из блокирующих API для асинхронного ввода-вывода – блокирующий Mutex
. Его использование в асинхронной функции приемлемо при определённых, но все же довольно распространенных условиях:
Он блокируется только на короткие периоды времени.
Он никогда не удерживается на точке
await
.
И тут, внимание, действительно серьезный недостаток: если Mutex
удерживается на точке await
, он может легко заблокировать ваш поток, так как другие задачи в том же потоке попытаются захватить блокировку, пока она удерживается ожидаемой задачей (стандартный Mutex
из библиотеки повторно задействовать не получится ). А это значит, что в каких-то случаях его можно использовать, а иногда, обращаться к нему не просто плохо, а недопустимо. Согласен, не самое лучшее положение дел!
«Мне не нужны быстрые потоки, мне нужны future»
Два предыдущих раздела были посвящены двум довольно не связанным между собой идеям:
В первом мы говорили о том, что модель future из Rust обеспечивает особый вид высокоэффективного «внутризадачного параллелизма», недоступный в модели потоков.
Во втором мы обсудили особенности блокирующих функций и невозможность отличить их от обычных, что проблематично для async Rust.
Эти два обсуждения объединяет признание разницы между асинхронными и блокирующими функциями как дополнительных возможностей признака Future. Это позволяет асинхронной задаче выполнять несколько операций параллельно — то, чего не может делать поток. И отсутствие этой возможности делает вызов блокирующих функций из асинхронного кода проблематичным, так как они не могут перейти в режим ожидания — они могут только блокировать. Мой принцип проектирования для async Rust заключается в следующем: все основания заложить эту возможность существуют, поэтому использовать её нужно на полную катушку. Как написал Генри де Валанс в своём X/Twitter: «Мне не нужны быстрые потоки, мне нужны future».
Эта идея вовсе не нова. В рабочем предложении (RFC) об удалении из Rust библиотеки зеленых потоков Аарон Турон утверждал, что попытка унифицировать API для асинхронного и блокирующего ввода-вывода ограничивает возможности async Rust:
При текущем дизайне модели «зелёных» и «родных» потоков, они всегда должны предоставлять для ввода-вывода одинаковый API. Однако некоторая функциональность эффективна или уместна только в одной из моделей потоков.
Например, самые легковесные модели задач M:N по сути представляют собой наборы замыканий и не обеспечивают какой-то особенной поддержки для ввода-вывода. Этот стиль легковесных задач используется в Servo, и среди прочих присутствует в исполнителях java.util.concurrent и монаде par Haskell. Эти более легкие модели не соответствуют текущей системе выполнения.
Турон продолжил разработку API для future в Rust с подходом на основании готовности, и причины этого можно увидеть в этих заметках. Думаю, что после появления синтаксиса async/await поверх абстракции future (а также из-за увеличения числа разработчиков Rust) эта идея была отодвинута на задний план и частично утеряна. Теперь предполагается, что async Rust и blocking Rust должны быть максимально сходными. Но это лишает async Rust дополнительных возможностей, не считая потенциального повышения производительности путем планирования на уровне пользовательского пространства.
Важно понять, как async/await вписывается в эту картину и что они – это не всё. Future дают возможность мультиплексирования параллельных операций в рамках задачи, но это не обязательное требование. Эта опция критически важна в те редкие моменты, когда вам нужно её применить. В большинстве случаев вы предпочтете, чтобы код шёл линейно. Именно это позволяет делать оператор await — он избегает глубоко вложенных обратных вызовов или цепочек комбинаторов. Это уменьшает стоимость использования future за счёт того, что они разделяют функции на асинхронные и неасинхронные без дополнительных сложностей. Но именно в те моменты, когда вы используете эту возможность и не ожидаете завершения future, всё и решается!
Future позволяют мультиплексировать произвольное количество задач оптимального размера в одном потоке и фиксированное число параллельных операций в рамках одной задачи. Так они позволяют пользователям логично структурировать код для параллельной работы, минимизируя необходимость включения шаблонных решений по управлению потоками. При этом у них значительно лучшая производительность – это может быть критически важно в сценариях с высокой степенью параллелизма. Как по мне, уже одно лишь это оправдывает затрачиваемые усилия и средства, но есть и другие преимущества.
Вернёмся к проблеме удержания блокировок в точках ожидания. Некоторые пользователи перед выполнением любой потенциально длительной асинхронной операции, например ввода-вывода, освобождают блокировку, чтобы другие операции могли перехватить её без ожидания. (тут нужно быть осторожным: необходимо убедиться, что код устойчив к возможным изменениям в защищённом состоянии, происходящим во время выполнения ввода-вывода). Async/await упрощает процесс по сравнению с блокирующим вводом-выводом, так как точки, где задача может выполнять длительную работу, помечены ключевым словом await
. Для блокирующего ввода-вывода ничто синтаксически не указывает на блокировку, и момент ее передачи легко пропустить. Но async Rust может быть ещё эффективнее.
Дэвид Барски предложил признак «жизненного цикла»: интерфейс, аналогичный Drop
, активирующийся при выходе или возобновлении future с объектом. Эта концепция ему показалась особенно интересной для трассировки, которая включает информацию о выполняемой задаче во все сообщения журнала и поэтому должна знать о любых изменениях. Её также можно использовать для создания примитива блокировки с автоматическим освобождением при выходе future и повторном захвате при возобновлении. Это устраняет риск забыть освободить блокировку при ожидании, и будет даже более оптимально, чем ручное управление. Если ваша задача на самом деле не приостанавливается (поскольку future был готов сразу), вам не придётся освобождать и заново захватывать блокировку.
maybe(async)
Было бы недальновидным не упомянуть одну функцию, обсуждаемую в рамках проекта Rust. По моему мнению, она полностью противоречит моему подходу: это идея maybe(async)
. Это функция (синтаксис будет определен позже) для написания кода, абстрагирующегося от того, асинхронный он или нет. Другими словами, любая функция maybe(async)
может быть реализована в двух вариантах: одном асинхронном (в котором ожидается наличие всех future) и одном синхронном (где функции, возвращающие future в асинхронной версии, будут блокироваться).
Главная проблема этой идеи — она эффективна только для многозадачного параллелизма. Как я уже писал ранее, существует прямая аналогия между кодом, написанным с использованием многозадачного параллелизма, и кодом с многопоточным параллелизмом. Однако у внутризадачного параллелизма нет аналогов в системах параллелизма на основе потоков, так как он зависит от возможностей future. Таким образом, любая попытка использовать maybe(async)
будет ограничена участками кода, строго использующими многозадачный параллелизм. Проблема заключается в наличии в любом значительном объёме кода участков, чья ключевая секция использует преимущества параллелизма внутри задачи и непригодна для абстракции с помощью maybe(async)
.
Недавно Марио Ортис Манеро написал о сложностях создания библиотеки, поддерживающей использование как блокирующего, так и асинхронного ввода-вывода. Эта статья в блоге представляется мне наиболее убедительным аргументом в пользу maybe(async)
, поэтому я хочу более тщательно её проанализировать.
Его случай заключался в создании обертки, транслирующей вызовы методов Rust в HTTP-запросы к API Spotify. Он хочет поддерживать как блокирующие, так и асинхронные версии своей библиотеки из одного исходного кода, используя reqwest как асинхронный HTTP-клиент и ureq как блокирующий HTTP-клиент. Он писал о том, что на данный момент это очень трудно, и с этим нельзя не согласиться.
Примечательно, что в библиотеке reqwest есть как собственный блокирующий HTTP-клиент, так и асинхронный. Для этого создаётся фоновый поток, при котором все запросы к этому клиенту выполняются асинхронно, мультиплексируя их на одном потоке.
Однако Ортис Манеро отверг этот подход:
К сожалению, это решение всё ещё связано со значительными издержками. Большие зависимости типа
future
илиtokio
добавляются и интегрируются в исполняемый файл. И всё это ради того, чтобы в итоге написать блокирующий код. То есть издержки возникают не только на этапе выполнения кода, но и при его компиляции. Мне это кажется неправильным.
Под «издержками» Ортис Манеро имеет в виду время на компиляцию этих зависимостей, а не на выполнение. Но давайте подумаем, зачем reqwest нужны эти зависимости, даже если это «кажется неправильным»? В блокирующем reqwest для мультиплексирования всех запросов к одному клиенту на одном потоке используется tokio. Это архитектурное различие между блокирующим reqwest и ureq (выполняющим блокирующий ввод-вывод из потока запроса) кажется мне более значимым, чем зависимость одного от tokio и отсутствие таковой у другого. Хотелось бы увидеть сравнение этих двух подходов для разных рабочих нагрузок на основе бенчмарков, а не отвергать один из них только из-за его дерева зависимостей.
Одной из функций reqwest является поддержка HTTP/2, которой нет у ureq. HTTP/2 разработан для мультиплексирования различных запросов через одно TCP-соединение. Ureq же предоставляет только HTTP/1 без пайплайна. И он не может поддерживать это с текущей архитектурой, поскольку каждый запрос пользователя через TCP-соединение блокирует поток до завершения запроса. Таким образом, количество параллельных сетевых запросов к сервису через ureq ограничено количеством TCP-соединений, которые сервис позволяет открыть; для каждого нового соединения требуется новый хэндшейк TCP (и вероятно TLS).
Если бы в ureq решили поддерживать HTTP/2 и его мультиплексирование, то столкнулись бы с необходимостью реализовать его через одно TCP-соединение. Возможно, async Rust для этого бы не использовался. Но при желании применить блокирующий ввод-вывод и при этом сохранить текущий API, всё равно пришлось бы использовать фоновый поток и каналы для мультиплексирования запросов из разных потоков через одно TCP-соединение. Другими словами, архитектура стала бы похожа на архитектуру reqwest.
Используя async Rust, reqwest легче скрывает различия между мультиплексированием запросов к разным соединениям в HTTP/1 и к одному соединению в HTTP/2. Это преимущество значительное, так как пользователи часто не знают, поддерживает ли интересующий их сервис HTTP/2.
Тем не менее, можно утверждать, что maybe(async)
окажется для автора полезным даже после перехода с ureq на блокирующий API reqwest, поскольку позволит избежать создания асинхронной версии библиотеки и блокирующего API поверх неё. Однако из-за ограничений, налагаемых maybe(async)
, это верно только для определённого типа библиотек, в строгом смысле представляющих собой «отображение» семантики библиотеки более низкого уровня.
Как в этом примере, это может библиотека, переводящая вызовы HTTP RPC в объекты и методы Rust, или библиотека, определяющая протокол на уровне байтового интерфейса, например TCP. Как только у библиотеки появляется собственное изменяющееся состояние (как у подлежащих ей библиотек HTTP или ввода-вывода), две реализации начинают существенно различаться и не могут быть реализованы одним и тем же кодом с помощью maybe(async)
.
Поскольку для этих библиотек поддержание двух версий сводится лишь к шаблонному коду, существуют более эффективные способы поддержки, чем добавление новой абстракции. Один из подходов заключается в использовании системы макросов. Она позволяет создать что-то вроде блокирующего интерфейса reqwest на основе асинхронного интерфейса, генерируя код для фонового потока и преобразования блокирующих функций в сообщения этому потоку. Библиотеки вроде клиента Spotify могли бы использовать этот макрос для избегания шаблонной поддержки своего блокирующего API за счёт использования асинхронной среды выполнения на фоновом потоке. В отличие от maybe(async)
, этот подход одинаково хорошо применим и для библиотек без состояния, и для библиотек с ним.
Другой подход называется «sans-IO» («без ввода-вывода»). Автор ureq также поддерживает библиотеку WebRTC под названием str0m, написанную в этом стиле, что позволяет избежать проблем с блокирующим и неблокирующим вводом-выводом, вообще не обрабатывая ввод-вывод в библиотеке. Такой библиотекой является quiche от Cloudflare, реализующей конечный автомат для QUIC, но без выполнения операций ввода-вывода. Опираясь на эту концепцию, можно представить способ полностью исключить проблему ввода-вывода из этих библиотек, написав их на основе абстрактного интерфейса, позволяющего выполнять их на любой реализации UDP, TCP, HTTP или других зависимостей. Как это можно обобщить — ещё предстоит выяснить.
Последнее отступление о сопрограммах
Статья и так уже получается слишком длинной, но она может заинтересовать читателей за пределами сообщества Rust, и вызвать определённую негативную реакцию: описываемые в ней возможности future достижимы не только с помощью future! Эти возможности могут быть реализованы любым видом сопрограмм. Rust использует бесстековые сопрограммы, имеющие определенные ограничения, но язык со стековыми сопрограммами также мог бы обеспечить те же возможности с меньшим количеством сложностей.
Я полностью согласен. Возвращаясь к миру вымышленных языков, можно представить язык, где все функции — сопрограммы, а значит каждая функция в нём может приостанавливаться. И никакой раскраски функций! «Чистая» функция будет выдавать Never
(то есть на самом деле не создавать ничего), а «нечистая» функция будет выдавать Pending
(или другой магический тип из вашей среды выполнения в ожидании внешнего события). Нечистые функции будут использоваться по умолчанию, а все функции будут сопрограммами, так что оператор вызова будет автоматически передавать наружу значения Pending
. Можно пометить чистые функции для случаев, когда нужно гарантировать отсутствие операций ввода-вывода или синхронизации.
Язык также должен предоставить возможность создавать объект сопрограммы и возобновлять его вместо выполнения до завершения. Используя эту возможность, можно реализовать комбинаторы параллелизма, такие как select и join. Язык должен предоставить способ создания сопрограмм как полностью новых параллельных задач. И все это без необходимости использования async/await — вот что дают стековые сопрограммы.
Эти функции сопрограмм даже можно расширить для представления других объектов. Например, итерации можно представить как сопрограммы, выдающие значимое значение. Циклы for
будут обрабатывать каждое из этих значений по очереди. Асинхронные итерации будут просто выдавать это значение или Pending
. Исключения можно моделировать аналогичным образом, генерируя ошибку (вероятно, будет использоваться отдельное от yield и return ключевое слово, в знак признания того, что функция, сгенерировавшая исключение, не может быть перезапущена). Это не полное описание языка, но в целом подобный процесс выглядит убедительно и правдоподобно.
Действительно, для этого не обязательно использовать сопрограммы. Можно смоделировать в обратном порядке, регистрируя точку в стеке для возврата к каждому из этих элементов: одну для ожидающих операций ввода-вывода, одну для выброшенных исключений, одну для элементов, извлеченных из итерируемого объекта и так далее. Эту точку в стеке можно назвать «обработчиком» для указанных «эффектов», другими словами, своего рода «обработчиком алгебраических эффектов». В общем, эти два понятия языка — обработчики эффектов и сопрограммы — как минимум отчасти изоморфны друг другу.
Я также полагаю, хотя и не уверен на сто процентов, что такой язык мог бы обеспечить, относительно невозможности ссылкам быть одновременно изменяемыми и разделяемыми, те же гарантии, что и Rust, без добавления времени жизни в синтаксис. Пока сопрограммы могут завершаться и возобновляться с ссылками, сами ссылки могут превратиться в модификаторы, которые нельзя встраивать в типы объектов, и их время жизни можно полностью вывести. Это не позволит представлять код так же оптимально, как в Rust (в терминологии предыдущей статьи, будет отсутствовать доступ к «низкоуровневому регистру»), но все равно обеспечит те же гарантии корректности.
Почему в Rust не было реализовано что-то похожее? А ведь было! Но в итоге пришлось пойти на компромисс из-за других требований. Недавно на сайте lobste.rs был опубликован отличный комментарий, где мысль выразили лучше, чем это удалось бы мне:
Функции асинхронного стиля языка — это компромисс между вашей моделью выполнения, нативно совместимой с C ABI 1:1, стандартной библиотекой C и средой выполнения C, и моделью выполнения M:N. C++ async сталкивается с теми же проблемами, но он менее строг в вопросах безопасности времени жизни, а это – не очень хорошо. Ценой за нативную совместимость с системной средой выполнения C является проблема «раскраски функций».
Для Rust изначально была поставлена цель быть совместимым с текущей средой выполнения C. Это значит, что код на Rust составлен из стека подпрограмм, адреса элементов которого можно получить и сохранить не только в этом стеке, но и в других участках памяти программы.
Этот подход в Rust был выбран для обеспечения бесплатного FFI к огромному количеству существующего кода на C и C++, написанного по этой модели, так как среда выполнения C общая для всех основных платформ. Но эта модель времени выполнения несовместима со стековыми сопрограммами. Поэтому Rust ввёл механизм бесстековых сопрограмм. Каждый язык с async/await также привязан к существующей среде выполнения, которая не может представить стековые сопрограммы, и если это не среда C, то виртуальная машина. Особенность среды выполнения C в том, что она настолько распространена, что многие программисты даже не осознают её существование и считают её природным явлением.
Ещё одно замечание:
Какой-нибудь известный разработчик языков программирования мог бы убедить крупную технологическую компанию вложиться в создание нового языка, менее зависимого от среды выполнения C. Особенно если бы у него была безупречная репутация системного инженера с глубокими знаниями C и UNIX, которая помогла бы быстро распространить этот язык. Обладая таким влиянием, можно предложить новую парадигму, такую как стековые сопрограммы или обработчики эффектов, освобождая программистов от ложного выбора между потоками и future. Лейбниц утверждал, что мы живем в лучшем из возможных миров. Если это действительно так, то разработчик наверняка воспользовался бы такой уникальной возможностью.
(Хочется верить, что он бы удержался от обвинений пользователей в «неспособности понять новый гениальный язык»!)
В мире не столь идеальном можно выбрать что-то более приземлённое. Можно взять перерыв от среды выполнения C и затем просто вновь использовать потоки с похожей семантикой, но планируемые в пользовательском пространстве. Пользователи должны будут реализовывать параллелизм через потоки, блокировки и каналы, как это всегда делалось раньше. В язык можно добавить классические элементы, такие как нулевые указатели, конструкторы по умолчанию, гонки данных и GOTO. Можно ещё долго было бы оттягивать добавление дженериков, несмотря на настойчивые просьбы пользователей. Но это в не идеальном мире.
Увы. Иногда мне кажется, что в нашей отрасли застой, и каждое десятилетие мы переписываем новые программы с одинаковым поведением на новые языки с той же семантикой, получая лишь небольшие отличия в производительности, объясняемые скорее повышением аппаратных возможностей. Печальная участь, но, возможно, скоро она постигнет не программистов, а большие языковые модели (LLM). Я активно продвигаю Rust, так как считаю, что он благотворно влияет на программирование – стимулирует эволюцию других языков.
Несмотря на весь прогресс, Rust так и не отказался от среды выполнения C. Это более низкоуровневый и сложный аналог того же языка: можно получить те же гарантии, но потребуется «доработка напильником». Необходимо приложить все усилия в рамках наших строгих требований для минимизации необходимости доработки, и async/await уже заложил основу для этого.
Следует стремиться не к упрощению системы путём скрытия различий между future и потоками, а к созданию правильного набора API и функций языка, расширяющих возможности future для реализации большего количества применений. Сейчас у нас есть только основа, но это уже значительный прогресс по сравнению с прошлым миром ручных конечных автоматов и управления циклом обработки событий напрямую. Не надо менять future: доступный нам фундамент нужно использовать для развития, потому что это откроет перед нами ещё больше возможностей.