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

Собираем всё вместе: футуры, задачи и потоки

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

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

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

API для порождения потоков и задач так похожи не случайно. Потоки служат границей для наборов синхронных операций: конкурентность возможна между потоками. Задачи служат границей для наборов асинхронных операций: конкурентность возможна и между задачами, и внутри них, потому что задача может переключаться между футурами в своём теле. Наконец, футуры — самая мелкая единица конкурентности в Rust, и каждая футура может представлять целое дерево других футур. Среда выполнения — точнее, её исполнитель — управляет задачами, а задачи управляют футурами. В этом смысле задачи похожи на лёгкие потоки, которыми управляет среда выполнения. И у них есть дополнительные возможности — как раз потому, что управляет ими среда выполнения, а не операционная система.

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

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

Выбирая, что использовать, ориентируйтесь на такие правила:

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

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

Файл: src/main.rs
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}");
        }
    });
}
Листинг 17-25: Отправляем сообщения блокирующим кодом в потоке и ждём их в асинхронном блоке

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

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

Итоги

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

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

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