Что такое пул потоков
Перейти к содержимому

Что такое пул потоков

Как это работает в мире java. Пул потоков

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

Представим, что у вас которая вызывает большую загрузку процессора:

Мы хотим как можно быстрее обработать ряд таких задач, попробуем*:

На моей тачке с 4 физическими ядрами использование ресурсов процессора top -pid :

image

Время выполнения 104 сек.

Как вы заметили, загрузка одного процессора на один java-процесс с одним выполняемым потоком составляет 100%, но общая загрузка процессора в пользовательском пространстве составляет всего 2,5%, и у нас есть много неиспользуемых системных ресурсов.

Давайте попробуем использовать больше, добавив больше рабочих потоков:

image

ThreadPoolExecutor

Для ускорения мы использовали ThreadPool — в java его роль играет ThreadPoolExecutor, который может быть реализован непосредственно или из одного из методов в классе Utilities. Если мы заглянем внутрь ThreadPoolExecutor, мы можем найти очередь:

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

Каждый addWorker запускает новый поток с задачей Runnable, которая опрашивает workQueue на наличие новых задач и выполняет их.

ThreadPoolExecutor имеет очень понятный javadoc, поэтому нет смысла его перефразировать. Вместо этого, давайте попробуем сделать наш собственный:

Теперь давайте выполним ту же задачу, что и выше, с нашим пулом.
Меняем строку в MultithreadClient:

Время выполнения практически одинаковое — 15 секунд.

Размер пула потоков

Попробуем еще больше увеличить количество запущенных потоков в пуле — до 100.

Мы можем видеть, что время выполнения увеличилось до 28 секунд — почему это произошло?

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

Количество переключений контекста процесса можно увидеть, посмотрев на csw параметр при выводе команды top.

На 8 потоках:
image

На 100 потоках:
image

Как выбрать размер пула?

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

Предполагая, что потоки не блокируют друг друга, нет циклов ожидания I/O, и время обработки задач одинаково, оптимальный пул потоков = Runtime.getRuntime().availableProcessors() + 1.

Если потоки в основном ожидают I/O, то оптимальный размер пула должен быть увеличен на отношение между временем ожидания процесса и временем вычисления. Например. У нас есть процесс, который тратит 50% времени в iowait, тогда размер пула может быть 2 * Runtime.getRuntime().availableProcessors() + 1.

Другие виды пулов

Пул потоков с ограничением по памяти, который блокирует отправку задачи, когда в очереди слишком много задач MemoryAwareThreadPoolExecutor

Пул управляемых потоков

Класс System.Threading.ThreadPool обеспечивает приложение пулом рабочих потоков, управляемых системой, позволяя пользователю сосредоточиться на выполнении задач приложения, а не на управлении потоками. Если имеются небольшие задачи, которые требуют фоновой обработки, пул управляемых потоков — это самый простой способ воспользоваться преимуществами нескольких потоков. В Framework 4 и более поздних версиях использовать пул потоков стало значительно проще, так как вы можете создавать объекты Task и Task<TResult>, которые выполняют в потоках пула асинхронные задачи.

.NET использует потоки пула потоков для многих целей, включая операции библиотеки параллельных задач (TPL) , асинхронное завершение ввода-вывода, обратные вызовы таймера , зарегистрированные операции ожидания, асинхронные вызовы методов с использованием делегатов и соединения сокетов.

Характеристики пула потоков

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

Для каждого процесса существует только один пул потоков.

Исключения в потоках из пула потоков

Необработанные исключения в потоках из пула приводят к завершению процесса. Есть три исключения из этого правила:

  • Исключение System.Threading.ThreadAbortException возникает в потоке пула вследствие вызова Thread.Abort.
  • Исключение System.AppDomainUnloadedException возникает в потоке пула вследствие выгрузки домена приложения.
  • Среда CLR или процесс ведущего приложения прерывает выполнение потока.

Максимальное число потоков в пуле потоков

Число операций, которое можно поставить в очередь в пуле потоков, ограничено только доступной памятью. Однако пул потоков имеет ограничение на число потоков, которое можно активировать в процессе одновременно. Если все потоки в пуле заняты, дополнительные рабочие элементы помещаются в очередь и ожидают их освобождения. Размер по умолчанию пула потоков для процесса зависит от нескольких факторов, таких как размер виртуального адресного пространства. Процесс может вызвать метод ThreadPool.GetMaxThreads для определения количества потоков.

Вы можете управлять максимальным количеством потоков с помощью методов ThreadPool.GetMaxThreads и ThreadPool.SetMaxThreads.

В коде, содержащем среду CLR, этот размер можно задать с помощью метода ICorThreadpool::CorSetMaxThreads .

Минимальные значения пула потоков

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

Если потребность низкая, фактическое количество потоков из пула потоков может быть ниже минимальных значений.

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

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

Использование пула потоков

Самым простым способом использования пула потоков является применение библиотеки параллельных задач (TPL). По умолчанию такие типы TPL, как Task и Task<TResult>, используют потоки из пула для выполнения задач.

Пул потоков также можно использовать путем вызова ThreadPool.QueueUserWorkItem из управляемого кода (или ICorThreadpool::CorQueueUserWorkItem из неуправляемого кода) и передачи делегата System.Threading.WaitCallback, представляющего метод, который выполняет задачу.

Другим способом использования пула потоков является помещение в очередь рабочих элементов, которые имеют отношение к операции ожидания, с помощью метода ThreadPool.RegisterWaitForSingleObject и передача дескриптора System.Threading.WaitHandle, который вызывает метод, представленный делегатом System.Threading.WaitOrTimerCallback, при получении сигнала или истечении времени ожидания. Потоки из пула потоков используются для вызова методов обратного вызова.

Примеры см. по ссылкам на страницы API.

Пропуск проверок безопасности

Пул потоков также предоставляет методы ThreadPool.UnsafeQueueUserWorkItem и ThreadPool.UnsafeRegisterWaitForSingleObject. Используйте эти методы только в том случае, если вы уверены, что стек вызывающего объекта не важен для проверок безопасности, осуществляемых во время выполнения задачи в очереди. ThreadPool.QueueUserWorkItem и ThreadPool.RegisterWaitForSingleObject перехватывают стек вызывающего объекта, который объединяется со стеком потока из пула потоков, когда поток начинает выполнять задачу. Если требуется проверка безопасности, проверяется весь стек. Несмотря на обеспечение безопасности, такая проверка также влияет на производительность.

Когда не следует использовать потоки из пула потоков

Существует ряд сценариев, в которых следует создавать собственные потоки и работать с ними, а не использовать потоки из пула:

Фреймворк Executor в Java

Java

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

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

Из этой статьи вы узнаете о фреймворке Executor, пуле потоков и различных методах их создания, а также о том, как с их помощью управлять потоками. Давайте начнем.

Интерфейсы и классы Executor

Во фреймворке Executor присутствуют три интерфейса: Executor , ExecutorService и ScheduledExecutorService .

Executor — простой интерфейс, содержащий метод execute() для запуска задачи, заданной запускаемым объектом Runnable .

ExecutorService представляет собой суб-интерфейс Executor , который добавляет функциональность для управления жизненным циклом потоков. Он также включает в себя метод submit() , который аналогичен методу execute() , но более универсален. Перегруженные версии метода submit() могут принимать как выполняемый ( Runnable ), так и вызываемый ( Callable ) объект. Вызываемые объекты аналогичны выполняемым, за тем исключением, что задача, определенная вызываемым объектом, также может возвращать значение. Поэтому, если мы передаем объект Callable методу submit() , он возвращает объект Future . Этот объект можно использовать для получения возвращаемого значения Callable и управления статусом как Callable , так и Runnable задач.

ScheduledExecutorService , в свою очередь, — это суб-интерфейс ExecutorService . Он добавляет функциональность, которая позволяет планировать выполнение задач в коде.

Помимо трех вышеупомянутых интерфейсов, Executor Framework также содержит класс Executors , который по умолчанию включает в себя методы для создания различных типов служб-исполнителей. С помощью этого класса и интерфейсов можно создавать пулы потоков. Что же это такое?

Пулы потоков

Пул потоков — это набор объектов Runnable и постоянно работающих потоков. Коллекция объектов Runnable называется рабочей очередью. Постоянно запущенные потоки проверяют рабочий запрос на наличие новой работы, и если новая работа должна быть выполнена, то из рабочей очереди будет запущен объект Runnable . Чтобы использовать фреймворк Executor, нам нужно создать пул потоков и отправить туда задачу для выполнения. В классе Executors есть четыре основных метода, которые используются для создания пулов потоков. Рассмотрим каждый из них на примере.

newSingleThreadExecutor()

В данном примере мы отправляем на исполнение пять задач. Но так как применяется метод newSingleThreadExecutor() , будет создан только один новый поток и одновременно будет выполняться только одна задача. Остальные четыре задачи находятся в очереди ожидания. Как только задача выполнится потоком, этот поток тут же выберет и выполнит следующую. Метод shutdown() ожидает завершения выполнения задач, в настоящий момент переданных исполнителю, чтобы завершить его работу. Однако, если вам хочется завершить работу исполнителя без ожидания, используйте вместо этого метод shutdownNow() .

newFixedThreadPool()

Использован тот же пример, что и в предыдущем случае, только на этот раз — с методом newFixedThreadPool() . Этот метод позволяет создать пул с фиксированным количеством потоков. Таким образом, когда мы отправим пять задач, в коде будет создано три новых потока и будут выполнены три задачи. Остальные две задачи находятся в очереди ожидания. Как только какая-либо задача выполнится потоком, этим же потоком будет выбрана и выполнена следующая задача.

newCachedThreadPool()

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

В данном примере метод newCachedThreadPool() изначально создаст пять новых потоков и обработает пять задач. Никакой очереди ожидания здесь не будет. Если поток остается в бездействии более минуты, метод устраняет его. Таким образом, этот метод — хороший выбор, если вам хочется добиться большей производительности очереди, чем это возможно с методом newFixedThreadPool() . Но если вы хотите ограничить количество параллельно выполняемых задач во имя управления ресурсами, лучше использовать newFixedThreadPool() .

newScheduledThreadPool()

Метод newScheduledThreadPool() создает пул потоков, который может планировать выполнение задач после заданной задержки или через регулярные промежутки времени. Этот метод возвращает ScheduledExecutorService . Существует три метода для планирования задач в таком пуле потоков: schedule() , scheduleAtFixedRate() и scheduleWithFixedDelay() . Рассмотрим пример реализации пула потоков с помощью метода schedule() .

Как видно из примера:

  • Метод schedule принимает три аргумента: задачу, задержку и промежуток времени задержки.
  • Метод schedule() используется для планирования задачи после фиксированной задержки.
  • Метод scheduleAtFixedRate() используется для планирования задачи после фиксированной задержки и последующего периодического выполнения этой задачи.
  • Метод scheduleWithFixedDelay() используется для планирования задачи после начальной задержки, а затем выполнения задач с фиксированной задержкой после завершения предыдущей задачи.

Каждый из этих методов полезен в своём определённом сценариев.

Вот и всё о пулах потоков. Теперь — некоторые важные замечания насчет того, как использовать фреймворк Executor.

Важные замечания

  • Никогда не ставьте в очередь задачи, которые в это самое время ожидают результатов от других задач. Это может привести к тупику.
  • Пул потоков после окончания работы должен быть явно завершен путем вызова метода shutdown() . Если этого не сделать, программа будет продолжать работать без конца. Если вы отправите исполнителю другую задачу после завершения работы, она выдаст исключение RejectedExecutionException .
  • Будьте осторожны, применяя потоки для длительных операций. Это может привести к бесконечному ожиданию потока и в конечном итоге — к утечке ресурсов.
  • Для эффективной настройки пула потоков необходимо понимать особенности задач. Если задачи очень разные, имеет смысл использовать разные пулы потоков для разных типов задач, чтобы правильно их настроить.

На этом всё. Надеюсь, пулы потоков и Java ExecutorService стали для вас понятнее. Спасибо за чтение и счастливого кодирования!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *