Собираем всё вместе: футуры, задачи и потоки
Как мы видели в главе 16, потоки — один из подходов к конкурентности. В этой главе мы познакомились с другим: async с футурами и стримами. Если вам интересно, когда выбирать один, а когда другой, ответ такой: по обстоятельствам! И во многих случаях выбор стоит не «потоки или async», а «потоки и async».
Многие операционные системы уже десятилетиями предоставляют модели конкурентности на основе потоков, и поэтому их поддерживают многие языки. Но и у этих моделей есть свои издержки. Во многих ОС каждый поток расходует довольно много памяти. А кроме того, потоки доступны, только если их поддерживают операционная система и железо. В отличие от обычных настольных и мобильных компьютеров, у некоторых встраиваемых систем вообще нет ОС, а значит, нет и потоков.
Асинхронная модель предлагает другой — а в итоге дополняющий — набор
компромиссов. В ней конкурентным операциям не нужны собственные потоки. Вместо
этого они могут выполняться в задачах (tasks) — как когда мы в разделе о
стримах запускали работу из синхронной функции через trpl::spawn_task.
Задача похожа на поток, но управляет ею не операционная система, а код на
уровне библиотеки — среда выполнения.
API для порождения потоков и задач так похожи не случайно. Потоки служат границей для наборов синхронных операций: конкурентность возможна между потоками. Задачи служат границей для наборов асинхронных операций: конкурентность возможна и между задачами, и внутри них, потому что задача может переключаться между футурами в своём теле. Наконец, футуры — самая мелкая единица конкурентности в Rust, и каждая футура может представлять целое дерево других футур. Среда выполнения — точнее, её исполнитель — управляет задачами, а задачи управляют футурами. В этом смысле задачи похожи на лёгкие потоки, которыми управляет среда выполнения. И у них есть дополнительные возможности — как раз потому, что управляет ими среда выполнения, а не операционная система.
Это не значит, что асинхронные задачи всегда лучше потоков (или наоборот). В
чём-то конкурентность с потоками — более простая модель программирования, чем
конкурентность с async. Это может быть и плюсом, и минусом. Потоки работают
по принципу «запустил и забыл»: у них нет встроенного аналога футуры, поэтому
они просто выполняются до конца, и прервать их может только сама операционная
система.
А ещё оказывается, что потоки и задачи часто отлично работают вместе: задачи (по
крайней мере в некоторых средах выполнения) можно перемещать между потоками.
Более того, под капотом среда выполнения, которой мы пользовались, — включая
функции spawn_blocking и spawn_task, — по умолчанию многопоточная! Многие
среды выполнения используют подход под названием перехват работы (work
stealing): они незаметно перемещают задачи между потоками в зависимости от
того, насколько загружены потоки, чтобы повысить общую производительность
системы. Для такого подхода нужны и потоки, и задачи, а значит, и футуры.
Выбирая, что использовать, ориентируйтесь на такие правила:
- Если работа хорошо распараллеливается (то есть ограничена процессором) — например, нужно обработать кучу данных, каждую часть которых можно обрабатывать отдельно, — лучше выбрать потоки.
- Если работа сильно конкурентная (то есть ограничена вводом-выводом) — например, нужно обрабатывать сообщения из множества разных источников, которые приходят с разными интервалами и с разной скоростью, — лучше выбрать async.
А если нужны и параллелизм, и конкурентность, выбирать между потоками и async не обязательно. Их можно свободно сочетать, давая каждому делать то, что у него получается лучше всего. Например, в листинге 17-25 — довольно типичный для реального кода на Rust пример такого сочетания.
extern crate trpl; // для 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}");
}
});
}
Сначала создаём асинхронный канал, затем порождаем поток, который с помощью
ключевого слова move забирает владение отправляющей стороной канала. В
потоке отправляем числа от 1 до 10 с паузой в секунду между ними. И наконец, как
и на протяжении всей главы, выполняем футуру, созданную асинхронным блоком,
переданным в trpl::block_on. В этой футуре ждём сообщения — как и в других
примерах с передачей сообщений, которые мы видели.
Вернёмся к сценарию, с которого начиналась глава: представьте, что набор задач кодирования видео выполняется в отдельном потоке (ведь кодирование видео ограничено процессором), а интерфейс узнаёт о завершении этих операций через асинхронный канал. В реальных задачах таких сочетаний бесчисленное множество.
Итоги
Это не последняя встреча с конкурентностью в книге. Проект из главы 21 применит эти понятия в ситуации, более приближенной к реальности, чем простые примеры здесь, и нагляднее сравнит решение задач с помощью потоков и с помощью задач и футур.
Какой бы подход вы ни выбрали, Rust даёт инструменты, чтобы писать безопасный, быстрый конкурентный код — будь то высоконагруженный веб-сервер или встраиваемая операционная система.
Дальше мы поговорим об идиоматичных способах моделировать задачи и структурировать решения по мере роста программ на Rust. А заодно обсудим, как идиомы Rust соотносятся с тем, что может быть вам знакомо по объектно-ориентированному программированию.