Асинхронный Rust: проблемы и способы их решения

В 2013 году я открыл для себя Rust и сразу решил изучить его, сделав своим основным языком программирования.

Через четыре года я переехал в Берлин и присоединился к компании Parity в качестве разработчика Rust. В первые несколько месяцев моей задачей было создание библиотеки rust-libp2p с использованием технологии peer-to-peer на асинхронном Rust (на данный момент она насчитывает ~89 тысяч строк кода). Впоследствии я интегрировал ее в Substrate (~400 тыс. строк кода) и с тех пор являюсь специалистом по сопровождению сетевой части кодовой базы.

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

Внимание: я пишу эту статью не от имени своего работодателя Parity. Это мои личные замечания по итогам работы над наиболее асинхронными частями проектов, связанных с Parity. Но я никому их не показывал перед публикацией этой статьи, так что мнения других разработчиков этой компании могут не совпадать с моим.

Вводная часть

Мне показалось нужным добавить вступительный раздел, учитывая дискуссии и споры, которые происходили вокруг асинхронного Rust в последнее время.

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

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

Статья не называется «Проблемы в асинхронном Rust», хотя и акцентирует внимание на проблемах  —  опять же, во избежание превратного понимания.

За те годы, что асинхронный Rust создавался, в сообществе много раз возникала напряженность. И я с большим уважением отношусь к людям, которые все это время тратили свою энергию на дискуссии и регулирование огромного потока мнений. Именно поэтому последние 4–5 лет я был в стороне от сообщества Rust и воздерживаюсь от какой-либо критики в их адрес.

Но сфокусируемся больше не на прошлом (future 0.1 и future 0.3), а на теперешнем положении дел. Тем более, цель этой статьи в конечном итоге в том, чтобы продвинуться дальше.

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

Проблема отмены future

Начнем с самого проблемного на сегодняшний день вопроса в асинхронном Rust: возможно ли удаление future без появления бага.

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

async fn read_send(file: &mut File, channel: &mut Sender<...>) {
  loop {
    let data = read_next(file).await;
    let items = parse(&data);
    for item in items {
      channel.send(item).await;
    }
  }
}

Каждая точка await в асинхронном коде представляет собой момент, когда выполнение может быть прервано, а контроль  —  возвращен пользователю этого future. Пользователь может при желании удалить future в этой точке, полностью остановив его выполнение.

Когда пользователь вызывает функцию read_send, то опрашивает ее, пока она не достигнет второй точки await (отправка по каналу), затем уничтожает future read_send, а все локальные переменные (data, items и item) автоматически удаляются. Таким образом пользователь извлечет данные из file, но без отправки этих данных по каналу channel. Эти данные просто теряются.

Вы спросите: «Зачем пользователю это делать? Зачем опрашивать future, а затем уничтожать до его завершения?». Именно это делает макрос futures::select!:

let mut file = ...;
let mut channel = ...;
loop {
    futures::select! {
        _ => read_send(&mut file, &mut channel) => {},
        some_data => socket.read_packet() => {
            // ...
        }
    }
}

В этом втором фрагменте кода пользователь вызывает read_send и опрашивает ее. Но если socket получает пакет, то future read_send уничтожается и воссоздается на следующей итерации цикла. Мы уже видели: это приводит к тому, что данные из файла извлекаются, но по каналу не отправляются. Вряд ли это то, что нужно пользователю.

И тут надо внести ясность. Дело в том, что все это работает, как и должно. Проблема не в том, что именно происходит, а скорее в том, что происходит не то, что задумал пользователь. Конечно, такая ситуация, когда пользователь просто хочет полностью остановить read_send, тоже возможна. Но здесь нам этого не нужно.

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

  • Чтобы не уничтожать future, переписываем select!. Пример. Это, пожалуй, лучшее решение в данной конкретной ситуации. Но иногда оно чревато большими сложностями, например при воссоздании future с другим File, когда сокет получает сообщение.
  • Делаем так, чтобы read_send выполняла чтение и отправку атомарно. Пример. В целом это лучшее решение, но оно не всегда возможно или приводит к накладным расходам в сложных ситуациях.
  • Меняем API у read_send и избегаем любых локальных переменных в точке завершения. Пример. Пример из реальной ситуации. Тоже хорошее решение, но такой код бывает трудно написать, так как он все больше походит на futures, написанные вручную.
  • Не задействуем select! и создаем фоновую задачу для чтения. При необходимости используем канал для взаимодействия с фоновой задачей, так как на извлечение элементов из каналов отмена future не влияет. Пример. Это часто оказывается лучшим решением, хотя добавляет временную задержку и делает невозможным последующий доступ к file и channel.

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

Еще более проблематично, когда с кодовой базой работает не один разработчик, а несколько. Один разработчик может подумать, что отмена future происходит безопасно, хотя это не так. Может устареть документация. Разработчик может выполнить рефакторинг реализации future и случайно сделать отмену future небезопасной. Или провести рефакторинг части кода с select!, с тем чтобы уничтожение future происходило в другой момент времени, нежели чем это было раньше. Да и писать юнит-тесты (чтобы убедиться, что future после уничтожения и воссоздания работает правильно) очень муторно.

Мои рекомендации следующие. Точно знаете, что ваш асинхронный код будет в виде порожденной фоновой задачи? Тогда смело делайте все, что захотите. Не уверены  —  тогда позаботьтесь о том, чтобы отмена future происходила в нем безопасно. Слишком сложно? Тогда выполните рефакторинг кода для порождения фоновой задачи. Данные рекомендации предполагают, что реализовывать future будет один разработчик, а использовать, скорее всего, другой. Это обстоятельство часто не учитывается в маленьких примерах.

Что касается улучшения самого языка Rust, конкретного решения у меня, к сожалению, нет. Да, в Rust предложили инструмент проверки кода Clippy, который запрещает локальные переменные в точках завершения. Но он, видимо, просто не может определить момент, когда задача порождается в длительном цикле событий. И можно было бы подумать об использовании какого-нибудь типажа InterruptibleFuture для select!, но это наверняка еще больше повредит доступности асинхронного Rust.

Типаж Send больше не тот, что прежде

Типаж Send на Rust означает, что тип, в котором он реализуется, может быть перемещен из одного потока в другой. Большинство типов, с которыми вы работаете при ежедневном написании кода, реализуют этот типаж: String, Vec, целые числа и многие другие. Проще назвать те типы, которые не реализуют Send. Один из них  —  Rc.

Типы, которые не реализуют типаж Send, как правило более быстрые. Так, Rc  —  это более быстрая альтернатива типу Arc. А тип RefCell  —  более быстрый, чем Mutex. Это дает программисту возможность оптимизировать, ведь он знает: то, что он делает, происходит в рамках одного потока.

Однако асинхронные функции такое представление, в общем-то, поломали.

Представьте, что приведенная ниже функция выполняется в фоновом потоке и вам надо переписать ее в асинхронный Rust:

fn background_task() {
   let rc = std::rc::Rc::new(5);
   let rc2 = rc.clone();
   bar();
}

Велик соблазн просто добавить async и await:

async fn background_task() {
   let rc = std::rc::Rc::new(5);
   let rc2 = rc.clone();
   bar().await;
}

Но, попытавшись создать background_task() в цикле событий, вы упретесь в стену, потому что future, возвращаемый из background_task(), не реализует Send. Сам future, скорее всего, будет перемещаться между потоками. Отсюда и требование типажа Send к типу, в котором он реализуется. Но теоретически этот код абсолютно нормальный. До тех пор, пока тип Rc остается в этой задаче, мы так и будем всегда уверены, что его клоны клонируются или уничтожаются только по одному  —  вот где потенциальная опасность.

Пожалуй, типаж Send неплохо было бы изменить, чтобы он означал «объект, который может быть перемещен между границами потока или задачи». Но это поломает код, который использует !Send для целей, связанных с интерфейсами внешних функций, где важны потоки, например с OpenGL или GTK.

Управление потоком передачи данных затруднено

Многие асинхронные программы, в том числе Substrate, построены вокруг того, что мы обычно называем моделью акторов. Такое построение предполагает, что задачи выполняются параллельно в фоновом режиме и обмениваются друг с другом сообщениями. Когда задачам нечего выполнять, они переходят в режим ожидания. Я бы сказал, что именно такое построение программ асинхронная экосистема Rust продвигает и поддерживает.

Когда задача А непрерывно отправляет сообщения задаче В по неограниченному каналу, а задача В обрабатывает эти сообщения медленнее, чем задача А их отправляет, то количество элементов в канале все время растет и фактически возникает утечка памяти.

Велик соблазн во избежание этой ситуации использовать здесь ограниченный канал. Когда задача B медленнее задачи A, буфер в канале заполнится, после чего задача A замедлится, так как ей, прежде чем продолжить, придется ждать нового слота. Этот механизм называется противодавлением. Но, когда задача B тоже отправляет сообщения задаче A (намеренно или нет), использование двух ограниченных каналов в противоположных направлениях приведет ко взаимоблокировке в тот момент, когда они оба будут заполнены.

Более сложный случай: когда задача А отправляет сообщения задаче В, задача В отправляет сообщения задаче С, а задача С отправляет сообщения задаче А (причем все с использованием ограниченных каналов), то каналы также все заполнятся и возникнет взаимоблокировка. Обнаружить такую проблему практически невозможно, и единственный способ ее решить  —  иметь правильную архитектуру кода, предусматривающую возможность обнаружения этой проблемы.

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

Такая взаимоблокировка характерна не только для Rust. Это фундаментальная проблема с сетями и моделями акторов в целом. Но хотелось бы акцентировать ваше внимание вот на чем: Rust создает иллюзию того, что написать асинхронную программу легко, тогда как это совсем не так.

Знаете, в чем состоит одно из главных преимуществ Rust? Попросите начинающего программиста написать какой-нибудь код на Rust. Самое худшее, что произойдет  —  код запаникует или не будет работать. В то время как начинающий разработчик кода на C/C++ рискует тем, что контролем над его компьютером завладеет злоумышленник. Да, вполне возможно, что действия начинающего программиста случайно приведут к появлению взаимоблокировки при использовании мьютексов. Но такие взаимоблокировки обычно являются локальными, и для их недопущения избавляются от мьютексов или выполняют небольшую реорганизацию кода. Так что на практике это не такая существенная проблема.

Настоящая проблема  —  это написание правильного управления потоком передачи данных, задача совершенно непосильная для начинающих программистов. Так, много сил и энергии в последние пару недель пришлось потратить специалистам в кодовой базе Polkadot на отладку взаимоблокировок, к которым привело неправильное управление потоком передачи данных. Эти взаимоблокировки появились либо в результате действий программистов, имеющих лишь поверхностное представление о такого рода вопросах, либо случайно после рефакторинга кода (и это только усугубляет проблему). Знаменитое обещание бесстрашного рефакторинга здесь на самом деле не выполняется.

В Substrate мы реализовали практичное решение в виде «контролируемого неограниченного канала». Количество элементов, входящих и исходящих из каждого канала, сообщается клиенту Prometheus. Когда разница между этими двумя показателями превышает определенный порог, инициируется оповещение. Ограниченные каналы тоже используются, но только в тех местах, где управление потоком передачи данных действительно важно (например, в сетях). Так что этот подход оказался практичным и, похоже, работает.

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

Просто создайте новую задачу

В начале статьи уже говорилось о том, что самым простым решением проблем, связанных с отменой future, часто бывает создание дополнительной фоновой задачи. То есть, когда опрашивается больше 2–3 futures параллельно, все проблемы обычно решаются созданием дополнительных фоновых задач.

Обмен данными между несколькими такими фоновыми задачами осуществляется либо по каналам, либо с помощью Arc  —  как с Mutex (мьютексом), так и без него. Обычно это приводит к так называемой Arc-ификации кода. Вместо того, чтобы помещать объекты в стек, все оборачивают в Arc и передают.

Ничего плохого нет в том, чтобы помещать все в Arc. Проблема в том, что… возникает ощущение, будто и не на Rust уже пишешь.

В том же языке, задействующим передачу данных по ссылкам, например &str вместо String, предлагается также разбивать код на небольшие задачи, которые должны клонировать каждый фрагмент отправляемых ими друг другу данных. В том же языке, который использует сложную систему времен жизни, позволяющую отказаться от сборщика мусора, предлагается теперь вдобавок помещать все в Arc.

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

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

1) низкоуровневый язык исключительно для операций центрального процессора, использующий ссылки и точно отслеживающий владение всем;

2) язык высокого уровня для операций ввода-вывода, который решает все проблемы, клонируя данные или помещая их в Arcs.

Интроспекция программы

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

Хотите найти причину утечки памяти? Для этого есть инструменты, которые показывают, сколько памяти выделил и освободил код в каждом потоке. Хотите знать, какую нагрузку на ЦП создает тот или иной фрагмент кода? Для этого есть инструменты, которые показывают, сколько ресурсов центрального процессора использует каждый поток с течением времени. Но все эти инструменты бесполезны, когда код все время перескакивает с одного потока на другой.

Здесь мы поступили так же, как с упоминавшимся выше «контролируемым неограниченным каналом»: решили обернуть каждую длительно выполняемую задачу в обертку, которая сообщает клиенту Prometheus, сколько раз и как долго опрашивалась задача.

Пример графика, показывающего, сколько процессорного времени использует каждая задача

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

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

tokio vs async-std vs no-std

Я оставил эту тему напоследок.

Много дискуссий было о разделении экосистемы между библиотеками tokio и async-std. Каждая из них определяет свои собственные типы и типажи, и вам как разработчику приложения или даже библиотеки надо выбрать одну из этих библиотек. В Libp2p, например, имеется TcpConfig и TokioTcpConfig.

Технически tokio и async-std отличаются друг от друга сочетанием заложенных в них компромиссов. Tokio использует одни и те же потоки для опрашивания ядра на предмет наличия событий и для выполнения асинхронных задач, но происходит это за счет действующей локально в потоках темной магии. С другой стороны, Async-std для опрашивания ядра порождает отдельные потоки, что делает его более медленным.

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

Это обернулось самой настоящей проблемой во время перехода экосистемы от futures 0.1 к futures 0.3. Возникла большая путаница: одни библиотеки уже перешли, другие еще нет. Когда код состоит из нескольких сотен тысяч строк, то гарантировать соблюдение требования о том, что сокеты и таймеры tokio должны опрашиваться в исполнителе одной и той же версии tokio, почти невозможно. И в этот период мы выполняли отдельные циклы где-то от одного до трех событий. А потом переключились с tokio на async-std,потому что его дизайн позволял избежать всей этой проблемы. Но сегодня мы все еще запускаем цикл событий tokio 0.1 параллельно из-за устаревшей библиотеки.

Во избежание разделения экосистемы было предложено, чтобы реализации типажей AsyncRead/AsyncWrite рассматривались отвлеченно библиотеками с поддержкой асинхронности, давая этим пользователю возможность подключать tokio или async-std (смотря что ему удобнее). Проблема здесь в том, что в tokio и async-std даже не пришли к общему определению этих двух типажей.

А даже если бы эти две библиотеки использовали одни и те же определения типажей, все равно осталась бы фундаментальная проблема, которую на самом деле все, похоже, упускают из виду: AsyncRead и AsyncWrite несовместимы с no-std, так как используют в своих API std::io::Error. Любая библиотека, задействующая эти типажи, не может выполнять компиляцию для платформ no_std.

Использование std::io::Error в API почти всегда приводит к тому, что данных, касающихся API, указано недостаточно. То же относится и к этим двум типажам. После реализации типажей AsyncRead и AsyncWrite в подпотоках libp2p нам пришлось решить несколько очень практических вопросов. Например, когда poll_write возвращает ошибку, нужно ли все равно вызывать poll_close? Или когда poll_read возвращает ошибку, не запаникует ли он подобно futures при попытке вызвать его позже снова в том же объекте? Чтобы убедиться в том, что реализации AsyncRead и AsyncWrite «совместимые», нам пришлось прочитать исходный код различных методов объединения async-std и tokio и разобраться в том, как они вызывают методы этих типажей.

Считаю, что рассматривать AsyncRead и AsyncWrite отвлеченно  —  как они есть  —  конечно же, не самая хорошая идея из-за этих надоедливых и неясных std::io::Error.

Так как же решить эту проблему? Библиотека smoldot проекта Parity, который я возглавляю, использует еще более «грубый» API. Вместо того, чтобы задействовать типажи, мы напрямую передаем буферы данных, которые функция прочитывает и заполняет (прошу прощения за отсутствие документации). Кому-то это покажется шагом назад, но это самый гибкий подход, по моему мнению. Почему я так считаю? Потому что он не задействует никаких абстракций. Этот API сложнее использовать. Что ж, сложным проблемам  —  сложные решения.

Заключение

Статья получилась большой. Я не стал включать в нее мелкие вопросы, например о том, насколько долго нужен типаж InfiniteStream, или о том, как rustfmt не способен форматировать код в select!, или о том, как нигде в экосистеме нет асинхронного Mutex (мьютекса) с поддержкой no-std. Обо всем этом кто-нибудь еще напишет.

Я сделал акцент на проблемах, возникающих при масштабировании. Ведь при нынешнем положении дел многие уже работали с небольшими асинхронными проектами, но мало кто пробовал браться за крупномасштабные.

Несмотря на все негативные аспекты, должен признать, что мне в целом очень нравится применяемый в Rust подход, основанный на опрашивании. Большинство проблем возникает здесь не из-за ошибок, а потому, что на самом деле ни в одном другом языке этот принцип не продвинулся так далеко. Дизайн языка программирования  —  это в первую очередь сфера «творческая» и только потом уже техническая. Поэтому предвидеть последствия выбора того или иного решения, связанного с дизайном, практически невозможно.

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

Спасибо за внимание.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи tomaka: A look back at asynchronous Rust

Предыдущая статьяКак протестировать код на Go с Github Actions
Следующая статьяКомментарии: за или против?