Всегда ли параллелизм — лучший выбор? Давай выясним

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

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

Как подойти к проблеме?

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

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

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

Параллелизм и параллелизм

Я не буду подробно объяснять, что такое параллелизм и параллелизм, так как в Интернете есть много хорошо объясненных статей. Все же оставлю пару определений от одного из создателей Go:

«Параллелизм — это работа с большим количеством вещей одновременно. Параллелизм — это делать много вещей одновременно». — Роб Пайк

«Параллелизм — это структура, а параллелизм — выполнение. Другими словами, параллелизм — это способ структурировать что-то так, чтобы вы могли (возможно) использовать параллелизм для лучшей работы». — Роб Пайк

Нагрузки

Один полезный способ узнать, подходит ли параллелизм, — это понять рабочую нагрузку вашей программы. В основном при анализе параллелизма следует учитывать два типа рабочих нагрузок:

Рабочие нагрузки, связанные с ЦП

Они описывают ситуацию, когда выполнение программы сильно зависит от процессора; это напрямую связано со скоростью центрального процессора.

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

Рабочие нагрузки, связанные с вводом-выводом

Они описывают ситуацию, когда выполнение программы сильно зависит от системы ввода-вывода, но не от ресурсов процессора.

Как только блок задач проходит ввод-вывод, планировщик Go возобновляет любую другую задачу в этих рабочих нагрузках. Таким образом, мы можем использовать несколько горутин в одном ядре для повышения производительности. Использование параллелизма не приведет к повышению производительности.

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

Давайте код!

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

Вот используемые функции Golang:

Вот используемые функции бенчмаркинга:

Результаты сравнительного анализа могут различаться в зависимости от машины, на которой они запущены. Вы должны тестировать более одного раза, пытаясь заставить вашу машину работать как можно меньше.

Используйте флаг -count при запуске теста, чтобы запустить его определенное количество раз; Я пропустил это ради примера.

Я запускаю эти тесты на своем ноутбуке с десятью ядрами, поэтому параллельные версии функций, использующих Golang runtime.NumCPU(), будут использовать в общей сложности десять горутин.

Тесты: рабочие нагрузки ЦП

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

go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeHighNumberSequentially      1869           3147006 ns/op
BenchmarkComputeHighNumberConcurrently       632           9490364 ns/op

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

Вот пример эталонного теста рабочей нагрузки с использованием параллелизма и запуском десяти потоков:

go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeHighNumberSequentially-10           1794           3145169 ns/op
BenchmarkComputeHighNumberConcurrently-10           3162           1699410 ns/op

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

Несмотря на эти результаты, не все рабочие нагрузки ЦП хорошо подходят для параллелизма. Далее мы собираемся протестировать алгоритм сортировки слиянием, чтобы продемонстрировать его:

go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkMergeSortSequentially   3908991              1530 ns/op
BenchmarkMergeSortConcurrently    452580             12307 ns/op
go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkMergeSortSequentially-10        4068475              1496 ns/op
BenchmarkMergeSortConcurrently-10         533010             12462 ns/op

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

На каждой итерации мы создаем пару горутин для вычисления первой и второй частей списка. Поскольку это рекурсивный алгоритм, мы окажемся в ситуации, когда мы будем создавать горутины только для вычисления одного элемента. Это очень неэффективно. Мы находимся в ситуации, когда создать и запланировать горутину дороже, чем объединить один элемент в той же горутине.

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

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

Тесты: рабочие нагрузки ввода-вывода

Вот рабочая нагрузка без параллелизма, запускающая один поток:

go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeURLSequentially                2        2814863021 ns/op
BenchmarkComputeURLConcurrently                7         838693786 ns/op

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

Пример рабочей нагрузки, использующей параллелизм и выполняющей десять потоков:

go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeURLSequentially-10             2        2548004562 ns/op
BenchmarkComputeURLConcurrently-10             7        1001367625 ns/op

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

Заключение

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

Однако каждую рабочую нагрузку необходимо тщательно анализировать, так как мы заметили, что алгоритм сортировки слиянием, привязанный к ЦП, не подходит для параллелизма и параллелизма из-за характера его реализации.

Наконец, я хочу отметить, что добавление параллелизма всегда сопряжено с дополнительной сложностью, поэтому используйте его с умом и только тогда, когда он повышает производительность!

Ресурсы