Автор оригинала: Ali Dehghani.
1. Обзор
Когда дело доходит до реализации пула потоков, стандартная библиотека Java предоставляет множество вариантов на выбор. Фиксированные и кэшированные пулы потоков довольно распространены среди этих реализаций.
В этом уроке мы увидим, как пулы потоков работают под капотом, а затем сравним эти реализации и их варианты использования.
2. Кэшированный пул потоков
Давайте посмотрим, как Java создает кэшированный пул потоков, когда мы вызываем Executors.newCachedThreadPool() :
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue()); }
Кэшированные пулы потоков используют “синхронную передачу” для постановки новых задач в очередь. Основная идея синхронной передачи проста и в то же время интуитивно понятна: можно поставить элемент в очередь тогда и только тогда, когда другой поток принимает этот элемент одновременно. Другими словами, /SynchronousQueue не может содержать никаких задач вообще.
Предположим, появляется новая задача. Если в очереди находится незанятый поток, то создатель задачи передает задачу этому потоку. В противном случае, поскольку очередь всегда заполнена, исполнитель создает новый поток для обработки этой задачи .
Кэшированный пул начинается с нулевых потоков и потенциально может вырасти до Integer.MAX_VALUE потоки. Практически единственным ограничением для пула кэшированных потоков являются доступные системные ресурсы.
Чтобы лучше управлять системными ресурсами, пулы кэшированных потоков удаляют потоки, которые остаются простаивающими в течение одной минуты.
2.1. Примеры использования
Конфигурация пула кэшированных потоков кэширует потоки (отсюда и название) в течение короткого периода времени, чтобы повторно использовать их для других задач. В результате это работает лучше всего, когда мы имеем дело с разумным количеством краткосрочных задач.
Ключ здесь – “разумный” и “недолговечный”. Чтобы прояснить этот момент, давайте оценим сценарий, в котором кэшированные пулы не подходят. Здесь мы представим миллион заданий, каждое из которых займет 100 микросекунд:
Callabletask = () -> { 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()); }
В отличие от пула кэшированных потоков, в этом пуле используется неограниченная очередь с фиксированным числом никогда не истекающих потоков . Поэтому вместо постоянно увеличивающегося числа потоков пул фиксированных потоков пытается выполнять входящие задачи с фиксированным количеством потоков . Когда все потоки будут заняты, исполнитель поставит новые задачи в очередь. Таким образом, у нас будет больше контроля над потреблением ресурсов нашей программы.
В результате фиксированные пулы потоков лучше подходят для задач с непредсказуемым временем выполнения.
4. Прискорбное Сходство
До сих пор мы только перечислили различия между кэшированными и фиксированными пулами потоков.
Все эти различия в сторону, они оба используют AbortPolicy в качестве своей политики насыщения . Поэтому мы ожидаем, что эти исполнители выдадут исключение, когда они не смогут принять и даже поставить в очередь больше задач.
Давайте посмотрим, что происходит в реальном мире.
Кэшированные пулы потоков будут продолжать создавать все больше и больше потоков в экстремальных обстоятельствах, поэтому практически они никогда не достигнут точки насыщения . Аналогично, фиксированные пулы потоков будут продолжать добавлять все больше и больше задач в свою очередь. Следовательно, фиксированные пулы также никогда не достигнут точки насыщения .
Поскольку оба пула не будут насыщены, при исключительно высокой нагрузке они будут потреблять много памяти для создания потоков или задач очереди. Добавляя оскорбление к травме, кэшированные пулы потоков также повлекут за собой множество переключений контекста процессора.
В любом случае, чтобы иметь больше контроля над потреблением ресурсов, настоятельно рекомендуется создать пользовательский ThreadPoolExecutor :
var boundedQueue = new ArrayBlockingQueue(1000); new ThreadPoolExecutor(10, 20, 60, SECONDS, boundedQueue, new AbortPolicy());
Здесь наш пул потоков может содержать до 20 потоков и может ставить в очередь только до 1000 задач. Кроме того, когда он больше не сможет принимать нагрузку, он просто выдаст исключение.
5. Заключение
В этом уроке мы заглянули в исходный код JDK, чтобы увидеть, как разные Executor s |/работают под капотом. Затем мы сравнили фиксированные и кэшированные пулы потоков и их варианты использования.
В конце концов, мы попытались решить проблему неконтролируемого потребления ресурсов этими пулами с помощью пользовательских пулов потоков.