Собираем всё вместе: Фьючерсы, Задачи и Потоки

Как мы видели в Главе 16, потоки (threads) предоставляют один из подходов к конкурентности. В этой главе мы увидели другой подход: использование async с фьючерсами (futures) и потоками (streams). Если вы задаетесь вопросом, когда выбрать один метод вместо другого, ответ таков: это зависит от ситуации! И во многих случаях выбор стоит не между потоками или async, а между потоками и async.

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

Асинхронная модель предоставляет другой — и в конечном счете дополняющий — набор компромиссов. В асинхронной модели конкурентные операции не требуют собственных потоков. Вместо этого они могут выполняться в задачах (tasks), как когда мы использовали trpl::spawn_task для запуска работы из синхронной функции в разделе о потоках (streams). Задача похожа на поток, но вместо управления операционной системой ею управляет код на уровне библиотеки: рантайм (runtime).

Есть причина, по которой API для порождения потоков и порождения задач так похожи. Потоки действуют как граница для наборов синхронных операций; конкурентность возможна между потоками. Задачи действуют как граница для наборов асинхронных операций; конкурентность возможна как между задачами, так и внутри них, потому что задача может переключаться между фьючерсами в своем теле. Наконец, фьючерсы — это самая гранулярная единица конкурентности в Rust, и каждый фьючерс может представлять собой дерево других фьючерсов. Рантайм — конкретно его исполнитель (executor) — управляет задачами, а задачи управляют фьючерсами. В этом отношении задачи похожи на облегченные, управляемые рантаймом потоки с дополнительными возможностями, которые возникают из-за управления рантаймом, а не операционной системой.

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

И оказывается, что потоки и задачи часто очень хорошо работают вместе, потому что задачи могут (по крайней мере, в некоторых рантаймах) перемещаться между потоками. На самом деле, под капотом рантайм, который мы использовали — включая функции spawn_blocking и spawn_task — по умолчанию является многопоточным! Многие рантаймы используют подход, называемый кража работы (work stealing), чтобы прозрачно перемещать задачи между потоками на основе текущей загрузки потоков, чтобы повысить общую производительность системы. Этот подход фактически требует и потоков, и задач, а следовательно, и фьючерсов.

Когда думаете о том, какой метод использовать, руководствуйтесь этими эмпирическими правилами:

  • Если работа очень хорошо распараллеливается (то есть, ограничена по CPU (CPU-bound)), например, обработка набора данных, где каждая часть может обрабатываться отдельно, потоки — лучший выбор.
  • Если работа очень конкурентна (то есть, ограничена по вводу-выводу (I/O-bound)), например, обработка сообщений из множества разных источников, которые могут поступать с разными интервалами или скоростями, async — лучший выбор.

И если вам нужен и параллелизм, и конкурентность, вам не обязательно выбирать между потоками и async. Вы можете свободно использовать их вместе, позволяя каждому играть свою наилучшую роль. Например, Листинг 17-25 показывает довольно распространенный пример такого смешения в реальном коде на Rust.

Файл: src/main.rs

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::block_on(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}

Листинг 17-25: Отправка сообщений блокирующим кодом в потоке и ожидание сообщений в асинхронном блоке

Мы начинаем с создания асинхронного канала, затем порождаем поток, который забирает владение отправляющей стороной канала с помощью ключевого слова move. Внутри потока мы отправляем числа от 1 до 10, делая паузу в секунду между каждым. Наконец, мы запускаем фьючерс, созданный с помощью асинхронного блока, переданного в trpl::block_on, как мы делали на протяжении всей главы. В этом фьючерсе мы ожидаем эти сообщения, как и в других примерах с передачей сообщений, которые мы видели.

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

Резюме

Это не последний раз, когда вы увидите конкурентность в этой книге. Проект в Главе 21 применит эти концепции в более реалистичной ситуации, чем простые примеры, обсуждаемые здесь, и более прямо сравнит решение проблем с помощью потоков и задач/фьючерсов.

Независимо от того, какой из этих подходов вы выберете, Rust предоставляет вам инструменты, необходимые для написания безопасного, быстрого, конкурентного кода — будь то для высокопроизводительного веб-сервера или встраиваемой операционной системы.

Далее мы поговорим об идиоматических способах моделирования проблем и структурирования решений по мере роста ваших программ на Rust. Кроме того, мы обсудим, как идиомы Rust соотносятся с теми, с которыми вы могли быть знакомы по объектно-ориентированному программированию.