Недавно в сообществе разработчиков, где я состою, кто-то представил интересную проблему. Программа этого человека была спроектирована так, что она использовала как многопоточность (использование нескольких параллельных потоков) для выполнения некоторых задач, и циклы for для повторения определенных операций в этих потоках. Однако когда были внесены изменения в код программы и циклы поменяли на итераторы, производительность программы резко ухудшилась. Почему это произошло?

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

Чтобы лучше понять эту проблему, давайте рассмотрим пример программы и разберемся в том, как итераторы могут повлиять на ее производительность и поведение в целом.

Вот программа, похожая на то, что изначально было представлено. 

Мы решили использовать задержки в выполнении. Вместо реальной вычислительной работы программа будет искусственно замедлять свое выполнение. Такое достигается путем вставки временных задержек в код, которые приостанавливают выполнение программы на некоторое время. Это помогает наглядно продемонстрировать, как итераторы могут повлиять на производительность программы, даже в случае, когда фактическая работа не выполняется.

use std::thread;
use std::time;

fn do_work(i: usize) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let duration = time::Duration::from_millis(100);
        thread::sleep(duration);
        println!("thread {i} done");
    })
}

fn main() {
    let mut handles = Vec::new();

    for i in 0..10 {
        let handle = do_work(i);
        handles.push(handle);
    }

    for handle in handles {
        handle.join();
    }
}

Когда я запускаю данный код на своем компьютере, он выполняется за 103 миллисекунды.

Теперь посмотрим, как это будет работать с использованием итераторов.

use std::thread;
use std::time;

fn do_work(i: usize) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let duration = time::Duration::from_millis(100);
        thread::sleep(duration);
        println!("thread {i} done");
    })
}


fn main() {
    (0..10)
        .map(do_work)
        .for_each(|handle| {
            handle.join();
        });
}

Теперь для этого требуется... 1008 миллисекунд! Получается в 10 раз дольше. В некоторых отношениях код, использующий итераторы, проще для понимания, потому что нет необходимости явно создавать и отслеживать переменные, которые содержат обработчики соединения (join handles) для потоков. Вместо этого, программа автоматически управляет процессом соединения потоков без необходимости вручную создавать и отслеживать эти обработчики. Это может сделать код более читаемым и менее подверженным ошибкам, но, как оказалось в данном случае, может также существенно снизить производительность. Почему?

Подсказка заключается в том, что время выполнения замедляется именно в 10 раз. Это подозрительно соответствует количеству элементов, по которым мы выполняем итерацию. И на это есть веские причины: мы потеряли параллелизм.

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

Функция enumerate — это стандартная функция во многих языках программирования, включая Rust. Она позволяет пройти по элементам итератора и одновременно получать индексы (номера) этих элементов. То есть, если у вас есть итератор, представляющий последовательность данных, enumerate создает новый итератор, который возвращает пары (индекс, элемент).

Суть в том, что enumerate может быть использован с бесконечным итератором, таким как std::iter::repeat(), который создает бесконечную последовательность элементов. Если вы комбинируете бесконечный итератор с enumerate, то получаете возможность пронумеровать бесконечную последовательность. 

В представленном коде мы последовательно связываем несколько итераторов. 

Изначально у нас есть (0..10), что создает объект типа Range. Range - это итератор, который представляет собой последовательность чисел в определенном диапазоне. В данном случае, он начинается с 0 и включает числа до 10, но не включая само число 10. То есть, представляет собой последовательность чисел от 0 до 9.

Затем мы вызываем метод .map на этом Range, чтобы преобразовать каждый элемент этой последовательности с помощью функции do_work

Когда мы вызываем .map на Range, это создает новый итератор, который имеет свойство преобразовывать (трансформировать) каждый элемент при итерации, используя заданную функцию do_work. Важно отметить, что на данном этапе итератор типа Range сам по себе не начинает вычислять элементы или создавать числа сразу. Вместо этого, новый итератор (полученный после вызова .map) можно представить как своеобразный "план трансформации". Этот "план" говорит итератору, как нужно обрабатывать каждый элемент при итерации. То есть, вместо немедленной генерации чисел, он имеет инструкции по тому, как каждый элемент должен быть изменен функцией do_work.

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

Таким образом, "преобразование в 'процесс трансформации'" означает, что на этом этапе итератор получает инструкции по тому, как трансформировать элементы, но сам элементы не генерируются или не изменяются до момента фактической итерации. Это позволяет лениво и эффективно применять трансформации к элементам итератора при необходимости, а не заранее.

Наконец, мы вызываем метод for_each на этом 'процессе трансформации'. Этот метод итерируется по элементам этого 'процесса' и применяет функцию (замыкание) к каждому элементу по очереди, но не собирает элементы заранее в какую-либо коллекцию. Он обрабатывает элементы индивидуально и последовательно.

Таким образом, представленный код создает последовательность чисел, преобразует их по мере необходимости и выполняет операции пошагово, без создания и вычисления всех чисел сразу.

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

Здесь происходит следующее:

for i in 0..10 {
    let handle = do_work(i);
    handle.join();
}

Итак, потому что мы создаем обработчик (handle) и сразу же выполняем его соединение (join), то никакого параллелизма мы не достигаем, и процесс выполняется намного медленнее!

В такого рода программах циклы for являются довольно идиоматичными. Но вы можете написать ее и с помощью итераторов, если это вам больше по душе, просто придется сделать это немного по-другому. Если опустить повторное определение do_work. Вот пример:

fn main() {
    let handles: Vec<_> = (0..10).map(do_work).collect();
    handles.into_iter().for_each(move |handle| {
        handle.join();
    });
}

Этот вариант, конечно, более многословен. Но, что немаловажно, он позволяет использовать здесь итераторы и при этом добиться параллелизма. Ключевым моментом является то, что создание обработчиков для соединения (join handles) и их соединение разделены на два отдельных шага, каждый из которых потребляет базовые итераторы. (Заметьте, что последний for_each был бы гораздо более лаконичным, если бы использовался обычный цикл for, но мне захотелось продемонстрировать именно это. Пожалуй, вам это не нужно).

Вот и все! Если ваш код работает намного медленнее, когда вы используете итераторы, возможно, причина в этом.

Напоследок приглашаем всех начинающих Rust-разработчиков на открытый урок в OTUS, который пройдет 14 ноября. На нем мы поговорим о важной фиче Borrow checker, а также полайвкодим. В итоге выясним: как не запутаться в ссылках, почему одни типы живут дольше других, и как жить счастливой жизнью без Garbage Collector'а. Записаться можно по ссылке.

Комментарии (8)


  1. mayorovp
    07.11.2023 18:10
    +16

    Что-то вы совсем очевидные вещи рассказываете.

    Мне даже в голову не могло прийти начать джойнить потоки в том же проходе, в котором они создавались...


    1. DistortNeo
      07.11.2023 18:10
      +5

      А ещё это не является rust-специфичным поведением.
      Так итераторы работают вообще в любом языке программирования.


  1. PsyHaSTe
    07.11.2023 18:10
    +6

    Первое апреля в этом году настало раньше положенного


  1. KivApple
    07.11.2023 18:10

    Вроде так себя ведут итераторы в любом языке, где они настоящие, а не просто у массивов есть два метода map/filter возвращающих новый массив. Без ленивости будет аллокация памяти на полный размер массива на каждый шаг обработки.

    Такая же ситуация как в Rust будет в Java, например.

    Вот ленивый async есть не везде, а ленивые итераторы везде.


  1. Amareis
    07.11.2023 18:10
    +6

    Думаю, минусов накидали в первую очередь за заголовок. В оригинале статья называется "Помогите, итераторы делают мой код медленнее!", и с такой постановкой становится понятней что это разбор тривиальной ошибки.

    Да и сам перевод далеко не дословный, а с кучей дополнительных пояснений и воды.


    1. KivApple
      07.11.2023 18:10
      +11

      Я из-за слишком серьёзного заголовка ожидал рассмотрение каких-то краевых случаев, где zero-cost abstractions оказываются не zero-cost. А внутри увидел совсем другое (я даже не могу сказать, что производительность именно ухудшилась, ибо время исполнения выросло в 10 раз, но и нагрузка на CPU уменьшилась в 10 раз).

      Сама по себе статья полезная, но с "Помогите, итераторы делают мой код медленнее", у меня бы не было неоправдавшихся ожиданий от содержимого, я бы читал её именно как статью для новичков, а не понял это только в середине.


      1. errorsmissing
        07.11.2023 18:10

        где zero-cost abstractions оказываются не zero-cost. 

        Т.е. везде? Вызов функции не гарантирует сохранения и обработку всех инвариантов.

        Хотите zero-cost - юзайте макросы (кстате, в стандартной библиотеке так и поступают)


  1. djmaxus
    07.11.2023 18:10
    +3

    Если спавнить потоки std::thread::scope, когда не требуется нетривиальной обработки JoinHandle, то .join() будет происходить "автоматически", без явного вызова, который в вашем корректном примере с итераторами делал код многословнее