Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Собираем все вместе: futures, задачи и потоки

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

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

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

Есть причина, по которой API для порождения потоков и порождения задач так похожи. Потоки действуют как граница для наборов синхронных операций; конкурентность возможна между потоками. Задачи действуют как граница для наборов асинхронных операций; конкурентность возможна и между задачами, и внутри задач, потому что задача может переключаться между futures в своем теле. Наконец, futures являются самой мелкой единицей конкурентности в Rust, и каждый future может представлять дерево других futures. Среда выполнения, а точнее ее исполнитель, управляет задачами, а задачи управляют futures. В этом отношении задачи похожи на легковесные потоки, управляемые средой выполнения, с дополнительными возможностями, которые появляются благодаря управлению со стороны среды выполнения, а не операционной системы.

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

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

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

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

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

Имя файла: src/main.rs
extern crate trpl; // for mdbook test

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}");
        }
    });
}
Listing 17-25: Отправка сообщений блокирующим кодом в потоке и ожидание сообщений в async-блоке

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

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

Итоги

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

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

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