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 работают
под капотом. Затем мы сравнили фиксированные и кэшированные пулы потоков и варианты их использования.
В конце концов, мы попытались решить проблему неконтролируемого потребления ресурсов этими пулами с помощью настраиваемых пулов потоков.