Как стать автором
Обновить

Комментарии 47

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

И вообще, интуиция прямо подсказывает, что все это костыли, но чтобы доказать это нужно найти слабые места в предложенных изменениях и показать, что все становится только сложнее для всех.

>>Даже поток, который сидит и ничего не делает, использует ценные системные ресурсы.
Какие?

Как минимум, место под стэк. А футуры собственный стэк не пользуют.

Место под стек это ценные системные ресурсы? О_о
Вообще-то да. Место резервируется под рост и при большом количестве потоков (реалистичный сценарий для 32 битных приложений) можно запросто get virtual addressspace exhausted.
У меня есть такой проблемный сервер (под NT, не касается Rust, но это абстрактный разговор) — под каждое сосединение (а они keep-alive + временные под http) выделяется поток. 1500 соединений — это, в общем, максимум, что такой сервер может вытянуть. А потом просто жестко падает.
На NT в 32 битных приложениях вообще reserv'ится стек как для нативной 64 битной части потока, так и для wow64, что еще хуже.

Тем не менее футуры тоже должны хранить состояние свое.

Футуры требуют столько памяти, сколько им нужно, а не резервируют большие куски на всякий случай.

Бывают ещё горутины, там местом под стэк более экономно управляют

Или в случае, когда несколько потоков (одно соединение на один поток) конкурируют за ограниченный ресурс, приходится использовать примитивы синхронизации. Применяя концепцию асинхронности, мы можем добиться более высокой производительности, работая с несколькими соединениями в одном потоке.

Только вот вряд ли у вас будет один поток по кругу обслуживать все соединения. Будет пул потоков, и вон то соединение, с которым вы хотите тоже поработать, может быть «под другим» потоком, а значит, синхронизации все равно не избежать.

Подскажите, пожалуйста. Какие есть средства у Rust для написания тестов на асинхронный код?

Чтобы не быть как Алан и перед выдачей убедиться в асинхронной безопасности и корректности своего кода?

Чтобы не быть как Алан по большей части достаточно помнить, чем отличаются синхронные функции от асинхронных.

Синхронная функция в скомпилированом виде - это цельный кусок машинного кода. Мы не можем остановить ее выполнение посередине или даже поставить на паузу, если мы не хотим/не можем пользоваться инструментами, которые нам предоставляет ядро (например, таким инструментом будет SIGINT+обработчик, или выведение синхронной функции в отдельный поток, а затем остановка этого потока из другого потока нашей программы)

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

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

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

Предложение автора дать асинхронным функциям гарантированное завершение звучит очень странно. Их же как раз делали в т.ч. для того, чтобы можно было спокойно отменить выполнение уже ненужной функции прямо пока она работает, и это было бы нормальным поведением. Предложение отказаться от .await еще хуже - сейчас вызов асинхронной функции без .await означает создание того самого конечного автомата, который можно затем отдать какому-нибудь executor'у, а вызов .await означает создание в текущей асинхронной функции точки останова и встраивание в нее конечного автомата той функции, на которой был вызван .await

По итогу, статья очень спорная, со странными примерами и (на мой взгляд) плохими предложениями. Алану я бы рекомендовал почитать Async Book - это должно помочь ему не делать таких ошибок на ровном месте

Предложение отказаться от .await еще хуже - сейчас вызов асинхронной функции без .await означает создание того самого конечного автомата, который можно затем отдать какому-нибудь executor'у

Такое нужно в 5% случаев. А в 95% случаев при вызове асинхронной функции перед ней ставят await - можете посмотреть статистику в своем коде. Т.е. по сути надо бы await инвертировать: по дефолту все авейтится, а если хочется получить future (или promise в других языках), то тогда пишем ключевое слово.

Это наводит на мысль, что ключевое слово вообще нужно только одно - async:

  • async перед объявлением функции означает, что она асинхронная

  • async перед вызовом функции означает, что не надо ее авейтить, а вместо этого надо вернуть future или promise.

Я еще могу понять, почему в JS есть два ключевых слова async и await (язык динамически типизированный, и вызвав функцию, нельзя заранее знать, синхронная она или асинхронная). Но почему в статически типизированных языках (в том числе C++20) нельзя обойтись одним ключевым словом? Иначе как инерцией мышления авторов, я не знаю, как это объяснить. Вы знаете?

P.S.

Что-то по прочтении данной статьи сложилось удручающее впечатление об асинхронности в Расте. Я, конечно, не растоман ни разу - может, поэтому. Но кажется, что оно куда-то не туда едет совсем.

Но почему в статически типизированных языках (в том числе C++20) нельзя обойтись одним ключевым словом?

Это имеет меньше отношения к статической типизации и больше к ориентированности на zero-cost abstraction. Явный await каждый раз напоминает, что на этом месте исполнение уходит в нарнию за результатом и вернётся когда-нибудь.


Естественно, компилятор мог бы и сам увидеть, что если с одной стороны Future, а с другой T — то надо вставить await. Точно так же он мог бы и увидеть, что если с одной стороны Result<T, E>, а с другой — T, то можно вставить try! Но он так не делает и вы должны явно писать ?, чтобы развернуть результат с возвратом ошибки.

Как раз .await в этом примере должен был навести Алана на мысли, что с его кодом что-то не так:


let len = socket.read_u32().await?;
let mut line = vec![0; len];
socket.read_exact(&mut line).await?;

Два раза происходит чтение из сокета, причем блок кода выполняется не атомарно, так как в местах вызова .await возможны переключения. Другое дело, если код синхронный:


let len = socket.read_u32()?;
let mut line = vec![0; len];
socket.read_exact(&mut line)?;

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

Извините, а что именно с эти кодом не так? Вы знаете какой-то другой способ прочитать из сокета пакет переменной длины?


Более того, сама реализация read_exact в tokio подвержена той же самой проблеме с отменой операции, так что даже использование буфера фиксированной блины не спасёт!

У меня такое ощущение, что вы бросились мне отвечать не прочитав исходного сообщения, на которое я ответил. Поэтому ваши вопросы звучат как-то странно.


Извините, а что именно с эти кодом не так?

С ним не так то, что он асинхронный, а не синхронный. А значит в любой момент, где есть .await, может произойти переключение. И хорошо, что .await написан явно, а не заметен под ковер, как предлагает делать автор статьи и поддерживающие его комментаторы.


Как это исправить? Не надо здесь принимать socket по шаренной ссылке (как вариант).

Но ведь проблема, на которую указано, заключается вовсе не в переключении!

Не нравится термин "переключение" — хорошо, можно сказать боле общо: проблема в прерывании. .await явно показывает, в каком месте может возникнуть прерывание фьючи. Без него подобные ошибки будет проще допустить.

для обычных юнит-тестов асинхронного кода есть крайне удобный макрос #[tokio::test]. В tokio, в том числе, можно вручную управлять временем, а в крейте tokio-test есть примитивы для мока I/O, например.


для более глубокой проверки можно воспользоваться инструментом loom

для обычных юнит-тестов асинхронного токио есть крайне удобный макрос #[tokio::test]. В tokio, в том числе, можно вручную управлять временем, а в крейте tokio-test есть примитивы для мока I/O, например.


для более глубокой проверки можно воспользоваться инструментом loom

В этом и заключается проблема: любая асинхронная функция Rust может перестать работать в любое время, так как её могут просто прервать.

Вообще-то это стандарт асинхронного программирования. Так ведёт себя асинхронный код везде. И обозначенная в статье проблема решается иначе.

Нет, это не стандарт.


В C# асинхронную функцию нельзя прервать в произвольном месте. В JavaScript — тоже. В С++ — зависит от возвращаемого типа.

"в произвольном" или всё-таки "в точке await'а"?

В точке await, разумеется.

Вот и в Rust можно только в точке .await'a, тем более это язык, компилируемый в нативный код - просто взять и из кода приложения сказать виртуалке остановиться не получится, потому что нет ее, этой виртуалки

Ещё раз повторяю: в C# асинхронную функцию нельзя прервать в произвольной точке await, В JavaScript — тоже.


Что значит "вот и в Rust можно"?

НЛО прилетело и опубликовало эту надпись здесь

Там возможны ситуации что асинхронная функция работала-работала, и больше не работает и никогда не завершится?

Да, если вызывающая сторона решила, что ей больше не нужен результат выполнения асинхронной функции, и удалила соответствующий Future.

К сожалению, после небольшой переписки в чате сервер вылетает с ошибкой «invalid UTF-8». Теперь Алан не понимает, в чём дело: он проверяет код и не находит ошибок.

Я тоже слегка не понял — значит ли это что в любой точке прерывания (await) функция может закончиться и ее локальный стек пропадет навсегда? Для меня это звучит как очень нелогичное и бессмысленное поведение, в чем тогда вообще профит async/await без гарантии локального фрейма функции?

Функция может закончиться если вызвавший её код решил что результат ему не нужен.

А в данном случае как такое получается? Там есть какойто автогенерируемый код, который забывает про функтор, или автор статьи скрыл от нас какойто кусок кода?

Ничего он не скрыл. Посмотрите ещё раз на тот код, где используется макрос select!.


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

Ага, тоесть код внутри макроса написан им, это не автогенерируемый код? Фраза написана так, что не понятно, откуда появился этот макрос

футура это просто трейт с методом Poll, если его никто не вызывает,то она ничего и не делает. А так как это обычная структура, то ее можно просто взять и дропнуть, тогда она не завершится. Все довольно понятно становится, если все таки посмотреть на внутреннее устройство.

По сути, нужно писать код, держа в уме те же мысли, что и при написании exception safe кода.

Так понятно, спасибо. Действительно, для меня — привыкшего к nodejs async — это было бы крайне неочевидным поведением, но теперь, посмотрев на это в контексте ownership, стало понятнее.

В яваскрипте это называется генераторами.

Сразу оговорюсь, я не очень глубоко знаю раст, я его только изучаю. Зато я прилично знаю python, go и js. Поэтому заранее прошу прощения, если с точки зрения раст разработки сморожу глупость.


Писать асинхронный код зачастую сложнее, чем просто использовать потоки.

В чем же? Помечай асинхронные функции и не забывай сообщить "я помню что этот результат нужно подождать" при их вызове. По крайней мере в js и python как — то так. Единственно, я не увидел нигде упоминания о промисах, они уже существуют, или вы о них забыли?
Почему вы противопоставляете асинхронность и параллельность? Это 2 стороны одной медали, кмк. Что выбрать диктует задача, которую вы решаете, но ни как не инструмент. Например, для расчетных задач, где нет большой работы с файлами или сетью и зависимостей между ними потоки могут стать более предпочтительным выбором. Для сервиса — да, асинхронность работает лучше, потому что сеть отвечает не мгновенно, да и человек на том конце соединения может "задуматься". В таком случае имеет смысл отложить задачку и переключиться на что — то еще, пока не придет время вернуться к этой.


Например, приостановку или отмену операции на лету будет сложно реализовать

Стоит ли задумываться о реализации отдельных операций, или все таки рассмотреть механизм коммуникации с сопрограммами, через который и сообщать им о необходимости уснуть или быстро завершиться, подставляя на такие сообщения обработчики по умолчанию, если разработчик их не перекрыл своими?


Изменение #1

Даешь горутины почти в чистом виде? Только в go сопрограмма открывает управляющий канал и ловит команду на завершение от запустившей ее стороны. Произошла ошибка или пришла команда завершиться — аккуратно прибираемся и выходим из сопрограммы.


Явные и неявные вызовы .await

Питонский дзен учит: явное лучше неявного. Следствием этого может оказаться пожелание "не убирать .await".


Изменение #3: Отказываемся от Arc и используем scoped tasks

Здесь уже стоит посмотреть в сторону языка ada. Там, как я вас понял, присутствует что — то похожее. Вдруг окажется интересным, изучить опыт предшественников.

В статье есть ошибка в коде первого примера: операции read_u32 и read_exact вызываются только на мутируемом объекте (&mut TcpStream), а в статье объект заимствуется неизменяемым (&TcpStream).

Оказывается, под капотом, в стеке вызовов, используется макрос select!

Вот этот оборот — "под капотом" — все только запутывает. Нет там никакого "капота", речь про пользовательский же код, который просто находится выше по стеку вызовов. А то складывается впечатление, будто проблема где-то в недрах tokio.

А вот мне интересно, когда Алан писал код


select! {
    line_in = parse_line(&socket) => ...
    line_out = channel.recv() => ...
}

То он чего ждал? Что программа не будет работать так, как написано? select! и нужен для того, чтобы прерывать выполнение фьюч, а он взял и в него засунул parse_line. Код и сделал то, что его попросили: прервал чтение и парсинг, если channel.recv() завершился быстрее. Вместо этого можно было воспользоваться сначала методом peek у TcpStream для ожидания появления данных в очереди без их удаления. Или вообще переписать эту логику с использованием try_read.

А как тут вообще poll_peek использовать, когда задача — именно что дождаться появления данных?

Смысл в том, чтобы с помощь peek дождаться появления нужных данных в очереди — это ожидание и пихать в select!, а вычитать данные уже после, по факту выполнения ветки после ожидания.

Да, такой вариант возможен, но у него есть трудности с композицией: если у нас есть код, который читает таким образом A, и есть код, который читает таким образом B — вы не можем простым путём получить код, который читает AB.

Я категорически против того чтобы отказаться от await, именно за это недолюбливаю Kotlin.

Слишком много не очевидной магии. Раст конечно подразумевает много кодогенерации и макрсов, но они обычно явно вызваются. В принципе мне даже не очень нравится что async функции в сигнатуре возврата не указывают футуру, а тип-значения, но это терпимое зло (хотя impl Futute было-бы нагляднее как по мне).

Раст точно слишком близкий к железу чтобы пропускать авейт и безконтрольно авейтить. Следующий пропозал -- сделать все ф-и асинхронными и создать "Грин треды" и прощай статус системного языка.

Я категорически против того чтобы отказаться от await, именно за это недолюбливаю Kotlin.

А вы можете рассказать, какие это создаёт проблемы?

Видосик бы хороший на пару часов на эту тему. Мне как человеку лишь поверхностно знакомым с асинхронностью в расте трудно понять.

Зарегистрируйтесь на Хабре , чтобы оставить комментарий