Перейти к основному содержимому

Исполнители newCachedThreadPool() против newFixedThreadPool()

· 4 мин. чтения

1. Обзор

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

В этом руководстве мы увидим, как пулы потоков работают «под капотом», а затем сравним эти реализации и варианты их использования.

2. Кэшированный пул потоков

Давайте посмотрим, как Java создает пул кешированных потоков, когда мы вызываем Executors.newCachedThreadPool() :

public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

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

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

Кэшированный пул начинается с нулевых потоков и потенциально может вырасти до потоков Integer.MAX_VALUE . Практически единственным ограничением для кэшированного пула потоков являются доступные системные ресурсы.

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

2.1. Сценарии использования

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

Ключевым здесь является «разумный» и «недолговечный». Чтобы прояснить этот момент, давайте оценим сценарий, в котором кэшированные пулы не подходят. Здесь мы собираемся отправить миллион задач, выполнение каждой из которых занимает 100 микросекунд:

Callable<String> task = () -> {
long oneHundredMicroSeconds = 100_000;
long startedAt = System.nanoTime();
while (System.nanoTime() - startedAt <= oneHundredMicroSeconds);

return "Done";
};

var cachedPool = Executors.newCachedThreadPool();
var tasks = IntStream.rangeClosed(1, 1_000_000).mapToObj(i -> task).collect(toList());
var result = cachedPool.invokeAll(tasks);

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

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

3. Фиксированный пул потоков

Давайте посмотрим, как фиксированные пулы потоков работают внутри:

public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

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

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

4. Неудачное сходство

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

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

Посмотрим, что происходит в реальном мире.

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

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

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

var boundedQueue = new ArrayBlockingQueue<Runnable>(1000);
new ThreadPoolExecutor(10, 20, 60, SECONDS, boundedQueue, new AbortPolicy());

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

5. Вывод

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

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