Базовое асинхронное использование

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

Во-первых, давайте создадим новый проект, используя Cargo с cargo new rustasync --bin

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

[package]
name = "rust-async"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
futures = "0.3"

В ящике futures-rs реализованы некоторые основные абстракции Rust для асинхронного программирования. Мы рассмотрим их более подробно позже, но сейчас черта Future имеет для нас наибольшее значение.

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

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

// The block_on function simply blocks the current thread until the 
// Future has completed and returned.
use futures::executor::block_on;
fn main() {
    // The make_coffee, make_eggs, and make_bacon functions 
    // have all been annoted with the async keyword. 
    // This means that they will all return a Future.
    // In fact, the following 2 functions are the same
    // async fn foo() { // do stuff }
    // fn foo() -> impl Future<Outpout = ()> {
    //     async { // do stuff }
    // }
    let coffee_future = make_coffee();
    // The block_on executor accepts a Future as input and blocks
    // the current thread until that future has completed
    block_on(coffee_future);
    let bacon_future = make_bacon();
    block_on(bacon_future);
    let eggs_future = make_eggs();
    block_on(eggs_future);
}
async fn make_coffee() {
    println!("Coffee started.");
}
async fn make_eggs() {
    println!("Eggs started.");
}
async fn make_bacon() {
    println!("Bacon started.");
}

Во-первых, мы импортируем очень простой исполнитель из ящика фьючерсов. В Rust фьючерсы ленивы. Мы рассмотрим это более подробно позже, но создание будущего ничего не делает само по себе. Это должно быть доведено до завершения исполнителем. Это работа исполнителя on_block(). Он принимает Future в качестве параметра и выполняет этот код, блокируя основной поток.

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

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

Во-первых, мы добавим новую асинхронную функцию с именем make_coffee_and_bacon(), показанную ниже:

async fn make_coffee_and_bacon() {
    make_coffee().await;
    make_bacon().await;
}

Далее мы добавим еще одну новую асинхронную функцию с именем async_main():

async fn async_main() {
    let long_running_stuff = make_coffee_and_bacon();
    let other_stuff = make_eggs();
    futures::join!(long_running_stuff, other_stuff);
}

Наконец, мы изменим существующую функцию main(), чтобы она выглядела так:

fn main() {
    block_on(async_main());
}

Давайте пройдемся по этим изменениям по одному. Во-первых, мы создаем новую асинхронную функцию для задач с кофе и беконом. Вы заметите, что мы используем await внутри функций. Использование await внутри функции async похоже на использование on_block() для ожидания разрешения Future, но вместо блокировки потока оно позволяет выполнять другие задачи, если будущее заблокировано, например, ожидание ввода из сокета или потока.

Во-вторых, мы добавили функцию async_main(). Это довольно простое изменение. Поскольку await может использоваться только внутри асинхронных функций или блоков асинхронного кода, а main() функция не может быть аннотирована ключевым словом async. В то время как поток, выполняющий main(), будет блокироваться до разрешения вызова block_on(), все, начиная с async_main(), и далее по цепочке выполнения будет выполняться асинхронно. Третья строка новая. Макрос futures::join!() похож на ключевое слово await, но он работает с несколькими фьючерсами, возвращаясь только тогда, когда все Future завершены, независимо от порядка завершения.

Ранее я упоминал, что Futures «ленивы» в том смысле, что они ничего не делают сами по себе. Они никогда не вернут значение, если не будут доведены до конца. Это может показаться странным, особенно если вы знакомы с тем, как другие языки реализуют асинхронный код, но это осознанный выбор разработчиков языка. Позволяя Future быть ленивыми, создание асинхронной функции становится операцией с нулевой стоимостью. Под «нулевой стоимостью» я подразумеваю, что создание Future не влияет на время выполнения, если только вы их не используете. Если вы хотите увидеть наглядный пример этой лени в действии, просто удалите .await из вызова make_coffee() и make_bacon() в функции make_coffee_and_bacon() и запустите код. Вы заметите, что когда вы запустите его после удаления await, он никогда не выведет никакого вывода.

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

Это может показаться нелогичным, в конце концов, предполагается, что это асинхронное выполнение. Давайте посмотрим, почему. Если вы помните из первой статьи серии, мы упоминали, что ключевое слово await используется сгенерированным конечным автоматом в качестве маркера, обозначающего атомарные единицы выполнения. Например, в асинхронной функции make_coffee_and_bacon() мы сначала вызываем make_coffee().await, а затем make_bacon().await. В сгенерированном конечном автомате все, от начала функции до первого ключевого слова await, считается атомарной единицей. Точно так же все после вызова make_coffee().await до следующего ключевого слова await будет еще одной атомарной единицей. Это означает, что функция make_coffee() завершится до запуска make_bacon(). Ключевое слово await само по себе на самом деле не приводит Future к завершению. Именно вызов block_on() приводит к завершению асинхронного кода. Ранее я использовал термин «исполнитель», но не давал ему точного определения. Исполнитель отвечает за доведение до завершения всего асинхронного кода, за который он отвечает. В нашем случае мы передаем async_main() исполнителю block_on(), и этот исполнитель отвечает за доведение до завершения всего асинхронного кода, содержащегося в async_main(), но block_on() не реализует никакой логики, которая позволила бы мультиплексировать выполнение. Чтобы позволить коду, использующему async/await, выполняться асинхронно, нам нужно использовать более сложный исполнитель. Я думаю об этом так; будущее — это вычисление, которое мы хотим выполнить, задача планирует выполнение будущего, а исполнитель гарантирует, что все задачи выполнены, и координирует взаимозависимости.

Между прочим, причина существования макроса join!() состоит в том, чтобы обеспечить более гибкую композицию задач. Например, если бы мы заменили block_on() более сложным исполнителем, нам не нужно было бы вносить какие-либо другие изменения в наш асинхронный код.

Это была 2-я часть из 3-х частей. Я попытался представить эту информацию в простой форме, но при этом дал достаточно подробностей, чтобы обеспечить общее понимание. Это оказалось немного сложнее, чем я предполагал. В следующем разделе мы рассмотрим более реалистичную реализацию асинхронности Rust и поговорим о некоторых доступных средах выполнения, а также о некоторых подводных камнях. Если вы дочитали до этого момента, дайте мне аплодисменты или подпишитесь, чтобы я знал, нашли ли вы это полезным, и подпишитесь на меня, чтобы узнать, когда будет опубликована следующая статья из этой серии!

3-ю часть этой серии можно найти здесь.