Программирование GoТестированиеGo-разработчик по производительности

В benchmark Go нагрузка распределяется через RunParallel. Как интерпретировать SetParallelism и почему его ...

В benchmark Go нагрузка распределяется через RunParallel. Как интерпретировать SetParallelism и почему его значение нельзя считать числом одновременно выполняющихся горутин?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

SetParallelism задаёт множитель для числа рабочих горутин, которые использует RunParallel: целевое число обычно рассчитывается как значение параметра, умноженное на текущий GOMAXPROCS. Это не число одновременно исполняющихся горутин: GOMAXPROCS ограничивает число горутин, которые могут одновременно выполнять Go-код на процессорах, а остальные могут ожидать планирования или блокировки.

Исторический контекст

Обычный benchmark выполняет операции последовательно, поэтому плохо показывает поведение кода при конкуренции за общий ресурс: mutex, пул соединений, кэш или атомарную переменную. RunParallel появился как стандартный способ распределить итерации benchmark между рабочими горутинами и измерить пропускную способность конкурентной операции.

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

Постановка проблемы

Если принять значение SetParallelism за количество одновременно работающих горутин, можно неверно настроить эксперимент. Например, при GOMAXPROCS, равном 8, значение параллелизма 2 ориентирует RunParallel примерно на 16 рабочих горутин, но одновременно исполнять Go-код обычно смогут не более 8 из них.

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

Подробное решение

При запуске RunParallel тестовый фреймворк создаёт рабочие горутины. Каждая из них многократно вызывает тело benchmark, пока общий объём работы не достигнет рассчитанного значения b.N; отдельная итерация не обязана выполняться в отдельной горутине.

SetParallelism(p) задаёт множитель для числа рабочих горутин относительно текущего GOMAXPROCS. Значение по умолчанию — 1, поэтому типичный запуск ориентирован примерно на одну рабочую горутину на доступную Go-программе единицу процессорного параллелизма.

package cache import "testing" func BenchmarkLookupParallel(b *testing.B) { cache := newCache() b.SetParallelism(2) b.RunParallel(func(pb *testing.PB) { for pb.Next() { cache.Lookup("key") } }) }

В этом примере значение 2 не означает «две одновременно исполняющиеся горутины». При GOMAXPROCS, равном 4, benchmark ориентируется примерно на 8 рабочих горутин. Однако одновременно выполнять Go-код сможет не более числа, разрешённого GOMAXPROCS; часть горутин может блокироваться или ждать планировщика.

SetParallelism не изменяет GOMAXPROCS и не задаёт число операционных потоков. Он также влияет только на рабочие горутины RunParallel, а не на все горутины процесса и не на число повторных запусков benchmark при разных значениях флага -cpu.

Для сравнения реализаций нужно сохранять одинаковую модель нагрузки. Если у одного benchmark параллелизм равен 1, а у другого — 8, их результаты описывают разные режимы работы и не дают честного сравнения без дополнительного объяснения.

Ситуация из практики

Команда измеряла конкурентный кэш. Последовательный benchmark был простым и стабильным, но не показывал деградацию при конкуренции за блокировку. Вариант с обычным циклом оказался непригоден для этой цели, потому что измерял только последовательный путь; вариант с RunParallel по умолчанию лучше отражал нагрузку на машине разработчика, но не моделировал большое число клиентов.

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

Что кандидаты часто упускают

  1. Вопрос: Гарантирует ли RunParallel, что каждая итерация benchmark выполняется в новой горутине?

    Ответ: Нет. Рабочая горутина получает объект PB и вызывает Next() в цикле. Один worker выполняет множество итераций, поэтому создание горутины не является частью каждой измеряемой операции. Общее число итераций распределяется между workers, а не преобразуется в такое же количество горутин.

  2. Вопрос: Изменит ли SetParallelism значение GOMAXPROCS?

    Ответ: Нет. SetParallelism изменяет количество рабочих горутин, используемых RunParallel, но не число процессоров, на которых планировщик может одновременно исполнять Go-код. Если нужно исследовать влияние числа процессоров, это делают отдельными запусками с разными настройками GOMAXPROCS, явно фиксируя их в результатах.

  3. Вопрос: Можно ли напрямую сравнить результаты двух RunParallel-benchmark с разными значениями SetParallelism?

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