Для чего нужна асинхронность?

Нам всем нравится, как Rust позволяет нам писать быстрое и безопасное программное обеспечение. Но как асинхронное программирование вписывается в это видение?

Асинхронное программирование, или сокращённо async, — это параллельная модель программирования, поддерживаемая растущим числом языков программирования. Он позволяет выполнять большое количество одновременных задач в небольшом количестве потоков ОС, сохраняя при этом большую часть внешнего вида обычного синхронного программирования с помощью синтаксиса async/await.

Асинхронность и другие модели параллелизма

Параллельное программирование менее развито и «стандартизировано», чем обычное последовательное программирование. В результате мы по-разному выражаем параллелизм в зависимости от того, какую модель параллельного программирования поддерживает язык. Краткий обзор самых популярных моделей параллелизма поможет вам понять, как асинхронное программирование вписывается в более широкую область параллельного программирования:

  • Потоки ОС не требуют каких-либо изменений в модели программирования, что упрощает реализацию параллелизма. Однако синхронизация между потоками может быть затруднена, а издержки производительности велики. Пулы потоков могут снизить некоторые из этих затрат, но не настолько, чтобы поддерживать огромные рабочие нагрузки, связанные с вводом-выводом.
  • Программирование, управляемое событиями (event-driven programming), в сочетании с обратными вызовами (callbacks) может быть очень эффективным, но приводит к многословному, «нелинейному» потоку управления. Поток данных и распространение ошибок часто трудно отслеживать.
  • Корутины (Coroutines), как и потоки, не требуют изменений в модели программирования, что делает их простыми в использовании. Как и асинхронность, они также могут поддерживать большое количество задач. Однако они абстрагируются от низкоуровневых деталей, важных для системного программирования и разработчиков пользовательских сред выполнения.
  • Модель акторов делит все параллельные вычисления на единицы, называемые акторами, которые взаимодействуют посредством передачи ошибочных сообщений, как в распределённых системах. Модель акторов может быть эффективно реализована, но она оставляет без ответа многие практические вопросы, такие как управление потоком и логика повторных попыток.

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

Асинхронность в Rust против других языков

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

  • Футуры инертны в Rust и работают только при опросе. Сбрасывание футуры останавливает её дальнейший прогресс.
  • Асинхронность в Rust бесплатна (zero-cost), а это значит, что вы платите только за то, что используете. В частности, вы можете использовать асинхронность без распределения кучи и динамической диспетчеризации, что отлично подходит для производительности! Это также позволяет использовать асинхронность в средах с ограничениями, таких как встроенные системы.
  • В Rust нет встроенной среды выполнения асинхронности. Вместо этого такие среды предоставляются трейтами, поддерживаемыми сообществом.
  • В Rust доступны как однопоточные, так и многопоточные среды выполнения, которые имеют разные сильные и слабые стороны.

Асинхронность против потоков в Rust

Основной альтернативой асинхронности в Rust является использование потоков ОС либо напрямую через std::thread, либо косвенно через пул потоков. Переход от потоков к асинхронному или наоборот обычно требует серьёзной работы по рефакторингу как с точки зрения реализации, так и (если вы создаёте библиотеку) любых открытых общедоступных интерфейсов. Таким образом, ранний выбор модели, которая соответствует вашим потребностям, может сэкономить много времени на разработку.

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

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

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

Пример: одновременная загрузка

В этом примере наша цель — загрузить две веб-страницы одновременно. В типичном многопоточном приложении нам нужно создавать потоки для достижения параллелизма:

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);
}

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

Пользовательские модели параллелизма в Rust

И наконец, Rust не заставляет вас выбирать между потоками и асинхронностью. Вы можете использовать обе модели в одном и том же приложении, что может быть полезно, когда у вас есть смешанные многопоточные и асинхронные зависимости. На самом деле вы даже можете использовать другую модель параллелизма, например программирование, управляемое событиями (event-driven programming), если найдёте библиотеку, которая её реализует.