Собираем все вместе: 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-коде.
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}");
}
});
}
Мы начинаем с создания async-канала, а затем порождаем поток, который получает
владение отправляющей стороной канала с помощью ключевого слова move. Внутри
потока мы отправляем числа от 1 до 10, засыпая на секунду между каждым.
Наконец, мы запускаем future, созданный async-блоком, переданным в
trpl::block_on, как делали на протяжении всей главы. В этом future мы
ожидаем эти сообщения, так же как в других примерах передачи сообщений,
которые уже видели.
Возвращаясь к сценарию, с которого мы начали главу, представьте выполнение набора задач кодирования видео с помощью выделенного потока (потому что кодирование видео ограничено вычислениями), но уведомление UI о завершении этих операций через async-канал. В реальных сценариях использования есть бесчисленное множество примеров таких сочетаний.
Итоги
Это не последний раз, когда вы увидите конкурентность в этой книге. Проект в главе 21 применит эти концепции в более реалистичной ситуации, чем простые примеры, обсуждавшиеся здесь, и более напрямую сравнит решение задач с помощью потоков с решением с помощью задач и futures.
Независимо от того, какой из этих подходов вы выберете, Rust дает вам инструменты, необходимые для написания безопасного, быстрого, конкурентного кода — будь то высокопроизводительный веб-сервер или встроенная операционная система.
Далее мы поговорим об идиоматичных способах моделировать задачи и структурировать решения по мере роста ваших программ на Rust. Кроме того, мы обсудим, как идиомы Rust соотносятся с теми, которые могут быть знакомы вам из объектно-ориентированного программирования.