Каков должен быть размер у Thread Pool?

в 14:14, , рубрики: java, threads, Блог компании Отус, Программирование

В нашей статье Stream API & ForkJoinPool мы уже рассказывали про возможности изменять размер пула потоков, который мы можем использовать в параллельных обработчиках, использующих Stream API или Fork Join. Надеюсь эта информация вам пригодилась, когда находясь на должности Senior Java Developer, вы смогли увеличить производительность разработанной вами системы, изменив размер пула по умолчанию. Так как наши курсы, в целом, заточены на переход ступеньку выше от джуниора и миддла выше, то часть программы строится исходя из основных вопросов задаваемых на собеседованиях. Один из из которых звучит так: «У вас есть приложение. И есть задача использующая Stream API или Fork Join, которая поддается распараллеливанию. При каких условиях вы можете счесть разумным изменить размер пула потоков заданный по умолчанию? Какой размер вы предложите в этом случае?»

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

Чтобы теоретические рассуждения подкрепить настоящими цифрами предлагаем погонять небольшой бенчмарк для стандартного метода Arrays.parallelSort(), реализующего разновидность алгоритма merge sort, и исполняемого на ForkJoinPool.commonPool(). Запустим этот алгоритм на одном и том же большом массиве с различными размерами commonPool и проанализируем результаты.

Каков должен быть размер у Thread Pool? - 1

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

Бенчмарк написан с использованием JMH и выглядит вот так:

```
package ru.klimakov;
import org.openjdk.jmh.annotations.*;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;
 
@Fork(1)
@Warmup(iterations = 10)
@Measurement(iterations = 5)
@BenchmarkMode(Mode.AverageTime )
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class MyBenchmark {
	@State(Scope.Benchmark)
	public static class BenchmarkState {
    	public static final int SEED = 42;
    	public static final int ARRAY_LENGTH = 1_000_000;
    	public static final int BOUND = 100_000;
    	volatile long[] array;
 
    	@Setup
    	public void initState() {
        	Random random = new Random(SEED);
        	this.array = new long[ARRAY_LENGTH];
        	for (int i = 0; i < this.array.length; i++) {
            	this.array[i] = random.nextInt(BOUND);
        	}
    	}
	}
 
	@Benchmark
	public long[] defaultParallelSort(BenchmarkState state) {
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
	@Benchmark
	public long[] twoThreadsParallelSort(BenchmarkState state) {
    	System.setProperty(
            	"java.util.concurrent.ForkJoinPool.common.parallelism", "2");
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
	@Benchmark
	public long[] threeThreadsParallelSort(BenchmarkState state) {
    	System.setProperty(
          	  "java.util.concurrent.ForkJoinPool.common.parallelism", "3");
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
	@Benchmark
	public long[] fourThreadsParallelSort(BenchmarkState state) {
    	System.setProperty(
                "java.util.concurrent.ForkJoinPool.common.parallelism", "4");
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
	@Benchmark
	public long[] fiveThreadsParallelSort(BenchmarkState state) {
    	System.setProperty(
                "java.util.concurrent.ForkJoinPool.common.parallelism", "5");
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
	@Benchmark
	public long[] tooLargePoolParallelSort(BenchmarkState state) {
    	System.setProperty(
                "java.util.concurrent.ForkJoinPool.common.parallelism", "128");
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
 
 
	@Benchmark
	public long[] singleThreadParallelSort(BenchmarkState state) {
    	System.setProperty(
                "java.util.concurrent.ForkJoinPool.common.parallelism", "1");
    	Arrays.parallelSort(state.array);
    	return state.array;
	}
 
	@Benchmark
	public long[] serialSort(BenchmarkState state) {
    	Arrays.sort(state.array);
    	return state.array;
	}
}
```

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

Каков должен быть размер у Thread Pool? - 2

Итак победителем рейтинга оказался fourThreadsParallelSort. В этом тесте мы задали размер пула равный количеству ядер на моей машине, а следовательно на единичку больше, чем дефолтный пул. Тем не менее, факт победы данного микро-бенчмарка не означает, что вам следует в своем приложении определять размер commonPool равным количеству ядер, а не использовать на один воркер меньше, как нам предлагают разработчики JDK. Мы бы предложили принять их точку зрения и для большинства приложений никогда не менять значение размера commonPool, однако если мы хотим выжать из машины максимум и готовы более общими макро-бенчмарками доказать что овчинка стоит выделки, то можем получить небольшой выигрыш в более полной утилизации CPU, установив commonPool равным количеству ядер. При этом не забудьте, что системные потоки, например Garbage Collector, станут конкурировать с вашим пулом за процессорное время и планировщик операционной системы станет прерывать ваши потоки делая переключения контекста.

defaultParallelSort – оказался на втором месте. Размер пула — 3 потока. В процессе выполнения наблюдалась загрузка CPU около 70%, в отличии от 90% в предыдущем случае. Следовательно оказались не верны рассуждения о том, что основной поток программы из которого мы вызываем параллельную сортировку станет полноценным воркером разгребающим задачи fork join.

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

tooLargePoolParallelSort – показал что если и дальше увеличивать размер пула (в данном случае до 128 потоков), то и дальше будем терять эффективность и еще плотнее утилизировать процессор (около 100%).

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

Вы можете спросить, а что же с serialSort и singleThreadParallelSort? А ничего. Их просто нельзя сравнивать с остальными вариантами, так там внутри вообще не merge sort, а совсем другой алгоритм — DualPivotQuickSort, и на разных входных данных может показывать результаты, как уступающие тестируемому алгоритму, так и превосходящие их. Можно сказать больше, на большинстве рандомных массивов, которые рассматривались, DualPivotQuickSort значительно обгонял Arrays.parallelSort, и просто повезло сгенерировать в этот раз такой массив, в котором parallelSort показал лучшие результаты. Поэтому и решили именно этот массив включить в бенчмарки, чтобы вы не подумали, что parallelSort вообще никогда не имеет смысла использовать. Иногда имеет, но не забудьте это проверить и доказать хорошими тестами.

Ну и наконец, давайте попробуем ответить на вопрос: «В каких случаях может быть разумным изменить размер commonPool?». Один случай мы уже рассмотрели — если мы хотим более плотно утилизировать CPU, то иногда может иметь смысл задать размер commonPool равным количеству ядер, а не на единицу меньше, как задано по дефолту. Другим случаем может быть ситуация, когда одновременно с нашим приложением запущены другие, и мы хотим вручную поделить процессорные ядра между приложениями (хотя, кажется, docker для этих целей был бы уместнее). И, наконец, может оказаться так, что задачи, которые мы очень хотим помещать в Fork Join Pool, не достаточно чистые. Если эти задачи позволяют себе не только вычислять и читать/писать в ОЗУ, но и спать, ждать или читать/писать в блокирующий источник ввода/вывода. В таком случае очень не рекомендуется помещать такие задачи в commonPool, ибо они займут драгоценный поток из пула, поток перейдет в режим ожидания, а ядро которое по задумке должно было бы утилизироваться этим потоком станет простаивать. Поэтому для таких немного сонных задач лучше создать отдельный, кастомный ForkJoinPool.

THE END

Вопросы и предложения как всегда приветствуются и тут, и на дне открытых дверей. Ждём-с.

Автор: MaxRokatansky

Источник

Поделиться

* - обязательные к заполнению поля